diff --git a/adrs/adr-025-unified-lazy-loading.ncl b/adrs/adr-025-unified-lazy-loading.ncl new file mode 100644 index 0000000..9efa76d --- /dev/null +++ b/adrs/adr-025-unified-lazy-loading.ncl @@ -0,0 +1,168 @@ +let d = import "adr-defaults.ncl" in + +d.make_adr { + id = "adr-025", + title = "Unified Lazy-Loading: Empty mod.nu + Selective Imports + Bash-Direct Dispatch", + status = 'Accepted, + date = "2026-04-17", + amended = "2026-04-17", + amendment_summary = "Phase 4 implementation findings: (1) function-body `use` is eager in Nushell 0.112 — subprocess boundary is the only true lazy-load; (2) hard-fail default replaced with provisioning-cli.nu universal fallback for unmapped commands (transitional); (3) constraint bash-wrapper-has-no-runner-reference updated to permit provisioning-cli.nu; (4) added universal-fallback-is-transitional constraint documenting migration debt.", + + context = "The `prvng` CLI has two incompatible module-loading modes coexisting in `provisioning/core/nulib/`. Fat mode: commands routed through `core/nulib/provisioning` (the Nu runner, 492 lines) which star-imports `lib_provisioning *` and `main_provisioning *` at top level — this forces Nushell's parser to resolve every `export use X *` transitively through each `mod.nu`, parsing the AST of all 466 reachable .nu files on every invocation. Cold start: ~60s. Thin mode: 13 ad-hoc handlers (`provisioning-.nu`) with selective imports, bypassing the runner. Cold start: <1s. The asymmetry is structural: `lib_provisioning/mod.nu` re-exports 13 subtrees via `export use X *`, and `main_provisioning/mod.nu` re-exports ~30 command modules the same way. Every new command risks landing in the fat path by default. ADR-022 (ncl-sync) eliminated Nickel export latency but explicitly deferred the module-parse problem as orthogonal; this ADR addresses that remaining cost. A related constraint: the bash wrapper `core/cli/provisioning` already resolves three alias layers (single-char expansion, multi-letter case aliases, registry-declared aliases) and five special argument-shaping cases (subcommand order inversion for `create|new`, `ssh` shortcut with implicit `--run`, `volume` positional skip, `platform logs` interactive stdin, `server --help` inline interception) before Nu is invoked. Moving alias resolution into Nu would reintroduce the exact double-parse cost this ADR exists to eliminate.", + + decision = "Adopt a single loading mode: lazy-by-default, enforced structurally. (1) Root module indices `lib_provisioning/mod.nu` and `main_provisioning/mod.nu` contain no `export use` statements — only a comment documenting the no-star policy. (2) Every Nu file in `core/nulib/` uses selective imports exclusively: `use lib_provisioning//.nu [fn1 fn2]` — never `use X *` against the root. A transitivity rule is part of the contract: any file imported selectively must itself use only selective imports; a selective import against a file containing `use ../other.nu *` is not thin because the star propagates during parse. (3) All selective imports use absolute paths from `nulib/` root, never relative paths (`../`, `./`). This preserves the work across file movements in the follow-up restructure (ADR-026): a single `rg | sed` suffices to relocate importers when files move, whereas relative imports would require per-file recalculation. (4) The Nu runner `core/nulib/provisioning` is deleted in full. Dispatch becomes the responsibility of the bash wrapper alone. (5) Every top-level command in `commands-registry.ncl` has a dedicated thin handler `core/nulib/provisioning-.nu` following the canonical pattern in `provisioning-status.nu`. (6) The bash wrapper's three alias layers and five special argument-shaping cases are preserved verbatim — only the dispatch target inside each `case` branch changes from `$RUNNER` to the thin handler path. (7) The default branch of the bash dispatch becomes a hard failure (`exit 2`) — no silent fat-path fallback. (8) A pre-commit hook prevents reintroduction of root star-imports and relative-path imports.", + + rationale = [ + { + claim = "Nushell's parser resolves `export use X *` transitively at parse time — the cost is 100% upfront, not lazy", + detail = "When Nu parses `use lib_provisioning *`, it walks every `export use Y *` declared in `lib_provisioning/mod.nu`, recursively, building the full AST of every reachable file before executing a single line. With 466 .nu files reachable from the two root mod.nu files, cold start reaches ~60s regardless of what the command actually needs. Selective imports (`use path/to/file.nu [fn]`) parse only that single file and its direct dependencies. The difference is structural, not a tuning parameter.", + }, + { + claim = "Function-body `use` statements are also parsed eagerly — the subprocess boundary is the only true lazy-load mechanism in Nushell 0.112", + detail = "During Phase 4 implementation (2026-04-17), the 'lazy-inside-function' approach was tested: moving `use servers/create.nu *` inside a `match` arm so it would only fire when that arm executed. Empirical measurement showed zero improvement — calling the OTHER arm (which never reaches the `use`) still incurred the full 755ms parse cost of `create.nu`. Nushell parses all `use` statements in a file at module-load time regardless of lexical scope: top-level, `def` body, `match` arm, or `if` block. The only mechanism that achieves genuine lazy-loading is a subprocess boundary — a separate Nu process that sources only the module it needs. This is exactly the thin-handler pattern: each `provisioning-.nu` is a subprocess that parses one domain module. Consequence: a single-entry Nu script that imports N domain modules via a dispatcher will always pay the cost of all N modules at startup, even with selective imports inside wrapper functions. The bash wrapper is the correct dispatch layer not merely for alias resolution but because it provides the process-per-command isolation that Nu cannot provide internally.", + }, + { + claim = "The bash wrapper is the correct dispatch layer — moving alias logic to Nu would reintroduce double parsing", + detail = "Alias resolution is a lookup over command names. If the lookup table itself lives in a Nu module, Nu must parse enough of its module graph to access the table before it can decide which module to actually execute. That is the double parse this ADR eliminates. Bash resolves aliases in O(1) via `case` statements with no language runtime required. Until Nu implements lazy module resolution at the interpreter level, bash is the only place alias expansion can happen without paying the parse cost twice.", + }, + { + claim = "One dispatch model with enforced guardrails is safer than two with documentation", + detail = "The existing 13 thin handlers are patches layered on top of the fat path. Every new command since their creation has been a coin-flip: contributors who read the pattern ship thin, contributors who don't ship fat. The fat path is invisible at PR review because it looks identical to a thin command from the outside. Structural enforcement (empty mod.nu + pre-commit hook blocking star-imports) makes regression syntactically impossible, not culturally discouraged.", + }, + { + claim = "Deleting the Nu runner removes a dispatch layer — bash → thin handler is one process, bash → runner → dispatch would be two", + detail = "Every Nu process incurs ~200ms of interpreter startup plus module parse. A bash-to-runner-to-handler chain pays this twice: once for the runner (which must parse whatever it uses for dispatch), once for the handler. The canonical thin-handler pattern (`provisioning-status.nu`) already demonstrates that a single Nu process with selective imports completes in <1s including actual work. The runner adds no value: bash can already route by command name.", + }, + { + claim = "Transitivity rule is load-bearing — without it, selective imports decay silently", + detail = "A developer writes `use lib_provisioning/utils/settings.nu [load_settings]` believing it is thin. If `settings.nu` contains `use ../providers/registry.nu *`, Nu parses the registry tree as well. The caller's import was selective but the transitive reach is not. Without a rule that requires all imported files to themselves be star-free, the refactor regresses incrementally as utility files gain new star imports. The rule is enforced by the same pre-commit hook that blocks root star-imports.", + }, + { + claim = "Absolute-path imports preserve this refactor's work across the follow-up restructure (ADR-026)", + detail = "Relative imports encode the caller's location. `use ../utils/settings.nu [fn]` means different files depending on where the caller lives. When ADR-026 moves `lib_provisioning/utils/` to `primitives/utils/`, every relative importer breaks and must be individually recalculated. Absolute imports encode the target's location. `use lib_provisioning/utils/settings.nu [fn]` always refers to the same file; when the target moves, a single `rg -l 'lib_provisioning/utils/' | xargs sed -i 's|lib_provisioning/utils/|primitives/utils/|g'` updates every caller uniformly. This turns ADR-025 into structural groundwork that ADR-026 consumes, not a tax paid twice.", + }, + { + claim = "ADR-022 (ncl-sync) and this ADR are complementary and both required", + detail = "ADR-022 eliminated Nickel export latency (2-5s per call × 124 call sites). This ADR eliminates Nu module parse latency (~60s for fat commands). Together they bring `prvng component list` from ~70s cold start to <1s, and `prvng deploy` from 15-30s to <5s. Neither alone is sufficient; ncl-sync only helps commands that read Nickel, module parse dominates every command regardless.", + }, + ], + + consequences = { + positive = [ + "Every `prvng --help` completes in <1s cold start — uniform UX across the command surface", + "New commands cannot accidentally land in a slow path: the fat path no longer exists", + "Dispatch topology is explicit in the bash wrapper — grepping `case` statements enumerates the full CLI surface", + "Pre-commit hook blocks star-import regressions mechanically; code review does not carry the burden", + "Deleting the Nu runner removes 492 lines of duplicated dispatch logic and its module-import side effects", + "Thin handlers are small (~30-60 lines) and follow a single template — contributor onboarding cost drops", + ], + negative = [ + "Every command in `commands-registry.ncl` must have a corresponding thin handler; adding a command requires two artifacts (registry entry + handler file) instead of one (registry entry alone, historically)", + "The `export-env` side effects currently triggered by star-imports in `lib_provisioning/cmd/env.nu` and `lib_provisioning/providers/registry.nu` must be invoked explicitly by handlers that depend on them — a per-handler decision, not an ambient guarantee", + "Bash is now load-bearing for alias and argument shaping; contributors who prefer Nu must understand that the wrapper cannot be replaced without solving Nu's module-parse problem first", + "The `platform logs` branch requires a dedicated thin handler that preserves interactive stdin (no `