91748a43c67eb4e4b88e4b22465c7a76a605b172 diff --git a/content/blog/sama-v2-go-project-dive-rebuilt.md b/content/blog/sama-v2-go-project-dive-rebuilt.md new file mode 100644 index 0000000000000000000000000000000000000000..195a893741e3fb2e9860e96e825dbf0750d6b9be --- /dev/null +++ b/content/blog/sama-v2-go-project-dive-rebuilt.md @@ -0,0 +1,322 @@ +# `dive`, rebuilt under SAMA v2 — a thought experiment + +[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). + +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. + +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 + +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/sama-v2-go-project-dive) — the source of the ~5/7 score this rebuild starts from +- [The WordPress audit](/blog/sama-v2-wordpress-plugin-audit) — the 0/7 baseline from a different ecosystem +- [The WordPress rebuild](/blog/sama-v2-wordpress-plugin-rebuilt) — what 7/7 looked like for that codebase +- [The §5 metrics emitter](/blog/sama-v2-metrics-emitter) — what makes deltas measurable in the first place +- [The v2 spec](/sama/v2) — the rules being audited against diff --git a/src/a31_blog.ts b/src/a31_blog.ts index 0f2b1c5cd28c982eeec3b23ce91578e7a3313686..54935a8cb4a8b270fe4bb817f1d0251b7c91e2bd 100644 --- a/src/a31_blog.ts +++ b/src/a31_blog.ts @@ -12,6 +12,12 @@ export interface BlogEntry { } export const ALL_POSTS: BlogEntry[] = [ + { + slug: "sama-v2-go-project-dive-rebuilt", + title: "`dive`, rebuilt under SAMA v2 — a thought experiment", + 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.", + date: "2026-05-23", + }, { slug: "sama-v2-go-project-dive", title: "Pointing SAMA v2 at `dive`: Go's conventions cover more than you'd think",