syntaxai/tdd.md · commit 91748a4

Blog: dive, rebuilt under SAMA v2 (companion to the Go audit)

Parallel-architecture sketch matching the WordPress rebuild post,
but the lift here is genuinely small — dive starts at ~5/7, not 0/7.

The post commits to:
- A v2.1 directory-based dialect: Sorted-by-package-path instead of
  Sorted-by-filename-prefix. Required to even score Go honestly.
- The proposed sama.profile.toml mapping packages to canonical
  layers + sublayers (resolver/loader/config in L2; domain/viewmodel/
  view/evaluator in L1).
- One real package split: dive/filetree/file_info.go moves into a
  new dive/filetree_loader/ (Layer 2 adapter), leaving dive/filetree/
  as 100% pure tree algebra.
- ~30 new sibling tests for the image-adapter packages, command/ci
  evaluator, and TUI view/ files.

Predicted §5 deltas for the rebuilt version: boundaryRatio →100%,
violationCounts → 0, workingSetFit unchanged (~80%, already healthy).
~10 working days estimated total work — compared to months for the
WordPress refactor sketch.

Three observations the sketch surfaces in its conclusion: (1) the
directory-based dialect is a real spec extension the audit makes
necessary; (2) the work-cost from idiomatic Go to v2-compliant Go is
small; (3) the Go ecosystem is a cheap source of v2 baseline data
once the dialect lands.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-23 15:14:28 +01:00
parent
f3a50b8
commit
91748a43c67eb4e4b88e4b22465c7a76a605b172

2 files changed · +328 −0

added content/blog/sama-v2-go-project-dive-rebuilt.md +322 −0
@@ -0,0 +1,322 @@
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
modified src/a31_blog.ts +6 −0
@@ -12,6 +12,12 @@ export interface BlogEntry {
1212 }
1313
1414 export const ALL_POSTS: BlogEntry[] = [
15+ {
16+ slug: "sama-v2-go-project-dive-rebuilt",
17+ title: "`dive`, rebuilt under SAMA v2 — a thought experiment",
18+ description: "The companion to today's dive audit (which found Go's standard layout already scores ~5 of 7 §4 checks). Same parallel-architecture sketch as the WordPress rebuild, but the lift is much smaller — days of work instead of months, because the starting point is so much closer. Profile under a hypothetical v2.1 directory-based dialect (Sorted-by-package-path instead of Sorted-by-filename-prefix), one package split (filetree/file_info.go moves into a new dive/filetree_loader/ adapter), ~30 sibling tests written for image-adapter files. No god-class splits, no API breaks, no contract changes. ~10 working days estimated. Predicted §5 deltas: boundaryRatio rises to 100% (the filetree_loader split closes the last filesystem-leak), violationCounts drops from ~30 to 0, workingSetFit unchanged (Go file sizes are already healthy). Three observations the sketch surfaces: (1) v2.0's Sorted check is the real spec gap, not anything dive does wrong; (2) the work-cost to go from idiomatic Go to v2-compliant Go is genuinely small; (3) the Go ecosystem is a cheap source of v2 baseline data — once the dialect lands, n=3 could grow to n=20+ in an afternoon.",
19+ date: "2026-05-23",
20+ },
1521 {
1622 slug: "sama-v2-go-project-dive",
1723 title: "Pointing SAMA v2 at `dive`: Go's conventions cover more than you'd think",