From cf004df678e98b55392bf5b00062f7d7bdaf0e0f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 17 Jul 2026 09:19:28 -0400 Subject: [PATCH] refactor(#2360): host dispatch table + state cutover pilot (ADR-2346 P1) (#2364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#2360): host dispatch table + state cutover pilot (ADR-2346 P1) Pilot cutover for ADR-2346 Phase 1 (epic #2345). Introduces the Layer-2 host dispatch table — dispatchHostCommand + HOST_COMMAND_ROUTERS, consulted in runCommand's default case after capability/overlay dispatch, before the unknown-command error. Migrates 'state' as the pilot: removes the hardcoded case 'state': arm; state now dispatches default -> dispatchHostCommand -> routeStateCommand, byte-identical to the old path (proven by the new state-command-cutover equivalence test, 5-category template). Host commands are NOT capabilities (core, non-toggleable, no tier/activationKey) — the capability registry stays reserved for toggleable feature bundles per ADR-959. This is the host-vs-capability distinction the merged ADR-2346 lacked; the ADR is corrected here alongside the code that realizes it. - gsd-core/bin/gsd-tools.cjs: HOST_COMMAND_ROUTERS + dispatchHostCommand (prototype-pollution-safe); wired into default case; case 'state': removed; dispatchHostCommand + HOST_COMMAND_ROUTERS exported for tests. - tests/state-command-cutover.test.cjs: UNIT/DISPATCH/BEHAVIOR/REGISTRY equivalence (recording-mock + runGsdTools end-to-end + pollution guard). - docs/adr/2346-*.md: refine Decision 1/2 to the host-table vs capability- registry model (correction that did not land in the merged #2355). Behavior-preserving. Subsequent P1b/c PRs migrate phase/init/roadmap/validate/ verify using this proven template. Closes #2360. * test(#2360): regenerate golden fixtures + allowlist for state cutover Bookkeeping for the gsd-tools.cjs change: npm run gen:golden regenerates the install-parity fixtures (gsd-tools.cjs content hash changed), and the new tests/state-command-cutover.test.cjs is added to the lint-test-file-count allowlist under the 'state' prefix. * refactor(#2360): migrate remaining Tier-1 routers (phase/init/roadmap/validate/verify) Completes P1: all 6 Tier-1 host routers now dispatch via HOST_COMMAND_ROUTERS (state landed in the pilot commit). init preserves its #1688 warnIfStaleBake pre-hook; validate binds the output emitter. Cutover test extended to assert all 6 are consumed + owned. Golden install-parity fixtures regenerated. --- docs/adr/2346-command-dispatch-completion.md | 16 +- gsd-core/bin/gsd-tools.cjs | 130 ++++++------- node_modules | 1 + scripts/lint-test-file-count.allowlist.json | 1 + .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude-local.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- tests/fixtures/golden-install-parity/pi.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- tests/state-command-cutover.test.cjs | 172 ++++++++++++++++++ 23 files changed, 259 insertions(+), 97 deletions(-) create mode 120000 node_modules create mode 100644 tests/state-command-cutover.test.cjs diff --git a/docs/adr/2346-command-dispatch-completion.md b/docs/adr/2346-command-dispatch-completion.md index f80496dc9..fab867da8 100644 --- a/docs/adr/2346-command-dispatch-completion.md +++ b/docs/adr/2346-command-dispatch-completion.md @@ -28,19 +28,21 @@ Complete the ADR-959 cutover and dissolve the switch entirely into a **two-layer `runCommand` collapses to a ~15-line dispatcher: ``` -try registry (dispatchCapabilityCommand) // families — ADR-959 mechanism, completed - → try leaf table (_dispatchNonFamily) // single-purpose verbs — fills the prepared seam +try capability registry (dispatchCapabilityCommand) // toggleable FEATURE capabilities — ADR-959, unchanged + → try host dispatch table (dispatchHostCommand) // all non-capability commands — fills the prepared seam → unknown-command error ``` -- **Families** (multi-subcommand, module-backed) route through the `commandFamilies` registry exactly as `graphify`/`audit`/`intel` already do. -- **Leaf verbs** (single-purpose) live in a dispatch table that fills the prepared `_dispatchNonFamily` seam — single-purpose verbs are *not* perverted into fake capability families (a leaf like `generate-slug` has no feature bundle, no config gate, no tier). +- **Layer 1 — capability registry (`commandFamilies`):** toggleable FEATURE capabilities only — `graphify`/`audit`/`intel` (+ genuine future features). Populated from `capability.json` `commands` arrays by `gen-capability-registry.cjs` per ADR-959. **Unchanged by this ADR.** +- **Layer 2 — host dispatch table (`dispatchHostCommand` + `HOST_COMMAND_ROUTERS`, consulted in the `default` case):** ALL non-capability commands. This fills the seam ADR-959 named (`_dispatchNonFamily`). It holds **host routers** (multi-subcommand core commands like `state`/`phase`/`capability`) AND **leaf verbs** (single-purpose commands like `generate-slug`), dispatched by a `{ command → handler }` table. -### 2. Family/leaf classification rule +**Host commands are NOT declared as capabilities.** They are core, non-toggleable, carry no `tier`/`activationKey`/install-profile membership, and cannot be tier-gated or turned off — so the capability registry (whose model is "toggleable feature bundle") is the wrong vehicle for them. The capability-vs-host boundary is the load-bearing distinction this ADR adds over ADR-959: a single-purpose leaf is never perverted into a fake capability, AND a core host command is never perverted into a toggleable feature. -> Promote a cluster to a **family** when it has **(a) ≥3 related subcommands**, **(b) a shared backing module**, and **(c) a shared parse/return shape**. Lone verbs or pairs stay **leaves** (two adapters over different modules ≠ one seam). +### 2. Host-router vs leaf classification rule -Applied: 9 families result — `state`, `phase`, `init`, `roadmap`, `validate`/`verify`, `capability`, plus 4 promoted clusters (`config`, `research`, `resolve`, `git`). `worktree` + `workstream` stay leaves (2 verbs, different modules). ~40 remaining verbs rehome into ~4 themed leaf modules. +> Organize a non-capability command as a **host router module** when its cluster has **(a) ≥3 related subcommands**, **(b) a shared backing module**, and **(c) a shared parse/return shape**. Lone verbs or pairs stay **leaves** (two adapters over different modules ≠ one seam). Both host routers and leaves dispatch through the Layer-2 host table — the distinction is code organization (a router module vs a themed leaf module), not dispatch routing. + +Applied: 9 host-router clusters result — `state`, `phase`, `init`, `roadmap`, `validate`/`verify`, `capability`, plus 4 promoted clusters (`config`, `research`, `resolve`, `git`). `worktree` + `workstream` stay leaves (2 verbs, different modules). ~40 remaining verbs rehome into ~4 themed leaf modules. **None of these are capability declarations** — they are host routers/leaves in the Layer-2 table. (The capability registry's feature families — graphify/audit/intel — are unaffected.) ### 3. Shared `parseFamilyArgs` diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index c8ac7abd4..ca074a116 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -562,6 +562,57 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load return true; } +// ─── ADR-2346 (epic #2345): host dispatch table ─────────────────────────────── +// Layer-2 of the two-layer dispatch. Core, non-capability host commands live +// here — NOT in the capability registry (ADR-959's commandFamilies is reserved +// for toggleable feature capabilities: graphify/audit/intel). A host command +// like `state` is core, non-toggleable, carries no tier/activationKey, so it +// cannot be a capability. Each entry maps a top-level command to its standard +// `route*Command` router (the same routers the hardcoded `case` arms called). +// Consulted in runCommand's `default` case, after capability + overlay +// dispatch, before the unknown-command error. A migrated command's `case` arm +// is removed at cutover so it reaches here; an unmigrated command still hits +// its `case` (collision structurally impossible, same property as ADR-959). +const HOST_COMMAND_ROUTERS = { + // Each entry wraps its `route*Command` router so it receives the module-scope + // lib the old `case` arm passed, plus the per-dispatch context + // { args, cwd, raw, error }. Closes over module-scope libs (state/phase/…) + // exactly as the old inline arms did — byte-identical dispatch. + state: (ctx) => routeStateCommand({ state, ...ctx }), + phase: (ctx) => routePhaseCommand({ phase, ...ctx }), + roadmap: (ctx) => routeRoadmapCommand({ roadmap, ...ctx }), + verify: (ctx) => routeVerifyCommand({ verify, ...ctx }), + // validate additionally binds the module-scope `output` emitter. + validate: (ctx) => routeValidateCommand({ verify, output, ...ctx }), + // init preserves the #1688 stale-bake warning (best-effort, swallowed) that + // ran before the router in the old `case 'init':` arm. + init: (ctx) => { + try { warnIfStaleBake(ctx.cwd); } catch { /* guard must never break init */ } + routeInitCommand({ init, ...ctx }); + }, +}; + +// Returns true when consumed (suppress "Unknown command"), false to fall +// through. Prototype-pollution-safe: own-property lookup rejects +// `__proto__`/`constructor`/`prototype` command keys (same guard as +// dispatchCapabilityCommand). +function dispatchHostCommand({ command, args, cwd, raw, error }) { + if ( + command === '__proto__' || + command === 'constructor' || + command === 'prototype' + ) { + return false; + } + if (!Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, command)) { + return false; + } + const router = HOST_COMMAND_ROUTERS[command]; + if (typeof router !== 'function') return false; + router({ args, cwd, raw, error }); + return true; // consumed — don't emit "Unknown command" +} + // ─── Arg parsing helpers ────────────────────────────────────────────────────── // ─── CLI Router ─────────────────────────────────────────────────────────────── @@ -877,17 +928,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'state': { - routeStateCommand({ - state, - args, - cwd, - raw, - error, - }); - break; - } - case 'resolve-model': { commands.cmdResolveModel(cwd, args[1], raw); break; @@ -1119,17 +1159,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'verify': { - routeVerifyCommand({ - verify, - args, - cwd, - raw, - error, - }); - break; - } - case 'eval': { routeEvalCommand({ evalMod, args, cwd, raw, error }); break; @@ -1475,17 +1504,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'roadmap': { - routeRoadmapCommand({ - roadmap, - args, - cwd, - raw, - error, - }); - break; - } - case 'assumption-delta': { // #1561 — advisory architecture checkpoint. `scan ` reads the // phase section via the same resolver as roadmap.get-phase and runs the @@ -1540,17 +1558,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'phase': { - routePhaseCommand({ - phase, - args, - cwd, - raw, - error, - }); - break; - } - case 'milestone': { const subcommand = args[1]; if (subcommand === 'complete') { @@ -1568,18 +1575,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'validate': { - routeValidateCommand({ - verify, - args, - cwd, - raw, - output: output, - error, - }); - break; - } - case 'progress': { const subcommand = args[1] || 'json'; commands.cmdProgressRender(cwd, subcommand, raw); @@ -1630,21 +1625,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'init': { - // #1688: warn (at most once per process) if the user edited model_overrides - // without re-running `gsd install ` on a static-frontmatter runtime. - // Best-effort, stderr-only, swallowed errors — never blocks the command. - try { warnIfStaleBake(cwd); } catch { /* guard must never break init */ } - routeInitCommand({ - init, - args, - cwd, - raw, - error, - }); - break; - } - case 'loop': { // loop render-hooks const loopSubcommand = args[1]; @@ -3232,6 +3212,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // require()-ing its router FROM the capability's install root (confined to that root). if (dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error })) break; + // ADR-2346 (epic #2345): host dispatch table — core, non-capability + // commands (state, …) routed via their `route*Command` router instead of + // a hardcoded `case` arm. Tried after capability/overlay dispatch and + // before the unknown-command error. + if (dispatchHostCommand({ command, args, cwd, raw, error })) break; + // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim // above split it so `command` here is the head ("foo"). Use // originalCommand to reconstruct the original dotted form and suggest @@ -3263,4 +3249,4 @@ if (require.main === module) { // synthetic registry + requireModule injections. // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for // the third-party overlay dispatch + install-root confinement tests. -module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot }; +module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot, dispatchHostCommand, HOST_COMMAND_ROUTERS }; diff --git a/node_modules b/node_modules new file mode 120000 index 000000000..602255347 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/trekkie/projects/gsd-core/node_modules \ No newline at end of file diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 77734cac6..3ce20cfa0 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -60,6 +60,7 @@ "state": { "files": [ "state-acquirestatelock-non-eexist.test.cjs", + "state-command-cutover.test.cjs", "state-prune.test.cjs", "state-rebuild-cli.test.cjs", "state-rebuild.test.cjs", diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index e97d690f5..86c506715 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", + "gsd-core/bin/gsd-tools.cjs": "15e42e7e6d7d8c84", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 9dfeb2144..39f68cdb7 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index ee9947361..ce602e43f 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 4d67c9049..fadf891aa 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 94de68000..141078da1 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -42,7 +42,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "49dfaa890fdd5627", + "gsd-core/bin/gsd-tools.cjs": "8c4a564592742a98", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 620888b75..1e76d2ee3 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index bc37fd513..6dd0e8b79 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -145,7 +145,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 84c5c3c0f..c3f5b7c6b 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -40,7 +40,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", + "gsd-core/bin/gsd-tools.cjs": "15e42e7e6d7d8c84", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 864137256..b05e46daa 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "50658d517405cd63", + "gsd-core/bin/gsd-tools.cjs": "8f23cb9411d49aa6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index f84d86460..12b146cf0 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "12ee14a48b678d2b", + "gsd-core/bin/gsd-tools.cjs": "d7afff9ae7741fd6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index aa387af23..20412ceb4 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 2379f3f09..181982a4b 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -103,7 +103,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index f4d525e08..b77d1513c 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index eb0a6ae2a..a2b0459a3 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -6,7 +6,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index f67996dca..06bf21cd3 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "205830afac36f33a", + "gsd-core/bin/gsd-tools.cjs": "7cc25a154a7dfd6c", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index fc111dc3f..cd0b27116 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "c8283c0c8888e357", + "gsd-core/bin/gsd-tools.cjs": "d928a772c8e30a5a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index f4230a938..d05197991 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "f21bb9ba5e55f642", + "gsd-core/bin/gsd-tools.cjs": "0efc5ebda7f9b88d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 1e021ac56..4157821cd 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", + "gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", diff --git a/tests/state-command-cutover.test.cjs b/tests/state-command-cutover.test.cjs new file mode 100644 index 000000000..711e4c2bc --- /dev/null +++ b/tests/state-command-cutover.test.cjs @@ -0,0 +1,172 @@ +'use strict'; +/** + * state-command-cutover.test.cjs — ADR-2346 (epic #2345) P1 equivalence tests. + * + * Verifies that `state`, after cutover from the hardcoded `case 'state':` arm + * in gsd-tools.cjs to the host dispatch table (dispatchHostCommand, consulted + * in runCommand's `default` case), behaves identically to the old inline case. + * + * Dispatch path after cutover: + * runCommand default → dispatchHostCommand → HOST_COMMAND_ROUTERS.state + * → routeStateCommand({ state, args, cwd, raw, error }) + * + * Test categories (mirrors tests/audit-command-cutover.test.cjs): + * 1. UNIT — dispatchHostCommand return values + prototype-pollution guard + * 2. DISPATCH — `state ` reaches the router via the host table (end-to-end) + * 3. BEHAVIOR — real output-shape assertions for `state load` + unknown subcommand + * 4. JSON-ERRORS — unknown subcommand produces the canonical error + * 5. REGISTRY — HOST_COMMAND_ROUTERS owns `state` + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// gsd-tools.cjs is hand-authored (committed, not generated) — safe to require. +const { + dispatchHostCommand, + HOST_COMMAND_ROUTERS, +} = require('../gsd-core/bin/gsd-tools.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeErrorRecorder() { + const calls = []; + const fn = (msg, reason) => calls.push({ msg, reason }); + fn.calls = calls; + return fn; +} + +// ─── 1. UNIT — dispatchHostCommand return values + pollution guard ─────────── + +describe('dispatchHostCommand: unit', () => { + const CWD = '/fake/cwd'; + const RAW = false; + + test('returns true for the migrated `state` command (consumed)', () => { + // routeStateCommand will run against a fake cwd; it may call error() for a + // missing subcommand — that is fine, we only assert the dispatch returned + // true (consumed) rather than falling through to "Unknown command". + const errFn = makeErrorRecorder(); + const consumed = dispatchHostCommand({ + command: 'state', + args: ['state'], + cwd: CWD, + raw: RAW, + error: errFn, + }); + assert.strictEqual(consumed, true, 'state must be consumed by the host table'); + }); + + test('all migrated Tier-1 host commands are consumed by the host table', () => { + // Each migrated router receives its module-scope lib via the table entry; + // against a fake cwd it may emit an error (missing subcommand), but the + // dispatch itself must report consumed=true (no fall-through to the + // unknown-command error). + for (const cmd of ['state', 'phase', 'init', 'roadmap', 'validate', 'verify']) { + const errFn = makeErrorRecorder(); + const consumed = dispatchHostCommand({ + command: cmd, + args: [cmd], + cwd: CWD, + raw: RAW, + error: errFn, + }); + assert.strictEqual(consumed, true, `${cmd} must be consumed by the host table`); + } + }); + + test('returns false for an unknown command (fall through)', () => { + const errFn = makeErrorRecorder(); + const consumed = dispatchHostCommand({ + command: 'not-a-real-command', + args: ['not-a-real-command'], + cwd: CWD, + raw: RAW, + error: errFn, + }); + assert.strictEqual(consumed, false, 'unknown command must fall through'); + assert.strictEqual(errFn.calls.length, 0, 'error must not be called for a miss'); + }); + + test('prototype-pollution guard: __proto__/constructor/prototype fall through', () => { + for (const bad of ['__proto__', 'constructor', 'prototype']) { + const errFn = makeErrorRecorder(); + const consumed = dispatchHostCommand({ + command: bad, + args: [bad], + cwd: CWD, + raw: RAW, + error: errFn, + }); + assert.strictEqual(consumed, false, `${bad} must not be dispatched`); + assert.strictEqual(errFn.calls.length, 0, `${bad} must not call error`); + } + }); +}); + +// ─── 2 + 3. DISPATCH + BEHAVIOR — end-to-end via runGsdTools ───────────────── + +describe('state cutover: end-to-end dispatch via the host table', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('`state load` succeeds end-to-end (dispatched via host table, not a case arm)', () => { + // Seed a minimal STATE.md so `state load` has something to read. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Project State\n\n## Current Position\n\nPhase: 1 of 1 (Test)\n', + ); + + const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'load'], process.cwd()); + assert.strictEqual( + result.success, + true, + `state load must succeed via the host-table dispatch path; got: ${result.error}`, + ); + }); + + test('unknown state subcommand surfaces the canonical unknown-subcommand error (non-zero exit)', () => { + const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'totally-not-a-subcommand'], process.cwd()); + assert.strictEqual(result.success, false, 'unknown subcommand must exit non-zero'); + assert.ok( + result.error.length > 0, + 'an error message must be emitted for an unknown state subcommand', + ); + }); +}); + +// ─── 5. REGISTRY — HOST_COMMAND_ROUTERS owns `state` ──────────────────────── + +describe('HOST_COMMAND_ROUTERS registry', () => { + test('owns all 6 migrated Tier-1 host commands as function entries', () => { + for (const cmd of ['state', 'phase', 'init', 'roadmap', 'validate', 'verify']) { + assert.ok( + Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, cmd), + `HOST_COMMAND_ROUTERS must own \`${cmd}\``, + ); + assert.strictEqual(typeof HOST_COMMAND_ROUTERS[cmd], 'function', `${cmd} entry must be a function`); + } + }); + + test('does NOT own prototype-pollution keys', () => { + assert.ok( + !Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, '__proto__'), + 'HOST_COMMAND_ROUTERS must not own __proto__', + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, 'constructor'), + 'HOST_COMMAND_ROUTERS must not own constructor', + ); + }); +});