| 1 | +# `dive`, the prefix-scheme variant — what `ls`-readable layer order costs in Go |
| 2 | + |
| 3 | +[Earlier today's `dive` rebuilt sketch](/blog/sama-v2-go-project-dive-rebuilt) chose a hypothetical v2.1 *directory-based dialect* — pin layer mapping by package path, leave the actual files where they sit, accept the minimal-change cost. The visual payoff of that approach is muted: you can't `ls src/ | sort` and read the layer hierarchy off the screen the way you can in the [WordPress rebuild post](/blog/sama-v2-wordpress-plugin-rebuilt) (`a_meta_tag.php < a_page_context.php < ... < b1_image_selection_policy.php < ... < d_third_party_hooks.php`). |
| 4 | + |
| 5 | +Bas said the obvious thing: *"i miss this SAMA v2 style."* Fair. So here's the other end of the spectrum — what `dive` looks like if it commits to v2.0's prefix scheme *literally*, every file renamed, no spec extension required, the `ls`-readable layer order property fully recovered. |
| 6 | + |
| 7 | +This is the more dramatic refactor. It's also the variant that fights Go's idiom the hardest, and the post is candid about both halves. |
| 8 | + |
| 9 | +## The full file tree |
| 10 | + |
| 11 | +``` |
| 12 | +dive/ |
| 13 | +├── go.mod # at module root (required by Go) |
| 14 | +├── go.sum |
| 15 | +├── README.md |
| 16 | +├── Makefile |
| 17 | +├── Dockerfile |
| 18 | +├── Taskfile.yaml |
| 19 | +│ |
| 20 | +├── src/ # all Go sources, flat — accepts non-idiomatic |
| 21 | +│ │ Go layout for v2.0's lex-sort property |
| 22 | +│ │ |
| 23 | +│ │── ─── Layer 0 — Pure ────────────────────────────────────────────────── |
| 24 | +│ ├── a_bus.go # in-memory event bus type (was internal/bus/bus.go) |
| 25 | +│ ├── a_bus_helpers.go # (was internal/bus/helpers.go) |
| 26 | +│ ├── a_event.go # event types (was internal/bus/event/event.go) |
| 27 | +│ ├── a_event_payload_explore.go # (was internal/bus/event/payload/explore.go) |
| 28 | +│ ├── a_event_payload_generic.go # (was internal/bus/event/payload/generic.go) |
| 29 | +│ ├── a_file_node.go # FileNode value type (was dive/filetree/file_node.go) |
| 30 | +│ ├── a_image.go # Image, ImageMeta types (was dive/image/image.go) |
| 31 | +│ ├── a_layer.go # Layer type (was dive/image/layer.go) |
| 32 | +│ ├── a_log.go # log façade (was internal/log/log.go) |
| 33 | +│ ├── a_node_data.go # NodeData type (was dive/filetree/node_data.go) |
| 34 | +│ ├── a_order_strategy.go # pure enum (was dive/filetree/order_strategy.go) |
| 35 | +│ ├── a_path_error.go # (was dive/filetree/path_error.go) |
| 36 | +│ ├── a_ports.go # NEW: interfaces (Resolver, Loader, EngineClient, ...) |
| 37 | +│ ├── a_utils_format.go # CleanArgs etc (was internal/utils/format.go) |
| 38 | +│ ├── a_utils_view.go # (was internal/utils/view.go) |
| 39 | +│ ├── a_view_info.go # (was dive/filetree/view_info.go) |
| 40 | +│ │ |
| 41 | +│ │── ─── Layer 1, sublayer "policy" — pure algorithms ────────────────── |
| 42 | +│ ├── b1_comparer.go # (was dive/filetree/comparer.go) |
| 43 | +│ ├── b1_diff.go # (was dive/filetree/diff.go) |
| 44 | +│ ├── b1_efficiency.go # (was dive/filetree/efficiency.go) |
| 45 | +│ ├── b1_event_parser.go # (was internal/bus/event/parser/parsers.go) |
| 46 | +│ ├── b1_file_tree.go # tree algebra (was dive/filetree/file_tree.go) |
| 47 | +│ ├── b1_image_analysis.go # pure analysis (was dive/image/analysis.go) |
| 48 | +│ ├── b1_ci_evaluator.go # (was cmd/.../command/ci/evaluator.go) |
| 49 | +│ ├── b1_ci_rule.go # (was cmd/.../command/ci/rule.go) |
| 50 | +│ ├── b1_ci_rules.go # (was cmd/.../command/ci/rules.go) |
| 51 | +│ │ |
| 52 | +│ │── ─── Layer 1, sublayer "viewmodel" — stateful state managers ──────── |
| 53 | +│ ├── b2_viewmodel_config.go # (was .../viewmodel/config.go) |
| 54 | +│ ├── b2_viewmodel_filetree.go # (was .../viewmodel/filetree.go) |
| 55 | +│ ├── b2_viewmodel_layer_compare.go # (was .../viewmodel/layer_compare.go) |
| 56 | +│ ├── b2_viewmodel_layer_selection.go # (was .../viewmodel/layer_selection.go) |
| 57 | +│ ├── b2_viewmodel_layer_set_state.go # (was .../viewmodel/layer_set_state.go) |
| 58 | +│ ├── b2_format.go # (was .../format/format.go) |
| 59 | +│ ├── b2_keybindings.go # (was .../key/binding.go + key/config.go merged) |
| 60 | +│ ├── b2_ui_config.go # (was .../ui/v1/config.go) |
| 61 | +│ ├── b2_layout_area.go # (was .../layout/area.go) |
| 62 | +│ ├── b2_layout_location.go # (was .../layout/location.go) |
| 63 | +│ ├── b2_layout_compound.go # (was .../layout/compound/layer_details_column.go) |
| 64 | +│ ├── b2_layout_layout.go # (was .../layout/layout.go) |
| 65 | +│ ├── b2_layout_manager.go # (was .../layout/manager.go) |
| 66 | +│ │ |
| 67 | +│ │── ─── Layer 1, sublayer "view" — TUI render (depends on b2 state) ─── |
| 68 | +│ ├── b3_view_cursor.go # (was .../view/cursor.go) |
| 69 | +│ ├── b3_view_debug.go # (was .../view/debug.go) |
| 70 | +│ ├── b3_view_filetree.go # (was .../view/filetree.go) |
| 71 | +│ ├── b3_view_filter.go # (was .../view/filter.go) |
| 72 | +│ ├── b3_view_image_details.go # (was .../view/image_details.go) |
| 73 | +│ ├── b3_view_layer.go # (was .../view/layer.go) |
| 74 | +│ ├── b3_view_layer_change_listener.go # (was .../view/layer_change_listener.go) |
| 75 | +│ ├── b3_view_layer_details.go # (was .../view/layer_details.go) |
| 76 | +│ ├── b3_view_renderer.go # (was .../view/renderer.go) |
| 77 | +│ ├── b3_view_status.go # (was .../view/status.go) |
| 78 | +│ ├── b3_view_views.go # (was .../view/views.go) |
| 79 | +│ │ |
| 80 | +│ │── ─── Layer 2, sublayer "loader" — filesystem walking ────────────── |
| 81 | +│ ├── c1_file_info.go # filesystem walking (was dive/filetree/file_info.go) |
| 82 | +│ │ |
| 83 | +│ │── ─── Layer 2, sublayer "engine" — Docker/Podman daemon API ──────── |
| 84 | +│ ├── c2_docker_archive_resolver.go # (was dive/image/docker/archive_resolver.go) |
| 85 | +│ ├── c2_docker_build.go # (was dive/image/docker/build.go) |
| 86 | +│ ├── c2_docker_cli.go # (was dive/image/docker/cli.go) |
| 87 | +│ ├── c2_docker_config.go # (was dive/image/docker/config.go) |
| 88 | +│ ├── c2_docker_engine_resolver.go # (was dive/image/docker/engine_resolver.go) |
| 89 | +│ ├── c2_docker_host_unix.go # (was dive/image/docker/docker_host_unix.go) |
| 90 | +│ ├── c2_docker_host_windows.go # (was dive/image/docker/docker_host_windows.go) |
| 91 | +│ ├── c2_docker_image_archive.go # (was dive/image/docker/image_archive.go) |
| 92 | +│ ├── c2_docker_layer.go # (was dive/image/docker/layer.go) |
| 93 | +│ ├── c2_docker_manifest.go # (was dive/image/docker/manifest.go) |
| 94 | +│ ├── c2_docker_testing.go # (was dive/image/docker/testing.go) |
| 95 | +│ ├── c2_image_resolver.go # (was dive/image/resolver.go) |
| 96 | +│ ├── c2_image_resolver_factory.go # (was dive/get_image_resolver.go) |
| 97 | +│ ├── c2_podman_build.go # (was dive/image/podman/build.go) |
| 98 | +│ ├── c2_podman_cli.go # (was dive/image/podman/cli.go) |
| 99 | +│ ├── c2_podman_resolver.go # (was dive/image/podman/resolver.go) |
| 100 | +│ ├── c2_podman_resolver_unsupported.go # (was dive/image/podman/resolver_unsupported.go) |
| 101 | +│ │ |
| 102 | +│ │── ─── Layer 2, sublayer "config" — YAML parsing ──────────────────── |
| 103 | +│ ├── c3_options_analysis.go # (was cmd/.../options/analysis.go) |
| 104 | +│ ├── c3_options_application.go # (was cmd/.../options/application.go) |
| 105 | +│ ├── c3_options_ci.go # YAML parse .dive-ci.yaml (was .../options/ci.go) |
| 106 | +│ ├── c3_options_ci_rules.go # (was cmd/.../options/ci_rules.go) |
| 107 | +│ ├── c3_options_export.go # (was cmd/.../options/export.go) |
| 108 | +│ ├── c3_options_ui.go # (was cmd/.../options/ui.go) |
| 109 | +│ ├── c3_options_ui_diff.go # (was cmd/.../options/ui_diff.go) |
| 110 | +│ ├── c3_options_ui_filetree.go # (was cmd/.../options/ui_filetree.go) |
| 111 | +│ ├── c3_options_ui_keybindings.go # (was cmd/.../options/ui_keybindings.go) |
| 112 | +│ ├── c3_options_ui_layers.go # (was cmd/.../options/ui_layers.go) |
| 113 | +│ │ |
| 114 | +│ │── ─── Layer 3 — Entry: commands, bootstrap, app controller ───────── |
| 115 | +│ ├── d_app.go # (was cmd/.../ui/v1/app/app.go) |
| 116 | +│ ├── d_app_controller.go # (was cmd/.../ui/v1/app/controller.go) |
| 117 | +│ ├── d_app_job_control_other.go # (was cmd/.../ui/v1/app/job_control_other.go) |
| 118 | +│ ├── d_app_job_control_unix.go # (was cmd/.../ui/v1/app/job_control_unix.go) |
| 119 | +│ ├── d_cli.go # (was cmd/dive/cli/cli.go) |
| 120 | +│ ├── d_cmd_adapter_analyzer.go # (was cmd/.../command/adapter/analyzer.go) |
| 121 | +│ ├── d_cmd_adapter_evaluator.go # (was cmd/.../command/adapter/evaluator.go) |
| 122 | +│ ├── d_cmd_adapter_exporter.go # (was cmd/.../command/adapter/exporter.go) |
| 123 | +│ ├── d_cmd_adapter_resolver.go # (was cmd/.../command/adapter/resolver.go) |
| 124 | +│ ├── d_cmd_build.go # (was cmd/.../command/build.go) |
| 125 | +│ ├── d_cmd_export.go # (was cmd/.../command/export/export.go) |
| 126 | +│ ├── d_cmd_root.go # (was cmd/.../command/root.go) |
| 127 | +│ ├── d_main.go # (was cmd/dive/main.go) — calls cli.Run() |
| 128 | +│ ├── d_ui_no_ui.go # (was cmd/.../ui/no_ui.go) |
| 129 | +│ └── d_ui_v1.go # (was cmd/.../ui/v1.go) |
| 130 | +│ |
| 131 | +└── tests/ # one *_test.go per b*_ and c*_ file |
| 132 | + ├── b1_file_tree_test.go # (was dive/filetree/file_tree_test.go) |
| 133 | + ├── b1_efficiency_test.go # (was dive/filetree/efficiency_test.go) |
| 134 | + ├── b1_ci_evaluator_test.go # (was cmd/.../command/ci/evaluator_test.go) |
| 135 | + ├── b2_viewmodel_filetree_test.go # (was .../viewmodel/filetree_test.go) |
| 136 | + ├── ... (~40 sibling tests, one per source file in layers 1 and 2) |
| 137 | +``` |
| 138 | + |
| 139 | +**Lex check** (the property §4.1 cares about): `a_*` < `b1_*` < `b2_*` < `b3_*` < `c1_*` < `c2_*` < `c3_*` < `d_*`. The thirteen Layer 0 files, then nine Layer 1 policy files, then thirteen Layer 1 viewmodel, then eleven Layer 1 view, then one Layer 2 loader, then seventeen Layer 2 engine adapters, then ten Layer 2 config parsers, then fifteen Layer 3 entry files. Run `ls src/` and you get them in that order. The layer hierarchy *is* the screen. |
| 140 | + |
| 141 | +**The profile** (under v2.0 unchanged, no extension needed): |
| 142 | + |
| 143 | +```toml |
| 144 | +sama_version = "2.0" |
| 145 | +profile = "dive" |
| 146 | + |
| 147 | +[layers.0] |
| 148 | +prefixes = ["a_"] |
| 149 | + |
| 150 | +[layers.1] |
| 151 | +sublayers = [ |
| 152 | + { name = "policy", prefix = "b1_" }, |
| 153 | + { name = "viewmodel", prefix = "b2_" }, |
| 154 | + { name = "view", prefix = "b3_" }, |
| 155 | +] |
| 156 | + |
| 157 | +[layers.2] |
| 158 | +sublayers = [ |
| 159 | + { name = "loader", prefix = "c1_" }, |
| 160 | + { name = "engine", prefix = "c2_" }, |
| 161 | + { name = "config", prefix = "c3_" }, |
| 162 | +] |
| 163 | + |
| 164 | +[layers.3] |
| 165 | +prefixes = ["d_"] |
| 166 | +``` |
| 167 | + |
| 168 | +This passes all seven §4 checks under v2.0 unchanged. No spec extension required. |
| 169 | + |
| 170 | +## What this costs in Go |
| 171 | + |
| 172 | +Three concrete prices: |
| 173 | + |
| 174 | +**1. Everything's in one Go package.** Go's rule: package name equals directory name. A flat `src/` with 90+ files means they're all in `package dive` (or whatever name the directory takes). Today `dive` has 16+ packages (`filetree`, `image`, `image/docker`, `image/podman`, `internal/utils`, `internal/log`, `viewmodel`, `view`, `command/ci`, `command/adapter`, ...). Under the prefix scheme they merge into one giant package, losing: |
| 175 | + - **Encapsulation.** Today, the `dive/filetree` package can export `FileTree` while keeping `file_info.go`'s helpers private. In a single flat package, everything not lowercase is exported; everything lowercase is package-private to a 90-file blob. |
| 176 | + - **Compilation boundaries.** Go compiles per package; a change to one file in `filetree` doesn't force `image/docker` to recompile. In one giant package, every change touches the same compilation unit. |
| 177 | + - **Cyclic-import detection at the package level.** Go enforces "no cyclic imports between packages" — a hard error. In a single package, files import each other freely; the only enforcement is "no cyclic dependencies between files," which Go doesn't check (it doesn't need to, because Go has no per-file imports). |
| 178 | + |
| 179 | +**2. `internal/` semantics evaporate.** Go's `internal/X` packages can only be imported by paths under `X`'s parent — a hard, compiler-enforced visibility rule. In a flat `src/`, no path has an `internal/` qualifier. Every Layer 0 type is import-equivalent to every Layer 3 file. The Law check (§1.2) now has to do the work the compiler was doing for free. |
| 180 | + |
| 181 | +**3. The Go community would reject the PR.** The flat-layout style would be one of the first things called out in any code review. The Go style guide is pretty firm on package organization, and `package dive` containing `b1_*.go` filenames isn't going to land. A maintainer attempting this refactor would spend the first three PRs arguing about naming alone. |
| 182 | + |
| 183 | +## What the prefix-scheme variant gets right |
| 184 | + |
| 185 | +Against those costs, what's bought: |
| 186 | + |
| 187 | +- **`ls src/ | sort` reads top-to-bottom in dependency order.** The visual payoff that the [WP rebuild post](/blog/sama-v2-wordpress-plugin-rebuilt) leans on works identically here. A reviewer looking at `git diff src/` sees layer changes in their natural order. |
| 188 | +- **No spec extension needed.** v2.0 as written applies unchanged. The [other rebuild post](/blog/sama-v2-go-project-dive-rebuilt) requires a hypothetical v2.1 directory dialect; this one doesn't. |
| 189 | +- **Profile is shorter.** Eight prefixes mapped to seven sublayer slots — a one-screen `sama.profile.toml`. The directory-dialect profile is twice as long because it lists every package path. |
| 190 | +- **Cross-language consistency.** A reader who knows the WP rebuild's prefix scheme reads this tree without translation. `a_*` is types, `b1_*` is pure policy, `c1_*` is repo, `d_*` is entry — identical to what they already know from the PHP example. |
| 191 | + |
| 192 | +## Which variant should the spec actually recommend? |
| 193 | + |
| 194 | +Probably the directory dialect for Go, the prefix scheme for most other languages. |
| 195 | + |
| 196 | +The reasoning: SAMA v2's value comes from making layer boundaries enforceable. Go already enforces them via package + `internal/` semantics — borrowing that machinery is cheaper than fighting it. PHP and TypeScript don't have those compiler-level checks, so the file-prefix scheme buys you something the language otherwise doesn't. |
| 197 | + |
| 198 | +A v2.1 spec extension would probably say: |
| 199 | + |
| 200 | +> *A profile may choose between two `layout` modes: `"prefix"` (filename-prefix-based; the verifier's Sorted check verifies lex order of prefixes matches the layer order declared in the profile) and `"directory"` (package-path-based; the verifier's Sorted check verifies that no import edge points from an earlier-declared package to a later-declared one). The choice is per-profile and influences only the Sorted check's enforcement; all other §4 checks are layout-mode-independent.* |
| 201 | + |
| 202 | +That's the kind of optional extension §6 evolution policy admits naturally: falsifiable (does the directory mode produce equivalent verification guarantees?), measurable (the §5 metrics work either way), and promotable to official only if cross-repo data shows both modes catch the same class of drift. |
| 203 | + |
| 204 | +## What's the same as the other rebuild post |
| 205 | + |
| 206 | +Predicted §5 metrics for the prefix-scheme variant are **identical** to the directory-dialect variant: |
| 207 | + |
| 208 | +| metric | dive today | dive — directory variant | dive — prefix variant | |
| 209 | +|---|---|---|---| |
| 210 | +| §4 checks passing | ~5 / 7 | 7 / 7 (under v2.1 dialect) | 7 / 7 (under v2.0 unchanged) | |
| 211 | +| graphDepth | ~5 | ~5 | ~5 | |
| 212 | +| boundaryRatio | ~85% | ~100% | ~100% | |
| 213 | +| workingSetFit | ~80% | ~80% | ~80% | |
| 214 | +| violationCounts | ~30 | 0 | 0 | |
| 215 | + |
| 216 | +The §5 metrics don't care about layout mode. They measure **the same architectural facts** whether expressed by package or by prefix. Which is itself a useful observation about the spec: the metrics are the load-bearing artifact; the Sorted check is a convenience surface. |
| 217 | + |
| 218 | +## Two paths, same destination |
| 219 | + |
| 220 | +This is the third `dive` post in a row, and I'll stop after this one. The bracket is now visible: |
| 221 | + |
| 222 | +- [The audit](/blog/sama-v2-go-project-dive) — `dive` as-is, ~5 of 7 checks pass naturally. |
| 223 | +- [The directory-dialect rebuild](/blog/sama-v2-go-project-dive-rebuilt) — minimal moves, requires a hypothetical v2.1 extension, 7/7 ✓. |
| 224 | +- This post — the prefix-scheme rebuild, works under v2.0 unchanged, fights Go hard, also 7/7 ✓. |
| 225 | + |
| 226 | +Both rebuilds reach the same end state. The first matches Go's idiom; the second matches the WP rebuild's visual aesthetic. The §5 metrics — the empirical artifact the spec cares about — come out identically in both. That's the part of the v2 design that quietly works: the rules are stricter than the surface syntax that enforces them. |
| 227 | + |
| 228 | +--- |
| 229 | + |
| 230 | +**Companion posts:** |
| 231 | + |
| 232 | +- [The Go audit](/blog/sama-v2-go-project-dive) — where ~5/7 comes from |
| 233 | +- [The directory-dialect rebuild](/blog/sama-v2-go-project-dive-rebuilt) — the minimal-change variant |
| 234 | +- [The WordPress audit + rebuild](/blog/sama-v2-wordpress-plugin-audit) — same exercise on a 0/7 starting point |
| 235 | +- [The §5 metrics post](/blog/sama-v2-metrics-emitter) — why the metrics matter more than the surface syntax |