# `dive`, rebuilt under SAMA v2 — a thought experiment [Today's audit](/blog/2026-05/sama-v2-go-project-dive) walked through `wagoodman/dive` and concluded it already scores roughly 5 of 7 §4 checks naturally. Go's standard layout (`cmd/`, `internal/`, package-per-concern) plus the language's `internal/` semantics enforce most of what v2 asks for. The two checks that don't pass for free: **#1 Sorted** (Go organizes by package directory, not filename prefix — incompatible with the v2.0 lex-sort-the-prefixes rule) and **#3 Modeled-tests** (18 test files for 92 source files, with the gaps clustered in the image-adapter packages). Bas asked the same companion question he asked for the [WordPress rebuild post](/blog/2026-05/sama-v2-wordpress-plugin-rebuilt): what would `dive` look like if it had been laid out for v2 from day one? Same scope, same features, same user-facing behavior, same idiomatic Go — just enough decisions made deliberately to score 7/7 under a directory-based v2 dialect. This sketch is much smaller than the WordPress one. The starting point is already much closer; the lift is days of work, not months. Which is itself the finding. ## The directory-based dialect this sketch assumes *(Update: this dialect has since been drafted formally into [/sama/v2 §6.1 Directory-layout dialect](/sama/v2#61-directory-layout-dialect) as a v2.1-draft extension. The discussion below is the original informal proposal from this post; the spec section is the canonical reference.)* v2.0's §4.1 Sorted check is written with TypeScript/PHP filename conventions in mind: *"every file carries a profile-recognized prefix; lexicographic prefix order equals layer order."* The Sorted check is what makes the layer-from-`ls` property real — a reviewer can glance at `ls src/` and read dependency direction off the file ordering. Go doesn't work like that. Files inside a Go package have descriptive names (`comparer.go`, `diff.go`, `efficiency.go`) and no architectural ordering between them; the layering happens at the **package directory** level. So for Go, the Sorted check translates to: *"the profile declares packages in layer order, and Go's compiler-enforced `internal/` semantics + the absence of upward edges in the import graph confirm that the lex-order of declared package paths matches the actual import direction."* That's a v2.1-style profile extension — falsifiable, the same property under a different surface syntax. The rest of this post writes the rebuilt `dive` under that hypothetical extension. If the spec doesn't get the extension, half the moves below stop making sense and the codebase stays at ~5/7. Which is also fine — the audit's argument was that 5/7 is a useful score, not a failure. ## What stays exactly the same Before the rebuild, the contract that doesn't move: - `dive` still explores Docker image layers, computes efficiency scores, runs in CI mode, exports JSON reports, and ships as a single Go binary. - The CLI invocation surface (`dive `, `dive build -t ... .`, `CI=true dive ...`) is unchanged. - The `.dive.yaml` config format is unchanged; existing users' configs still load. - All public APIs of the `dive/` packages stay valid; third-party importers don't break. - The `cobra`-based command structure, the `tview`/`tcell`-based TUI, the Docker/Podman dual-engine support — all unchanged. What changes is *where each piece of work is declared to live*, not what the user gets. ## The profile ```toml sama_version = "2.0" profile = "dive" layout = "directory" # ← v2.1 dialect: layer mapping by package path, # not filename prefix. Sorted check verifies # "no import edge violates the array order in # each [layers.N] block below." # Layer 0 — Pure. Shared helpers with no domain coupling, no I/O, # no clock. Re-importable by any other package without compiling # in unexpected behavior. [layers.0] packages = [ "internal/utils", # CleanArgs, format helpers "internal/log", # log façade (the impl lives in Layer 2) "internal/bus", # in-memory event bus type "internal/bus/event", "internal/bus/event/payload", ] # Layer 1 — Core. Domain logic, viewmodel state, pure render. No # filesystem, no network, no exec.Command, no Docker daemon. [layers.1] sublayers = [ { name = "domain", packages = ["dive/filetree", "dive/image"] }, { name = "viewmodel", packages = ["cmd/dive/cli/internal/ui/v1/viewmodel"] }, { name = "view", packages = [ "cmd/dive/cli/internal/ui/v1/view", "cmd/dive/cli/internal/ui/v1/layout", "cmd/dive/cli/internal/ui/v1/format", "cmd/dive/cli/internal/ui/v1/key", "cmd/dive/cli/internal/ui/v1/config", ]}, { name = "evaluator", packages = ["cmd/dive/cli/internal/command/ci"] }, ] # Layer 2 — Adapter. The boundary. Filesystem reads, image archive # parsing, Docker/Podman daemon calls, exec.Command shelling, YAML/ # JSON parsing of external input. [layers.2] sublayers = [ { name = "resolver", packages = ["dive/image/docker", "dive/image/podman"] }, { name = "loader", packages = ["dive/filetree_loader"] }, # ← NEW (split out of filetree/) { name = "config", packages = ["cmd/dive/cli/internal/options"] }, ] # Layer 3 — Entry. Command setup, dependency wiring, TUI bootstrap. # Composes c3/c1 controllers + b2/b1 services + view/viewmodel. [layers.3] packages = [ "cmd/dive", "cmd/dive/cli", "cmd/dive/cli/internal/command", "cmd/dive/cli/internal/command/root", "cmd/dive/cli/internal/command/build", "cmd/dive/cli/internal/command/export", "cmd/dive/cli/internal/command/adapter", "cmd/dive/cli/internal/ui", "cmd/dive/cli/internal/ui/v1", "cmd/dive/cli/internal/ui/v1/app", ] ``` Two real structural changes hide behind that declaration: `dive/filetree/` splits into `dive/filetree/` (pure) plus a new `dive/filetree_loader/` (Layer 2 adapter), and `cmd/dive/cli/internal/command/ci/` moves from "miscellaneous command package" to "Layer 1 evaluator sublayer" because the CI evaluation logic is pure decision-making (matches a `b1_` policy in TS-flavored profiles). ## The directory Compared to today's `dive`, ~95% of the file tree is identical. The diff is: ``` dive/ ├── filetree/ # ↓ trimmed: only pure tree algebra │ ├── comparer.go ← unchanged │ ├── diff.go ← unchanged │ ├── efficiency.go ← unchanged │ ├── file_node.go ← unchanged (the value type) │ ├── file_tree.go ← unchanged (the tree algebra) │ ├── node_data.go ← unchanged │ ├── order_strategy.go ← unchanged │ ├── path_error.go ← unchanged │ ├── view_info.go ← unchanged │ └── (file_info.go REMOVED — moved into filetree_loader/) │ ├── filetree_loader/ # ← NEW package (Layer 2) │ ├── file_info.go ← MOVED: os.Lstat, filesystem walking │ └── file_info_test.go ← NEW │ ├── image/ # unchanged ├── image/docker/ # unchanged file list, sibling tests added ├── image/podman/ # unchanged file list, sibling tests added └── (rest unchanged) cmd/dive/cli/internal/ # unchanged structure ├── command/ci/ # unchanged files; profile now declares it Layer 1 ├── command/root,build,export,adapter/ # unchanged ├── options/ # unchanged; profile declares it Layer 2 config sublayer └── ui/v1/ # unchanged; siblings filled in where missing internal/ # unchanged ``` Plus around **30 new `*_test.go` files** sprinkled across `dive/image/`, `dive/image/docker/`, `dive/image/podman/`, the `cmd/.../command/` tree, and the UI `view/` files. None of the existing source files move; they just gain siblings. That's the entire structural diff. Two real-file moves (`file_info.go` and a new package directory), thirty test files written. No god-class splits, no API breaks, no contract changes. ## Layer 0 — Pure (unchanged) `internal/utils/`, `internal/log/`, `internal/bus/` already meet the Layer 0 bar: no domain knowledge, no I/O, no framework coupling. The `log` package is a façade — the actual sink for log writes lives at Layer 2 wiring time, the package itself just defines `Fields`, `Trace`, `WithFields`, etc. as pure value-and-builder functions. ```go // internal/utils/format.go — pure helper func CleanArgs(args []string) []string { out := args[:0] for _, a := range args { if a != "" { out = append(out, a) } } return out } ``` No changes to these packages. ## Layer 1 — Core, with three named sublayers The audit identified that `dive/filetree/` is *mostly* pure (tree algebra, diff computation, ordering strategies) but contains one file — `file_info.go` — that calls `os.Lstat` and walks the filesystem. That single file is what would force the entire package into Layer 2 under a strict reading. The rebuild splits it: ```go // dive/filetree/file_tree.go — pure (unchanged) package filetree func (t *FileTree) Diff(other *FileTree) (*FileTree, error) { // ... pure tree-diff algorithm, operates on already-populated // FileNode structures. No filesystem reads. } ``` ```go // dive/filetree_loader/file_info.go — NEW package, Layer 2 package filetree_loader import ( "os" "github.com/wagoodman/dive/dive/filetree" // imports Layer 1 down to Layer 1 ) // FromFilesystem walks a directory and populates a *filetree.FileTree. // THIS is where os.Lstat lives. func FromFilesystem(rootPath string) (*filetree.FileTree, error) { tree := filetree.NewFileTree() err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { info, err := os.Lstat(path) // ... populate tree via tree.AddPath(path, fileNodeFrom(info)) }) return tree, err } ``` Now `dive/filetree/` is 100% pure tree algebra and easy to unit-test with synthetic fixtures. `dive/filetree_loader/` is the adapter that turns "a path on disk" into "a populated FileTree." Each has its own sibling tests; importers of `dive/filetree/` no longer pull in a transitive `os` dependency. The `cmd/dive/cli/internal/command/ci/` directory contains `evaluator.go` (308 LOC) and `rules.go` (196 LOC) — pure decision-making over a `*filetree.FileTree` and the user's `.dive-ci.yaml`. The audit noted this is Layer 1 work, not Layer 3 entry work. The profile declares it as the `evaluator` sublayer; the files themselves don't move. ```go // cmd/dive/cli/internal/command/ci/evaluator.go — Layer 1 (sublayer "evaluator") // Pure: given a tree + rules, returns pass/fail + reasons. type Evaluator struct { rules []Rule } func (e *Evaluator) Evaluate(tree *filetree.FileTree, efficiency float64) Result { var failures []Failure for _, r := range e.rules { if !r.Check(tree, efficiency) { failures = append(failures, Failure{Rule: r, ...}) } } return Result{Failures: failures, Pass: len(failures) == 0} } ``` The `viewmodel/` and `view/` directories under `cmd/dive/cli/internal/ui/v1/` are the TUI's M and V respectively (`viewmodel/filetree.go` 472 LOC manages selection state; `view/filetree.go` 496 LOC renders to a `tview.Box`). Both are Layer 1 work — they react to events, transform domain values to display, and call no I/O directly. The audit confirmed both packages already live entirely in their own subtrees. The profile declares them as separate sublayers (viewmodel before view, since view consumes viewmodel state). ## Layer 2 — Adapter, with three sublayers The audit found boundary parsing was already 85% well-localized. The rebuild closes the remaining 15%: ```go // dive/image/docker/image_archive.go — Layer 2 (sublayer "resolver"), unchanged // JSON-Unmarshal'ing the Docker image manifest happens here, where it belongs. func newImageArchive(tarFile io.ReadCloser) (*ImageArchive, error) { // ... read tar entries, json.Unmarshal manifest.json, json.Unmarshal config.json } // dive/image/docker/cli.go — Layer 2, unchanged // exec.Command("docker", ...) shelling, isolated to this file func runDockerCmd(cmdStr string, args ...string) error { cmd := exec.Command("docker", utils.CleanArgs(append([]string{cmdStr}, args...))...) // ... } ``` `cmd/dive/cli/internal/options/ci.go` — the borderline-concern from the audit, which parses the user's `.dive-ci.yaml` while sitting inside the `cmd/` tree — is reclassified rather than moved. Under the directory-based dialect, the profile simply declares `cmd/dive/cli/internal/options/` as Layer 2's `config` sublayer. That's honest about what it does (parsing external input) without forcing a physical move that fights Go's idiom (config types living near the commands that use them). ```go // cmd/dive/cli/internal/options/ci.go — Layer 2 (sublayer "config") // THE single point where YAML-Unmarshal'ing user config happens. type Ci struct { Rules []ciRule `yaml:"rules"` } func LoadCi(path string) (*Ci, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var ci Ci if err := yaml.Unmarshal(raw, &ci); err != nil { return nil, err } return &ci, nil } ``` The `dive/filetree_loader/` package added earlier rounds out the adapter sublayer — it's the third site where the codebase reaches out to the filesystem (the other two being `docker/` and `podman/`). ## Layer 3 — Entry (lightly trimmed) The CLI command files in `cmd/dive/cli/internal/command/{root,build,export,adapter}/` stay where they are. Each command's `Run` function is already the right shape: parse flags via cobra → call Layer 2 to resolve an image → call Layer 1 to diff/evaluate → render via the TUI app or write JSON to stdout. Nothing in these commands implements business logic inline. ```go // cmd/dive/cli/internal/command/root/root.go — Layer 3 (unchanged) RunE: func(cmd *cobra.Command, args []string) error { appOpts, err := options.LoadApplication(cmd) // L2 config if err != nil { return err } resolver := docker.NewResolver(appOpts) // L2 adapter image, err := resolver.Fetch(args[0]) // L2 call if err != nil { return err } analysis, err := image.Analyze() // L1 domain if err != nil { return err } if isCI() { evaluator := ci.NewEvaluator(appOpts.CiRules) // L1 evaluator return evaluator.Evaluate(analysis).IntoExitError() } return ui.Run(analysis, appOpts.UI) // L3 TUI bootstrap (composes L1 view/viewmodel) }, ``` The wiring is already this shape today. No change. ## What concretely changes | change | size | difficulty | |---|---|---| | 1. Add `sama.profile.toml` declaring layer mapping | ~50 lines | trivial — no code change | | 2. Split `dive/filetree/file_info.go` into `dive/filetree_loader/` package | one move + small import adjustment in ~3 callers | half-day | | 3. Write sibling tests for `dive/image/docker/image_archive.go` | ~150 lines + a fixture image | one day | | 4. Write sibling tests for `dive/image/docker/engine_resolver.go` (needs a fake daemon) | ~100 lines + interface extraction | one day | | 5. Write sibling tests for `dive/image/podman/build.go`, `cli.go`, `resolver.go` | ~250 lines | two days | | 6. Write sibling tests for `cmd/dive/cli/internal/command/{root,build,ci}/...` Layer 1/L2-classified files | ~400 lines, mostly fixture-driven | three days | | 7. Write sibling tests for TUI `view/` files using rendered-string assertions | ~300 lines | three days | | **total** | **~30 new test files, one package split, one config file** | **~10 working days** | For context, the WP plugin's parallel-architecture rebuild required splitting a 1,554-line god-class into eleven files, redesigning the settings option as a typed value, and writing 20+ test files from scratch. Months of work, plus a real risk of breaking the PRO add-on, WooCommerce, Yoast, and AIOSEO integrations. `dive` to 7/7 is two weeks of focused work, no breaking changes, no user-facing impact. ## Predicted §5 metrics for the rebuilt dive | metric | dive today (estimated) | dive rebuilt (predicted) | tdd.md (measured) | |---|---|---|---| | §4 checks passing | ~5 / 7 | **7 / 7** | 7 / 7 | | graphDepth | ~5 | ~5 (unchanged — no architectural depth changes) | 7 | | boundaryRatio | ~85% | **~100%** (after `filetree_loader/` split) | 100% | | workingSetFit (50–500 LOC) | ~80% | ~80% (file sizes essentially unchanged) | 80% | | violationCounts (sum) | ~30 (mostly missing tests) | **0** | 0 | `workingSetFit` doesn't move because dive's file sizes are already healthy. `boundaryRatio` rises to 100% because the `filetree_loader/` split moves the last filesystem-touching code into a dedicated Layer 2 package. The big change is `violationCounts`, dropping from ~30 to 0 — almost entirely the closed gaps in Modeled-tests. ## What this sketch actually surfaces Three observations: **1. The directory-based dialect is the real ask.** The two §4 checks `dive` fails (Sorted, Modeled-tests) split into one that's a *spec gap* and one that's *missing work*. The Modeled-tests gap is on `dive`; the Sorted gap is on v2.0. The rebuild sketch can't honestly score `dive` at 7/7 without writing down the directory-based extension, because v2.0's lex-sort-the-prefixes rule doesn't apply to Go at all. Any further v2 audits against Go projects will need this extension or they'll waste energy debating Sorted-by-prefix-vs-package on every codebase. **2. The work-cost of going from "natural Go architecture" to "v2-compliant Go architecture" is small.** Two weeks for a 92-file, 8,500-LOC codebase. That's the cost a real maintainer would weigh against the benefit. The benefit isn't sketched here — it's whatever the §5 deltas turn out to predict about real-world outcomes (fewer agent regressions, faster onboarding, less drift) in the future delta-experiment phase. **3. Go ecosystems are a cheap source of v2 baseline data.** Most Go projects of dive's scope score ~5/7 with their existing layout, just by following community conventions. If the directory-based dialect lands in v2.1, the §5 metrics emitter could be ported to Go in a few hundred lines, and the empirical baseline could grow from n=3 (this site + dive + WP plugin) to n=20+ in a single afternoon by running the metrics across the popular Go projects in the GitHub Top-100 list. That's the kind of cross-repo evidence §6 of the spec explicitly calls for before promoting an extension to official. --- **Companion posts:** - [Today's `dive` audit](/blog/2026-05/sama-v2-go-project-dive) — the source of the ~5/7 score this rebuild starts from - [The WordPress audit](/blog/2026-05/sama-v2-wordpress-plugin-audit) — the 0/7 baseline from a different ecosystem - [The WordPress rebuild](/blog/2026-05/sama-v2-wordpress-plugin-rebuilt) — what 7/7 looked like for that codebase - [The §5 metrics emitter](/blog/2026-05/sama-v2-metrics-emitter) — what makes deltas measurable in the first place - [The v2 spec](/sama/v2) — the rules being audited against