dive, rebuilt under SAMA v2 — a thought experiment
Today's audit 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: 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 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:
divestill 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 <image-tag>,dive build -t ... .,CI=true dive ...) is unchanged. - The
.dive.yamlconfig 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, thetview/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
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.
// 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:
// 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.
}
// 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.
// 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%:
// 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).
// 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.
// 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
diveaudit — the source of the ~5/7 score this rebuild starts from - The WordPress audit — the 0/7 baseline from a different ecosystem
- The WordPress rebuild — what 7/7 looked like for that codebase
- The §5 metrics emitter — what makes deltas measurable in the first place
- The v2 spec — the rules being audited against