From 43eafea3d5161472c2dc2699f793ac100d4c45f4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 26 May 2026 11:23:53 -0400 Subject: [PATCH] fix(#303): deepen command routing seams and alias parity guard (#304) --- .githooks/pre-commit | 2 +- get-shit-done/bin/gsd-tools.cjs | 42 +--- .../bin/lib/cjs-command-router-adapter.cjs | 89 +++++++- .../bin/lib/command-arg-projection.cjs | 55 +++++ get-shit-done/bin/lib/init-command-router.cjs | 131 +++--------- .../bin/lib/phases-command-router.cjs | 36 +--- .../bin/lib/roadmap-command-router.cjs | 32 +-- .../bin/lib/state-command-router.cjs | 54 ++--- .../bin/lib/validate-command-router.cjs | 21 +- .../bin/lib/verify-command-router.cjs | 63 ++---- package.json | 1 + scripts/check-alias-drift.cjs | 108 ++++++++++ tests/cjs-command-router-adapter.test.cjs | 191 ++++++++++++++++++ 13 files changed, 523 insertions(+), 302 deletions(-) create mode 100644 get-shit-done/bin/lib/command-arg-projection.cjs create mode 100644 scripts/check-alias-drift.cjs create mode 100644 tests/cjs-command-router-adapter.test.cjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index dc81b8d71..6f0d10e3f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.generated\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then +if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then npm run check:alias-drift fi diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 5e403d9f8..46b577681 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -198,6 +198,7 @@ const { routePhaseCommand } = require('./lib/phase-command-router.cjs'); const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); const { routeValidateCommand } = require('./lib/validate-command-router.cjs'); const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs'); +const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs'); // ─── Bridge collapsed (Phase 4) ──────────────────────────────────────────────── // Non-family commands now run through their CJS handlers directly. Keep the @@ -235,44 +236,6 @@ function _dispatchNonFamily({ registryCommand, registryArgs, legacyCommand, lega // ─── Arg parsing helpers ────────────────────────────────────────────────────── -/** - * Extract named --flag pairs from an args array. - * Returns an object mapping flag names to their values (null if absent). - * Flags listed in `booleanFlags` are treated as boolean (no value consumed). - * - * parseNamedArgs(args, 'phase', 'plan') → { phase: '3', plan: '1' } - * parseNamedArgs(args, [], ['amend', 'force']) → { amend: true, force: false } - */ -function parseNamedArgs(args, valueFlags = [], booleanFlags = []) { - const result = {}; - for (const flag of valueFlags) { - const idx = args.indexOf(`--${flag}`); - result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--') - ? args[idx + 1] - : null; - } - for (const flag of booleanFlags) { - result[flag] = args.includes(`--${flag}`); - } - return result; -} - -/** - * Collect all tokens after --flag until the next --flag or end of args. - * Handles multi-word values like --name Foo Bar Version 1. - * Returns null if the flag is absent. - */ -function parseMultiwordArg(args, flag) { - const idx = args.indexOf(`--${flag}`); - if (idx === -1) return null; - const tokens = []; - for (let i = idx + 1; i < args.length; i++) { - if (args[i].startsWith('--')) break; - tokens.push(args[i]); - } - return tokens.length > 0 ? tokens.join(' ') : null; -} - // ─── CLI Router ─────────────────────────────────────────────────────────────── async function main() { @@ -563,7 +526,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand args, cwd, raw, - parseNamedArgs, error, }); break; @@ -935,7 +897,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand args, cwd, raw, - parseNamedArgs, output: core.output, error, }); @@ -1015,7 +976,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand args, cwd, raw, - parseNamedArgs, error, }); break; diff --git a/get-shit-done/bin/lib/cjs-command-router-adapter.cjs b/get-shit-done/bin/lib/cjs-command-router-adapter.cjs index 1598b40e2..1c7b7251f 100644 --- a/get-shit-done/bin/lib/cjs-command-router-adapter.cjs +++ b/get-shit-done/bin/lib/cjs-command-router-adapter.cjs @@ -1,5 +1,7 @@ 'use strict'; +const { createHub, ERROR_KINDS } = require('./command-routing-hub.cjs'); + /** * CJS Command Router Adapter Module * @@ -16,6 +18,41 @@ function routeCjsCommandFamily({ unsupported = {}, unknownMessage, error, + cwd, + raw, +}) { + routeHubCommandFamily({ + family: '__legacy_cjs_family__', + args, + subcommands, + handlers, + defaultSubcommand, + unsupported, + unknownMessage, + error, + cwd, + raw, + }); +} + +/** + * Hub-backed family router adapter. + * + * Deepens the command-topology seam by routing family handlers through + * CommandRoutingHub's typed Result contract instead of ad-hoc per-router + * lookup + error handling branches. + */ +function routeHubCommandFamily({ + family, + args, + subcommands, + handlers, + defaultSubcommand, + unsupported = {}, + unknownMessage, + error, + cwd, + raw, }) { const subcommand = args[1] || defaultSubcommand; @@ -24,16 +61,58 @@ function routeCjsCommandFamily({ return; } - const handler = subcommand ? handlers[subcommand] : null; - if (handler) { - handler(); + const available = subcommands.filter((s) => !unsupported[s]); + const registryHandlers = Object.fromEntries( + Object.entries(handlers).map(([name, handler]) => [ + name, + () => { + const result = handler(); + if (result && typeof result === 'object' && Object.prototype.hasOwnProperty.call(result, 'ok')) { + return result; + } + return { ok: true, data: null }; + }, + ]), + ); + + const hub = createHub({ + cjsRegistry: { [family]: registryHandlers }, + manifest: { [family]: available }, + }); + + const result = hub.dispatch({ + family, + subcommand, + args: args.slice(2), + cwd, + raw, + }); + + if (result.ok) return; + if (result.kind === ERROR_KINDS.UnknownCommand) { + error(unknownMessage(subcommand, available)); return; } + if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) { + error(result.reason); + return; + } + error(result.message); +} - const available = subcommands.filter(s => !unsupported[s]); - error(unknownMessage(subcommand, available)); +/** + * Projection helper for family routers that still declare SDK registry metadata + * but execute the CJS fallback path at this seam. + * + * Accepts variable argument shapes so routers can pass legacy projection tuples + * (`registryCommand`, `registryArgs`, `legacyArgs`, optional `rawFormatter`, `cjsFallback`). + */ +function cjsFallbackHandler(...projectionArgs) { + return projectionArgs[projectionArgs.length - 1]; } module.exports = { routeCjsCommandFamily, + routeHubCommandFamily, + cjsFallbackHandler, }; diff --git a/get-shit-done/bin/lib/command-arg-projection.cjs b/get-shit-done/bin/lib/command-arg-projection.cjs new file mode 100644 index 000000000..3ab778965 --- /dev/null +++ b/get-shit-done/bin/lib/command-arg-projection.cjs @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Command Argument Projection Module + * + * Shared helpers for command-family adapters to project argv tokens into + * typed named values and multi-word segments. + */ + +/** + * Extract named --flag pairs from an args array. + * Returns an object mapping flag names to their values (null if absent). + * Flags listed in `booleanFlags` are treated as booleans. + * + * @param {string[]} args + * @param {string[]} [valueFlags] + * @param {string[]} [booleanFlags] + * @returns {Record} + */ +function parseNamedArgs(args, valueFlags = [], booleanFlags = []) { + const result = {}; + for (const flag of valueFlags) { + const idx = args.indexOf(`--${flag}`); + result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--') + ? args[idx + 1] + : null; + } + for (const flag of booleanFlags) { + result[flag] = args.includes(`--${flag}`); + } + return result; +} + +/** + * Collect all tokens after --flag until the next --flag or end of args. + * + * @param {string[]} args + * @param {string} flag + * @returns {string|null} + */ +function parseMultiwordArg(args, flag) { + const idx = args.indexOf(`--${flag}`); + if (idx === -1) return null; + const tokens = []; + for (let i = idx + 1; i < args.length; i++) { + if (args[i].startsWith('--')) break; + tokens.push(args[i]); + } + return tokens.length > 0 ? tokens.join(' ') : null; +} + +module.exports = { + parseNamedArgs, + parseMultiwordArg, +}; diff --git a/get-shit-done/bin/lib/init-command-router.cjs b/get-shit-done/bin/lib/init-command-router.cjs index e10105ecf..1bf7e3ae6 100644 --- a/get-shit-done/bin/lib/init-command-router.cjs +++ b/get-shit-done/bin/lib/init-command-router.cjs @@ -2,6 +2,7 @@ const { INIT_SUBCOMMANDS } = require('./command-aliases.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { parseNamedArgs } = require('./command-arg-projection.cjs'); /** * Manifest-backed init subcommand router. @@ -15,11 +16,7 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * CJS-only subcommands: none. * SDK-only (unsupported in CJS router): none. */ -function routeInitCommand({ init, args, cwd, raw, parseNamedArgs, error }) { - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, cjsFallback) { - return cjsFallback; - } - +function routeInitCommand({ init, args, cwd, raw, error }) { routeCjsCommandFamily({ args, subcommands: INIT_SUBCOMMANDS, @@ -27,111 +24,31 @@ function routeInitCommand({ init, args, cwd, raw, parseNamedArgs, error }) { error, unknownMessage: (_subcommand, available) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`, handlers: { - 'execute-phase': sdkHandler( - 'init.execute-phase', - args.slice(2), - args.slice(1), - () => { - const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); - init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd }); - }, - ), - 'plan-phase': sdkHandler( - 'init.plan-phase', - args.slice(2), - args.slice(1), - () => { - const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); - init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd }); - }, - ), - 'new-project': sdkHandler( - 'init.new-project', - args.slice(2), - args.slice(1), - () => init.cmdInitNewProject(cwd, raw), - ), - 'new-milestone': sdkHandler( - 'init.new-milestone', - args.slice(2), - args.slice(1), - () => init.cmdInitNewMilestone(cwd, raw), - ), - quick: sdkHandler( - 'init.quick', - args.slice(2), - args.slice(1), - () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw), - ), - 'ingest-docs': sdkHandler( - 'init.ingest-docs', - args.slice(2), - args.slice(1), - () => init.cmdInitIngestDocs(cwd, raw), - ), - resume: sdkHandler( - 'init.resume', - args.slice(2), - args.slice(1), - () => init.cmdInitResume(cwd, raw), - ), - 'verify-work': sdkHandler( - 'init.verify-work', - args.slice(2), - args.slice(1), - () => init.cmdInitVerifyWork(cwd, args[2], raw), - ), - 'phase-op': sdkHandler( - 'init.phase-op', - args.slice(2), - args.slice(1), - () => init.cmdInitPhaseOp(cwd, args[2], raw), - ), - todos: sdkHandler( - 'init.todos', - args.slice(2), - args.slice(1), - () => init.cmdInitTodos(cwd, args[2], raw), - ), - 'milestone-op': sdkHandler( - 'init.milestone-op', - args.slice(2), - args.slice(1), - () => init.cmdInitMilestoneOp(cwd, raw), - ), - 'map-codebase': sdkHandler( - 'init.map-codebase', - args.slice(2), - args.slice(1), - () => init.cmdInitMapCodebase(cwd, raw), - ), - progress: sdkHandler( - 'init.progress', - args.slice(2), - args.slice(1), - () => init.cmdInitProgress(cwd, raw), - ), + 'execute-phase': () => { + const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); + init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd }); + }, + 'plan-phase': () => { + const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); + init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd }); + }, + 'new-project': () => init.cmdInitNewProject(cwd, raw), + 'new-milestone': () => init.cmdInitNewMilestone(cwd, raw), + quick: () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw), + 'ingest-docs': () => init.cmdInitIngestDocs(cwd, raw), + resume: () => init.cmdInitResume(cwd, raw), + 'verify-work': () => init.cmdInitVerifyWork(cwd, args[2], raw), + 'phase-op': () => init.cmdInitPhaseOp(cwd, args[2], raw), + todos: () => init.cmdInitTodos(cwd, args[2], raw), + 'milestone-op': () => init.cmdInitMilestoneOp(cwd, raw), + 'map-codebase': () => init.cmdInitMapCodebase(cwd, raw), + progress: () => init.cmdInitProgress(cwd, raw), // Keep manager on CJS for now so runtime-specific command rendering // (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers. manager: () => init.cmdInitManager(cwd, raw), - 'new-workspace': sdkHandler( - 'init.new-workspace', - args.slice(2), - args.slice(1), - () => init.cmdInitNewWorkspace(cwd, raw), - ), - 'list-workspaces': sdkHandler( - 'init.list-workspaces', - args.slice(2), - args.slice(1), - () => init.cmdInitListWorkspaces(cwd, raw), - ), - 'remove-workspace': sdkHandler( - 'init.remove-workspace', - args.slice(2), - args.slice(1), - () => init.cmdInitRemoveWorkspace(cwd, args[2], raw), - ), + 'new-workspace': () => init.cmdInitNewWorkspace(cwd, raw), + 'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw), + 'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw), }, }); } diff --git a/get-shit-done/bin/lib/phases-command-router.cjs b/get-shit-done/bin/lib/phases-command-router.cjs index 7918e1799..4b98b73b7 100644 --- a/get-shit-done/bin/lib/phases-command-router.cjs +++ b/get-shit-done/bin/lib/phases-command-router.cjs @@ -21,10 +21,6 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * CJS-only subcommands: none. */ function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) { - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, cjsFallback) { - return cjsFallback; - } - routeCjsCommandFamily({ args, // Exclude 'archive' — it's SDK-only and not supported in CJS. Excluding @@ -34,27 +30,17 @@ function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) { error, unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`, handlers: { - list: sdkHandler( - 'phases.list', - args.slice(2), - args.slice(1), - () => { - const typeIndex = args.indexOf('--type'); - const phaseIndex = args.indexOf('--phase'); - const options = { - type: typeIndex !== -1 ? args[typeIndex + 1] : null, - phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, - includeArchived: args.includes('--include-archived'), - }; - phase.cmdPhasesList(cwd, options, raw); - }, - ), - clear: sdkHandler( - 'phases.clear', - args.slice(2), - args.slice(1), - () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)), - ), + list: () => { + const typeIndex = args.indexOf('--type'); + const phaseIndex = args.indexOf('--phase'); + const options = { + type: typeIndex !== -1 ? args[typeIndex + 1] : null, + phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, + includeArchived: args.includes('--include-archived'), + }; + phase.cmdPhasesList(cwd, options, raw); + }, + clear: () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)), }, }); } diff --git a/get-shit-done/bin/lib/roadmap-command-router.cjs b/get-shit-done/bin/lib/roadmap-command-router.cjs index 18ef35332..03b19bb1b 100644 --- a/get-shit-done/bin/lib/roadmap-command-router.cjs +++ b/get-shit-done/bin/lib/roadmap-command-router.cjs @@ -16,10 +16,6 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * SDK-only (unsupported in CJS router): none. */ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) { - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, cjsFallback) { - return cjsFallback; - } - routeCjsCommandFamily({ args, subcommands: ROADMAP_SUBCOMMANDS, @@ -27,30 +23,10 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) { error, unknownMessage: (_subcommand, available) => `Unknown roadmap subcommand. Available: ${available.join(', ')}`, handlers: { - 'get-phase': sdkHandler( - 'roadmap.get-phase', - args.slice(2), - args.slice(1), - () => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw), - ), - analyze: sdkHandler( - 'roadmap.analyze', - args.slice(2), - args.slice(1), - () => roadmap.cmdRoadmapAnalyze(cwd, raw), - ), - 'update-plan-progress': sdkHandler( - 'roadmap.update-plan-progress', - args.slice(2), - args.slice(1), - () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw), - ), - 'annotate-dependencies': sdkHandler( - 'roadmap.annotate-dependencies', - args.slice(2), - args.slice(1), - () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw), - ), + 'get-phase': () => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw), + analyze: () => roadmap.cmdRoadmapAnalyze(cwd, raw), + 'update-plan-progress': () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw), + 'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw), }, }); } diff --git a/get-shit-done/bin/lib/state-command-router.cjs b/get-shit-done/bin/lib/state-command-router.cjs index 2648b56a1..d0a435def 100644 --- a/get-shit-done/bin/lib/state-command-router.cjs +++ b/get-shit-done/bin/lib/state-command-router.cjs @@ -1,7 +1,8 @@ 'use strict'; const { STATE_SUBCOMMANDS } = require('./command-aliases.cjs'); -const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { routeHubCommandFamily, cjsFallbackHandler } = require('./cjs-command-router-adapter.cjs'); +const { parseNamedArgs } = require('./command-arg-projection.cjs'); /** * Manifest-backed state subcommand router. @@ -14,7 +15,7 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * for workstream requests; subprocess is disabled in the sync bridge worker). * - Any command when the SDK is not available (build not present). */ -function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { +function routeStateCommand({ state, args, cwd, raw, error }) { const parsePlans = (plans) => { const parsedPlans = plans == null ? null : Number.parseInt(plans, 10); if (plans != null && Number.isNaN(parsedPlans)) { @@ -24,11 +25,8 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { return parsedPlans; }; - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, _rawFormatter, cjsFallback) { - return cjsFallback; - } - - routeCjsCommandFamily({ + routeHubCommandFamily({ + family: 'state', args, subcommands: ['load', 'complete-phase', ...STATE_SUBCOMMANDS.filter((s) => s !== 'load')], defaultSubcommand: 'load', @@ -36,37 +34,39 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { 'add-roadmap-evolution': 'state add-roadmap-evolution is SDK-only. Use: gsd-sdk query state.add-roadmap-evolution ...', }, error, + cwd, + raw, unknownMessage: (subcommand, available) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`, handlers: { - load: sdkHandler( + load: cjsFallbackHandler( 'state.load', [], args.slice(1), null, () => state.cmdStateLoad(cwd, raw), ), - json: sdkHandler( + json: cjsFallbackHandler( 'state.json', [], args.slice(1), null, () => state.cmdStateJson(cwd, raw), ), - get: sdkHandler( + get: cjsFallbackHandler( 'state.get', args.slice(2), args.slice(1), null, () => state.cmdStateGet(cwd, args[2], raw), ), - update: sdkHandler( + update: cjsFallbackHandler( 'state.update', args.slice(2), args.slice(1), null, () => state.cmdStateUpdate(cwd, args[2], args[3]), ), - patch: sdkHandler( + patch: cjsFallbackHandler( 'state.patch', args.slice(2), args.slice(1), @@ -83,14 +83,14 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStatePatch(cwd, patches, raw); }, ), - 'advance-plan': sdkHandler( + 'advance-plan': cjsFallbackHandler( 'state.advance-plan', [], args.slice(1), null, () => state.cmdStateAdvancePlan(cwd, raw), ), - 'record-metric': sdkHandler( + 'record-metric': cjsFallbackHandler( 'state.record-metric', args.slice(2), args.slice(1), @@ -100,14 +100,14 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw); }, ), - 'update-progress': sdkHandler( + 'update-progress': cjsFallbackHandler( 'state.update-progress', [], args.slice(1), null, () => state.cmdStateUpdateProgress(cwd, raw), ), - 'add-decision': sdkHandler( + 'add-decision': cjsFallbackHandler( 'state.add-decision', args.slice(2), args.slice(1), @@ -117,7 +117,7 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw); }, ), - 'add-blocker': sdkHandler( + 'add-blocker': cjsFallbackHandler( 'state.add-blocker', args.slice(2), args.slice(1), @@ -127,14 +127,14 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateAddBlocker(cwd, { text, text_file }, raw); }, ), - 'resolve-blocker': sdkHandler( + 'resolve-blocker': cjsFallbackHandler( 'state.resolve-blocker', args.slice(2), args.slice(1), null, () => state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw), ), - 'record-session': sdkHandler( + 'record-session': cjsFallbackHandler( 'state.record-session', args.slice(2), args.slice(1), @@ -144,7 +144,7 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateRecordSession(cwd, { stopped_at, resume_file: resume_file || 'None' }, raw); }, ), - 'begin-phase': sdkHandler( + 'begin-phase': cjsFallbackHandler( 'state.begin-phase', args.slice(2), args.slice(1), @@ -154,7 +154,7 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateBeginPhase(cwd, p, name, parsePlans(plans), raw); }, ), - 'signal-waiting': sdkHandler( + 'signal-waiting': cjsFallbackHandler( 'state.signal-waiting', args.slice(2), args.slice(1), @@ -164,14 +164,14 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdSignalWaiting(cwd, type, question, options, p, raw); }, ), - 'signal-resume': sdkHandler( + 'signal-resume': cjsFallbackHandler( 'state.signal-resume', [], args.slice(1), null, () => state.cmdSignalResume(cwd, raw), ), - 'planned-phase': sdkHandler( + 'planned-phase': cjsFallbackHandler( 'state.planned-phase', args.slice(2), args.slice(1), @@ -181,14 +181,14 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStatePlannedPhase(cwd, p, parsePlans(plans), raw); }, ), - validate: sdkHandler( + validate: cjsFallbackHandler( 'state.validate', [], args.slice(1), null, () => state.cmdStateValidate(cwd, raw), ), - sync: sdkHandler( + sync: cjsFallbackHandler( 'state.sync', args.slice(2), args.slice(1), @@ -198,7 +198,7 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { state.cmdStateSync(cwd, { verify }, raw); }, ), - prune: sdkHandler( + prune: cjsFallbackHandler( 'state.prune', args.slice(2), args.slice(1), @@ -213,7 +213,7 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { const { phase: p } = parseNamedArgs(args, ['phase']); state.cmdStateCompletePhase(cwd, raw, p || args[2]); }, - 'milestone-switch': sdkHandler( + 'milestone-switch': cjsFallbackHandler( 'state.milestone-switch', args.slice(2), args.slice(1), diff --git a/get-shit-done/bin/lib/validate-command-router.cjs b/get-shit-done/bin/lib/validate-command-router.cjs index 1d93a9605..dfbb6903c 100644 --- a/get-shit-done/bin/lib/validate-command-router.cjs +++ b/get-shit-done/bin/lib/validate-command-router.cjs @@ -3,6 +3,7 @@ const { VALIDATE_SUBCOMMANDS } = require('./command-aliases.cjs'); const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { parseNamedArgs } = require('./command-arg-projection.cjs'); /** * Manifest-backed validate subcommand router. @@ -20,11 +21,7 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * * SDK-only (unsupported in CJS router): none. */ -function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: outputFn, error }) { - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, cjsFallback) { - return cjsFallback; - } - +function routeValidateCommand({ verify, args, cwd, raw, output: outputFn, error }) { routeCjsCommandFamily({ args, subcommands: VALIDATE_SUBCOMMANDS, @@ -32,12 +29,7 @@ function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: error, unknownMessage: (_subcommand, available) => `Unknown validate subcommand. Available: ${available.join(', ')}`, handlers: { - consistency: sdkHandler( - 'validate.consistency', - args.slice(2), - args.slice(1), - () => verify.cmdValidateConsistency(cwd, raw), - ), + consistency: () => verify.cmdValidateConsistency(cwd, raw), // Keep health on CJS for now so fix hints are rendered via runtime-slash // helpers (codex expects $gsd-* command shape). health: () => { @@ -45,12 +37,7 @@ function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: const backfillFlag = args.includes('--backfill'); verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw); }, - agents: sdkHandler( - 'validate.agents', - args.slice(2), - args.slice(1), - () => verify.cmdValidateAgents(cwd, raw), - ), + agents: () => verify.cmdValidateAgents(cwd, raw), // context: CJS-only — complex inline logic using classifyContextUtilization // with custom output formatting that has no direct SDK counterpart. context: () => { diff --git a/get-shit-done/bin/lib/verify-command-router.cjs b/get-shit-done/bin/lib/verify-command-router.cjs index adc398760..b9cb47515 100644 --- a/get-shit-done/bin/lib/verify-command-router.cjs +++ b/get-shit-done/bin/lib/verify-command-router.cjs @@ -16,10 +16,6 @@ const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); * SDK-only (unsupported in CJS router): none. */ function routeVerifyCommand({ verify, args, cwd, raw, error }) { - function sdkHandler(_registryCommand, _registryArgs, _legacyArgs, cjsFallback) { - return cjsFallback; - } - routeCjsCommandFamily({ args, subcommands: VERIFY_SUBCOMMANDS, @@ -27,53 +23,18 @@ function routeVerifyCommand({ verify, args, cwd, raw, error }) { error, unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`, handlers: { - 'plan-structure': sdkHandler( - 'verify.plan-structure', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyPlanStructure(cwd, args[2], raw), - ), - 'phase-completeness': sdkHandler( - 'verify.phase-completeness', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw), - ), - references: sdkHandler( - 'verify.references', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyReferences(cwd, args[2], raw), - ), - commits: sdkHandler( - 'verify.commits', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyCommits(cwd, args.slice(2), raw), - ), - artifacts: sdkHandler( - 'verify.artifacts', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyArtifacts(cwd, args[2], raw), - ), - 'key-links': sdkHandler( - 'verify.key-links', - args.slice(2), - args.slice(1), - () => verify.cmdVerifyKeyLinks(cwd, args[2], raw), - ), - 'schema-drift': sdkHandler( - 'verify.schema-drift', - args.slice(2), - args.slice(1), - () => { - const rest = args.slice(2); - const skipFlag = rest.includes('--skip'); - const phaseArg = rest.find((arg) => !arg.startsWith('-')); - verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw); - }, - ), + 'plan-structure': () => verify.cmdVerifyPlanStructure(cwd, args[2], raw), + 'phase-completeness': () => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw), + references: () => verify.cmdVerifyReferences(cwd, args[2], raw), + commits: () => verify.cmdVerifyCommits(cwd, args.slice(2), raw), + artifacts: () => verify.cmdVerifyArtifacts(cwd, args[2], raw), + 'key-links': () => verify.cmdVerifyKeyLinks(cwd, args[2], raw), + 'schema-drift': () => { + const rest = args.slice(2); + const skipFlag = rest.includes('--skip'); + const phaseArg = rest.find((arg) => !arg.startsWith('-')); + verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw); + }, // verify codebase-drift dispatches direct to CJS — drift is out-of-seam // per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through // sdkHandler would re-enter the SDK bridge, and Phase 6's removed diff --git a/package.json b/package.json index 71642fe36..c333852b0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "scripts": { "check:env": "bash scripts/check-env.sh", + "check:alias-drift": "node scripts/check-alias-drift.cjs", "check:integrity": "./scripts/check-npm-integrity.sh", "build": "npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", diff --git a/scripts/check-alias-drift.cjs b/scripts/check-alias-drift.cjs new file mode 100644 index 000000000..68d54bfc5 --- /dev/null +++ b/scripts/check-alias-drift.cjs @@ -0,0 +1,108 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const aliasesPath = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.cjs'); + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function ensureArray(value, name) { + if (!Array.isArray(value)) { + fail(`check:alias-drift: expected ${name} to be an array`); + } +} + +function assertNoDuplicates(values, label) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + fail(`check:alias-drift: duplicate ${label} value "${value}"`); + } + seen.add(value); + } +} + +if (!fs.existsSync(aliasesPath)) { + fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`); +} + +const aliases = require(aliasesPath); + +const families = [ + { + commandAliases: 'STATE_COMMAND_ALIASES', + subcommands: 'STATE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'state-command-router.cjs'), + }, + { + commandAliases: 'VERIFY_COMMAND_ALIASES', + subcommands: 'VERIFY_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'verify-command-router.cjs'), + }, + { + commandAliases: 'INIT_COMMAND_ALIASES', + subcommands: 'INIT_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'init-command-router.cjs'), + }, + { + commandAliases: 'PHASE_COMMAND_ALIASES', + subcommands: 'PHASE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'phase-command-router.cjs'), + }, + { + commandAliases: 'PHASES_COMMAND_ALIASES', + subcommands: 'PHASES_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'phases-command-router.cjs'), + }, + { + commandAliases: 'VALIDATE_COMMAND_ALIASES', + subcommands: 'VALIDATE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'validate-command-router.cjs'), + }, + { + commandAliases: 'ROADMAP_COMMAND_ALIASES', + subcommands: 'ROADMAP_SUBCOMMANDS', + routerPath: path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'roadmap-command-router.cjs'), + }, +]; + +for (const family of families) { + const commandAliases = aliases[family.commandAliases]; + const subcommands = aliases[family.subcommands]; + + ensureArray(commandAliases, family.commandAliases); + ensureArray(subcommands, family.subcommands); + + const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand); + assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`); + + if (derivedSubcommands.length !== subcommands.length) { + fail( + `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` + + `${family.commandAliases} length ${derivedSubcommands.length}`, + ); + } + + for (let i = 0; i < derivedSubcommands.length; i++) { + if (derivedSubcommands[i] !== subcommands[i]) { + fail( + `check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` + + `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`, + ); + } + } + + const routerSource = fs.readFileSync(family.routerPath, 'utf8'); + if (!routerSource.includes(family.subcommands)) { + fail( + `check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`, + ); + } +} + +process.stdout.write('check:alias-drift ok\n'); diff --git a/tests/cjs-command-router-adapter.test.cjs b/tests/cjs-command-router-adapter.test.cjs new file mode 100644 index 000000000..de143956f --- /dev/null +++ b/tests/cjs-command-router-adapter.test.cjs @@ -0,0 +1,191 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { routeCjsCommandFamily, routeHubCommandFamily } = require('../get-shit-done/bin/lib/cjs-command-router-adapter.cjs'); +const { makeInvalidArgs } = require('../get-shit-done/bin/lib/command-routing-hub.cjs'); + +describe('cjs-command-router-adapter routeHubCommandFamily', () => { + test('routes known subcommand handler through the hub', () => { + let calls = 0; + let errorMessage = null; + + routeHubCommandFamily({ + family: 'unit', + args: ['unit', 'ok'], + subcommands: ['ok'], + handlers: { + ok: () => { + calls += 1; + }, + }, + unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`, + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(calls, 1); + assert.equal(errorMessage, null); + }); + + test('maps unknown subcommands via unknownMessage and filtered availability', () => { + let errorMessage = null; + + routeHubCommandFamily({ + family: 'unit', + args: ['unit', 'missing'], + subcommands: ['ok', 'legacy'], + unsupported: { legacy: 'legacy disabled' }, + handlers: { ok: () => {} }, + unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`, + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(errorMessage, 'Unknown missing. Available: ok'); + }); + + test('returns unsupported subcommand error before dispatch', () => { + let errorMessage = null; + + routeHubCommandFamily({ + family: 'unit', + args: ['unit', 'legacy'], + subcommands: ['ok', 'legacy'], + unsupported: { legacy: 'legacy disabled' }, + handlers: { ok: () => {} }, + unknownMessage: () => 'should not be used', + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(errorMessage, 'legacy disabled'); + }); + + test('projects InvalidArgs result reason via error callback', () => { + let errorMessage = null; + + routeHubCommandFamily({ + family: 'unit', + args: ['unit', 'invalid'], + subcommands: ['invalid'], + handlers: { + invalid: () => makeInvalidArgs('--phase', '--phase must be an integer'), + }, + unknownMessage: () => 'should not be used', + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(errorMessage, '--phase must be an integer'); + }); + + test('projects thrown handler exceptions as HandlerFailure message', () => { + let errorMessage = null; + + routeHubCommandFamily({ + family: 'unit', + args: ['unit', 'boom'], + subcommands: ['boom'], + handlers: { + boom: () => { + throw new Error('boom'); + }, + }, + unknownMessage: () => 'should not be used', + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(errorMessage, 'boom'); + }); +}); + +describe('cjs-command-router-adapter routeCjsCommandFamily', () => { + test('routes known subcommand handler via the legacy adapter', () => { + let calls = 0; + let errorMessage = null; + + routeCjsCommandFamily({ + args: ['unit', 'ok'], + subcommands: ['ok'], + handlers: { + ok: () => { + calls += 1; + }, + }, + unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`, + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(calls, 1); + assert.equal(errorMessage, null); + }); + + test('honors defaultSubcommand when args[1] is absent', () => { + let calls = 0; + let errorMessage = null; + + routeCjsCommandFamily({ + args: ['unit'], + subcommands: ['load'], + defaultSubcommand: 'load', + handlers: { + load: () => { + calls += 1; + }, + }, + unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`, + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(calls, 1); + assert.equal(errorMessage, null); + }); + + test('converts thrown handler exceptions into error callback messages', () => { + let errorMessage = null; + + routeCjsCommandFamily({ + args: ['unit', 'boom'], + subcommands: ['boom'], + handlers: { + boom: () => { + throw new Error('boom'); + }, + }, + unknownMessage: () => 'should not be used', + error: (message) => { + errorMessage = message; + }, + cwd: '/tmp/proj', + raw: false, + }); + + assert.equal(errorMessage, 'boom'); + }); +});