| 1 | +# `dive`, rebuilt under SAMA v2 — a thought experiment |
| 2 | + |
| 3 | +[Today's audit](/blog/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). |
| 4 | + |
| 5 | +Bas asked the same companion question he asked for the [WordPress rebuild post](/blog/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. |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## The directory-based dialect this sketch assumes |
| 10 | + |
| 11 | +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. |
| 12 | + |
| 13 | +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."* |
| 14 | + |
| 15 | +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. |
| 16 | + |
| 17 | +## What stays exactly the same |
| 18 | + |
| 19 | +Before the rebuild, the contract that doesn't move: |
| 20 | + |
| 21 | +- `dive` still explores Docker image layers, computes efficiency scores, runs in CI mode, exports JSON reports, and ships as a single Go binary. |
| 22 | +- The CLI invocation surface (`dive <image-tag>`, `dive build -t ... .`, `CI=true dive ...`) is unchanged. |
| 23 | +- The `.dive.yaml` config format is unchanged; existing users' configs still load. |
| 24 | +- All public APIs of the `dive/` packages stay valid; third-party importers don't break. |
| 25 | +- The `cobra`-based command structure, the `tview`/`tcell`-based TUI, the Docker/Podman dual-engine support — all unchanged. |
| 26 | + |
| 27 | +What changes is *where each piece of work is declared to live*, not what the user gets. |
| 28 | + |
| 29 | +## The profile |
| 30 | + |
| 31 | +```toml |
| 32 | +sama_version = "2.0" |
| 33 | +profile = "dive" |
| 34 | +layout = "directory" # ← v2.1 dialect: layer mapping by package path, |
| 35 | + # not filename prefix. Sorted check verifies |
| 36 | + # "no import edge violates the array order in |
| 37 | + # each [layers.N] block below." |
| 38 | + |
| 39 | +# Layer 0 — Pure. Shared helpers with no domain coupling, no I/O, |
| 40 | +# no clock. Re-importable by any other package without compiling |
| 41 | +# in unexpected behavior. |
| 42 | +[layers.0] |
| 43 | +packages = [ |
| 44 | + "internal/utils", # CleanArgs, format helpers |
| 45 | + "internal/log", # log façade (the impl lives in Layer 2) |
| 46 | + "internal/bus", # in-memory event bus type |
| 47 | + "internal/bus/event", |
| 48 | + "internal/bus/event/payload", |
| 49 | +] |
| 50 | + |
| 51 | +# Layer 1 — Core. Domain logic, viewmodel state, pure render. No |
| 52 | +# filesystem, no network, no exec.Command, no Docker daemon. |
| 53 | +[layers.1] |
| 54 | +sublayers = [ |
| 55 | + { name = "domain", packages = ["dive/filetree", "dive/image"] }, |
| 56 | + { name = "viewmodel", packages = ["cmd/dive/cli/internal/ui/v1/viewmodel"] }, |
| 57 | + { name = "view", packages = [ |
| 58 | + "cmd/dive/cli/internal/ui/v1/view", |
| 59 | + "cmd/dive/cli/internal/ui/v1/layout", |
| 60 | + "cmd/dive/cli/internal/ui/v1/format", |
| 61 | + "cmd/dive/cli/internal/ui/v1/key", |
| 62 | + "cmd/dive/cli/internal/ui/v1/config", |
| 63 | + ]}, |
| 64 | + { name = "evaluator", packages = ["cmd/dive/cli/internal/command/ci"] }, |
| 65 | +] |
| 66 | + |
| 67 | +# Layer 2 — Adapter. The boundary. Filesystem reads, image archive |
| 68 | +# parsing, Docker/Podman daemon calls, exec.Command shelling, YAML/ |
| 69 | +# JSON parsing of external input. |
| 70 | +[layers.2] |
| 71 | +sublayers = [ |
| 72 | + { name = "resolver", packages = ["dive/image/docker", "dive/image/podman"] }, |
| 73 | + { name = "loader", packages = ["dive/filetree_loader"] }, # ← NEW (split out of filetree/) |
| 74 | + { name = "config", packages = ["cmd/dive/cli/internal/options"] }, |
| 75 | +] |
| 76 | + |
| 77 | +# Layer 3 — Entry. Command setup, dependency wiring, TUI bootstrap. |
| 78 | +# Composes c3/c1 controllers + b2/b1 services + view/viewmodel. |
| 79 | +[layers.3] |
| 80 | +packages = [ |
| 81 | + "cmd/dive", |
| 82 | + "cmd/dive/cli", |
| 83 | + "cmd/dive/cli/internal/command", |
| 84 | + "cmd/dive/cli/internal/command/root", |
| 85 | + "cmd/dive/cli/internal/command/build", |
| 86 | + "cmd/dive/cli/internal/command/export", |
| 87 | + "cmd/dive/cli/internal/command/adapter", |
| 88 | + "cmd/dive/cli/internal/ui", |
| 89 | + "cmd/dive/cli/internal/ui/v1", |
| 90 | + "cmd/dive/cli/internal/ui/v1/app", |
| 91 | +] |
| 92 | +``` |
| 93 | + |
| 94 | +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). |
| 95 | + |
| 96 | +## The directory |
| 97 | + |
| 98 | +Compared to today's `dive`, ~95% of the file tree is identical. The diff is: |
| 99 | + |
| 100 | +``` |
| 101 | +dive/ |
| 102 | +├── filetree/ # ↓ trimmed: only pure tree algebra |
| 103 | +│ ├── comparer.go ← unchanged |
| 104 | +│ ├── diff.go ← unchanged |
| 105 | +│ ├── efficiency.go ← unchanged |
| 106 | +│ ├── file_node.go ← unchanged (the value type) |
| 107 | +│ ├── file_tree.go ← unchanged (the tree algebra) |
| 108 | +│ ├── node_data.go ← unchanged |
| 109 | +│ ├── order_strategy.go ← unchanged |
| 110 | +│ ├── path_error.go ← unchanged |
| 111 | +│ ├── view_info.go ← unchanged |
| 112 | +│ └── (file_info.go REMOVED — moved into filetree_loader/) |
| 113 | +│ |
| 114 | +├── filetree_loader/ # ← NEW package (Layer 2) |
| 115 | +│ ├── file_info.go ← MOVED: os.Lstat, filesystem walking |
| 116 | +│ └── file_info_test.go ← NEW |
| 117 | +│ |
| 118 | +├── image/ # unchanged |
| 119 | +├── image/docker/ # unchanged file list, sibling tests added |
| 120 | +├── image/podman/ # unchanged file list, sibling tests added |
| 121 | +└── (rest unchanged) |
| 122 | + |
| 123 | +cmd/dive/cli/internal/ # unchanged structure |
| 124 | +├── command/ci/ # unchanged files; profile now declares it Layer 1 |
| 125 | +├── command/root,build,export,adapter/ # unchanged |
| 126 | +├── options/ # unchanged; profile declares it Layer 2 config sublayer |
| 127 | +└── ui/v1/ # unchanged; siblings filled in where missing |
| 128 | + |
| 129 | +internal/ # unchanged |
| 130 | +``` |
| 131 | + |
| 132 | +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. |
| 133 | + |
| 134 | +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. |
| 135 | + |
| 136 | +## Layer 0 — Pure (unchanged) |
| 137 | + |
| 138 | +`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. |
| 139 | + |
| 140 | +```go |
| 141 | +// internal/utils/format.go — pure helper |
| 142 | +func CleanArgs(args []string) []string { |
| 143 | + out := args[:0] |
| 144 | + for _, a := range args { |
| 145 | + if a != "" { out = append(out, a) } |
| 146 | + } |
| 147 | + return out |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +No changes to these packages. |
| 152 | + |
| 153 | +## Layer 1 — Core, with three named sublayers |
| 154 | + |
| 155 | +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: |
| 156 | + |
| 157 | +```go |
| 158 | +// dive/filetree/file_tree.go — pure (unchanged) |
| 159 | +package filetree |
| 160 | + |
| 161 | +func (t *FileTree) Diff(other *FileTree) (*FileTree, error) { |
| 162 | + // ... pure tree-diff algorithm, operates on already-populated |
| 163 | + // FileNode structures. No filesystem reads. |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +```go |
| 168 | +// dive/filetree_loader/file_info.go — NEW package, Layer 2 |
| 169 | +package filetree_loader |
| 170 | + |
| 171 | +import ( |
| 172 | + "os" |
| 173 | + "github.com/wagoodman/dive/dive/filetree" // imports Layer 1 down to Layer 1 |
| 174 | +) |
| 175 | + |
| 176 | +// FromFilesystem walks a directory and populates a *filetree.FileTree. |
| 177 | +// THIS is where os.Lstat lives. |
| 178 | +func FromFilesystem(rootPath string) (*filetree.FileTree, error) { |
| 179 | + tree := filetree.NewFileTree() |
| 180 | + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { |
| 181 | + info, err := os.Lstat(path) |
| 182 | + // ... populate tree via tree.AddPath(path, fileNodeFrom(info)) |
| 183 | + }) |
| 184 | + return tree, err |
| 185 | +} |
| 186 | +``` |
| 187 | + |
| 188 | +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. |
| 189 | + |
| 190 | +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. |
| 191 | + |
| 192 | +```go |
| 193 | +// cmd/dive/cli/internal/command/ci/evaluator.go — Layer 1 (sublayer "evaluator") |
| 194 | +// Pure: given a tree + rules, returns pass/fail + reasons. |
| 195 | +type Evaluator struct { |
| 196 | + rules []Rule |
| 197 | +} |
| 198 | + |
| 199 | +func (e *Evaluator) Evaluate(tree *filetree.FileTree, efficiency float64) Result { |
| 200 | + var failures []Failure |
| 201 | + for _, r := range e.rules { |
| 202 | + if !r.Check(tree, efficiency) { |
| 203 | + failures = append(failures, Failure{Rule: r, ...}) |
| 204 | + } |
| 205 | + } |
| 206 | + return Result{Failures: failures, Pass: len(failures) == 0} |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +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). |
| 211 | + |
| 212 | +## Layer 2 — Adapter, with three sublayers |
| 213 | + |
| 214 | +The audit found boundary parsing was already 85% well-localized. The rebuild closes the remaining 15%: |
| 215 | + |
| 216 | +```go |
| 217 | +// dive/image/docker/image_archive.go — Layer 2 (sublayer "resolver"), unchanged |
| 218 | +// JSON-Unmarshal'ing the Docker image manifest happens here, where it belongs. |
| 219 | +func newImageArchive(tarFile io.ReadCloser) (*ImageArchive, error) { |
| 220 | + // ... read tar entries, json.Unmarshal manifest.json, json.Unmarshal config.json |
| 221 | +} |
| 222 | + |
| 223 | +// dive/image/docker/cli.go — Layer 2, unchanged |
| 224 | +// exec.Command("docker", ...) shelling, isolated to this file |
| 225 | +func runDockerCmd(cmdStr string, args ...string) error { |
| 226 | + cmd := exec.Command("docker", utils.CleanArgs(append([]string{cmdStr}, args...))...) |
| 227 | + // ... |
| 228 | +} |
| 229 | +``` |
| 230 | + |
| 231 | +`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). |
| 232 | + |
| 233 | +```go |
| 234 | +// cmd/dive/cli/internal/options/ci.go — Layer 2 (sublayer "config") |
| 235 | +// THE single point where YAML-Unmarshal'ing user config happens. |
| 236 | +type Ci struct { |
| 237 | + Rules []ciRule `yaml:"rules"` |
| 238 | +} |
| 239 | + |
| 240 | +func LoadCi(path string) (*Ci, error) { |
| 241 | + raw, err := os.ReadFile(path) |
| 242 | + if err != nil { return nil, err } |
| 243 | + var ci Ci |
| 244 | + if err := yaml.Unmarshal(raw, &ci); err != nil { return nil, err } |
| 245 | + return &ci, nil |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +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/`). |
| 250 | + |
| 251 | +## Layer 3 — Entry (lightly trimmed) |
| 252 | + |
| 253 | +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. |
| 254 | + |
| 255 | +```go |
| 256 | +// cmd/dive/cli/internal/command/root/root.go — Layer 3 (unchanged) |
| 257 | +RunE: func(cmd *cobra.Command, args []string) error { |
| 258 | + appOpts, err := options.LoadApplication(cmd) // L2 config |
| 259 | + if err != nil { return err } |
| 260 | + resolver := docker.NewResolver(appOpts) // L2 adapter |
| 261 | + image, err := resolver.Fetch(args[0]) // L2 call |
| 262 | + if err != nil { return err } |
| 263 | + analysis, err := image.Analyze() // L1 domain |
| 264 | + if err != nil { return err } |
| 265 | + if isCI() { |
| 266 | + evaluator := ci.NewEvaluator(appOpts.CiRules) // L1 evaluator |
| 267 | + return evaluator.Evaluate(analysis).IntoExitError() |
| 268 | + } |
| 269 | + return ui.Run(analysis, appOpts.UI) // L3 TUI bootstrap (composes L1 view/viewmodel) |
| 270 | +}, |
| 271 | +``` |
| 272 | + |
| 273 | +The wiring is already this shape today. No change. |
| 274 | + |
| 275 | +## What concretely changes |
| 276 | + |
| 277 | +| change | size | difficulty | |
| 278 | +|---|---|---| |
| 279 | +| 1. Add `sama.profile.toml` declaring layer mapping | ~50 lines | trivial — no code change | |
| 280 | +| 2. Split `dive/filetree/file_info.go` into `dive/filetree_loader/` package | one move + small import adjustment in ~3 callers | half-day | |
| 281 | +| 3. Write sibling tests for `dive/image/docker/image_archive.go` | ~150 lines + a fixture image | one day | |
| 282 | +| 4. Write sibling tests for `dive/image/docker/engine_resolver.go` (needs a fake daemon) | ~100 lines + interface extraction | one day | |
| 283 | +| 5. Write sibling tests for `dive/image/podman/build.go`, `cli.go`, `resolver.go` | ~250 lines | two days | |
| 284 | +| 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 | |
| 285 | +| 7. Write sibling tests for TUI `view/` files using rendered-string assertions | ~300 lines | three days | |
| 286 | +| **total** | **~30 new test files, one package split, one config file** | **~10 working days** | |
| 287 | + |
| 288 | +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. |
| 289 | + |
| 290 | +`dive` to 7/7 is two weeks of focused work, no breaking changes, no user-facing impact. |
| 291 | + |
| 292 | +## Predicted §5 metrics for the rebuilt dive |
| 293 | + |
| 294 | +| metric | dive today (estimated) | dive rebuilt (predicted) | tdd.md (measured) | |
| 295 | +|---|---|---|---| |
| 296 | +| §4 checks passing | ~5 / 7 | **7 / 7** | 7 / 7 | |
| 297 | +| graphDepth | ~5 | ~5 (unchanged — no architectural depth changes) | 7 | |
| 298 | +| boundaryRatio | ~85% | **~100%** (after `filetree_loader/` split) | 100% | |
| 299 | +| workingSetFit (50–500 LOC) | ~80% | ~80% (file sizes essentially unchanged) | 80% | |
| 300 | +| violationCounts (sum) | ~30 (mostly missing tests) | **0** | 0 | |
| 301 | + |
| 302 | +`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. |
| 303 | + |
| 304 | +## What this sketch actually surfaces |
| 305 | + |
| 306 | +Three observations: |
| 307 | + |
| 308 | +**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. |
| 309 | + |
| 310 | +**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. |
| 311 | + |
| 312 | +**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. |
| 313 | + |
| 314 | +--- |
| 315 | + |
| 316 | +**Companion posts:** |
| 317 | + |
| 318 | +- [Today's `dive` audit](/blog/sama-v2-go-project-dive) — the source of the ~5/7 score this rebuild starts from |
| 319 | +- [The WordPress audit](/blog/sama-v2-wordpress-plugin-audit) — the 0/7 baseline from a different ecosystem |
| 320 | +- [The WordPress rebuild](/blog/sama-v2-wordpress-plugin-rebuilt) — what 7/7 looked like for that codebase |
| 321 | +- [The §5 metrics emitter](/blog/sama-v2-metrics-emitter) — what makes deltas measurable in the first place |
| 322 | +- [The v2 spec](/sama/v2) — the rules being audited against |