refactor(#596): drop dead projection metadata in state-command-router (#633)

cjsFallbackHandler was a no-op pass-through returning only its last
argument, and state-command-router was its sole consumer — every handler
wrapped a thunk in a 5-arg projection tuple whose first four args
(registryCommand, registryArgs, legacyArgs, rawFormatter) were
constructed and immediately discarded (rawFormatter null at 100% of
sites). The SDK-dispatch path the metadata was nominally for is a retired
shim.

- Collapse all 20 state handlers to plain () => state.cmd*(...) thunks,
  matching the post-#298 inline convention used by the verify/roadmap/
  phases routers (complete-phase already used this form).
- Remove the local fallback() helper and Handler type from
  state-command-router.cts.
- Drop cjsFallbackHandler from cjs-command-router-adapter.cts exports —
  no remaining consumers.

Behavior-preserving: each thunk body is unchanged. No test referenced
cjsFallbackHandler. Adapter test (8/8), state-routing tests (36/36), and
the full unit suite (3435 pass / 0 fail) stay green.

Source-only diff; gsd-core/bin/lib/*.cjs are built-at-publish per ADR-457.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Joe
2026-06-02 22:32:10 -05:00
committed by GitHub
parent 14e9aea47f
commit 3cf112b3de
2 changed files with 94 additions and 234 deletions

View File

@@ -135,19 +135,7 @@ function routeHubCommandFamily({
error((result as { message: string }).message);
}
/**
* 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: unknown[]): unknown {
return projectionArgs[projectionArgs.length - 1];
}
export = {
routeCjsCommandFamily,
routeHubCommandFamily,
cjsFallbackHandler,
};

View File

@@ -17,19 +17,11 @@
import { STATE_SUBCOMMANDS } from './command-aliases.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
const { routeHubCommandFamily, cjsFallbackHandler } = cjsCommandRouterAdapter;
const { routeHubCommandFamily } = cjsCommandRouterAdapter;
import { parseNamedArgs } from './command-arg-projection.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
// Handler type matching cjs-command-router-adapter's internal Handler type.
type Handler = () => unknown;
// Helper: cast cjsFallbackHandler result (always the last arg, a Handler) to Handler.
function fallback(...projectionArgs: unknown[]): Handler {
return cjsFallbackHandler(...projectionArgs) as Handler;
}
// Helper: extract string-only named arg value (value flags never return boolean).
function strArg(opts: Record<string, string | boolean | null>, key: string): string | null | undefined {
const v = opts[key];
@@ -94,226 +86,106 @@ function routeStateCommand({ state, args, cwd, raw, error }: RouteStateCommandOp
raw,
unknownMessage: (subcommand: string, available: string[]) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`,
handlers: {
load: fallback(
'state.load',
[],
args.slice(1),
null,
() => state.cmdStateLoad(cwd, raw),
),
json: fallback(
'state.json',
[],
args.slice(1),
null,
() => state.cmdStateJson(cwd, raw),
),
get: fallback(
'state.get',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateGet(cwd, args[2], raw),
),
update: fallback(
'state.update',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateUpdate(cwd, args[2], args[3]),
),
patch: fallback(
'state.patch',
args.slice(2),
args.slice(1),
null,
() => {
const patches: Record<string, string> = {};
if (args.length === 3 && typeof args[2] === 'string' && args[2].trim().startsWith('{')) {
let parsed: unknown;
try {
parsed = JSON.parse(args[2]);
} catch (err) {
error(`state patch: invalid JSON object: ${(err as Error).message}`);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
error('state patch: JSON input must be an object of field/value pairs.');
return;
}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (key && value !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
patches[key] = String(value);
}
}
} else {
for (let i = 2; i < args.length; i += 2) {
const key = args[i].replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) {
patches[key] = value;
}
load: () => state.cmdStateLoad(cwd, raw),
json: () => state.cmdStateJson(cwd, raw),
get: () => state.cmdStateGet(cwd, args[2], raw),
update: () => state.cmdStateUpdate(cwd, args[2], args[3]),
patch: () => {
const patches: Record<string, string> = {};
if (args.length === 3 && typeof args[2] === 'string' && args[2].trim().startsWith('{')) {
let parsed: unknown;
try {
parsed = JSON.parse(args[2]);
} catch (err) {
error(`state patch: invalid JSON object: ${(err as Error).message}`);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
error('state patch: JSON input must be an object of field/value pairs.');
return;
}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (key && value !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
patches[key] = String(value);
}
}
state.cmdStatePatch(cwd, patches, raw);
},
),
'advance-plan': fallback(
'state.advance-plan',
[],
args.slice(1),
null,
() => state.cmdStateAdvancePlan(cwd, raw),
),
'record-metric': fallback(
'state.record-metric',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
state.cmdStateRecordMetric(cwd, {
phase: strArg(a, 'phase'),
plan: strArg(a, 'plan'),
duration: strArg(a, 'duration'),
tasks: strArg(a, 'tasks'),
files: strArg(a, 'files'),
}, raw);
},
),
'update-progress': fallback(
'state.update-progress',
[],
args.slice(1),
null,
() => state.cmdStateUpdateProgress(cwd, raw),
),
'add-decision': fallback(
'state.add-decision',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
state.cmdStateAddDecision(cwd, {
phase: strArg(a, 'phase'),
summary: strArg(a, 'summary'),
summary_file: strArg(a, 'summary-file'),
rationale: strArg(a, 'rationale') || '',
rationale_file: strArg(a, 'rationale-file'),
}, raw);
},
),
'add-blocker': fallback(
'state.add-blocker',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['text', 'text-file']);
state.cmdStateAddBlocker(cwd, { text: strArg(a, 'text'), text_file: strArg(a, 'text-file') }, raw);
},
),
'resolve-blocker': fallback(
'state.resolve-blocker',
args.slice(2),
args.slice(1),
null,
() => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgs(args, ['text']), 'text'), raw),
),
'record-session': fallback(
'state.record-session',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['stopped-at', 'resume-file']);
// Pass resume_file as-is (undefined when --resume-file was not provided) so
// cmdStateRecordSession can distinguish "caller explicitly passed a value" from
// "option was not supplied" and apply the template-default-only replacement guard.
state.cmdStateRecordSession(cwd, { stopped_at: strArg(a, 'stopped-at'), resume_file: strArg(a, 'resume-file') }, raw);
},
),
'begin-phase': fallback(
'state.begin-phase',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStateBeginPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
},
),
'signal-waiting': fallback(
'state.signal-waiting',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
state.cmdSignalWaiting(cwd, strArg(a, 'type'), strArg(a, 'question'), strArg(a, 'options'), strArg(a, 'phase'), raw);
},
),
'signal-resume': fallback(
'state.signal-resume',
[],
args.slice(1),
null,
() => state.cmdSignalResume(cwd, raw),
),
'planned-phase': fallback(
'state.planned-phase',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), parsePlans(strArg(a, 'plans')), raw);
},
),
validate: fallback(
'state.validate',
[],
args.slice(1),
null,
() => state.cmdStateValidate(cwd, raw),
),
sync: fallback(
'state.sync',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, [], ['verify']);
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
},
),
prune: fallback(
'state.prune',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
),
} else {
for (let i = 2; i < args.length; i += 2) {
const key = args[i].replace(/^--/, '');
const value = args[i + 1];
if (key && value !== undefined) {
patches[key] = value;
}
}
}
state.cmdStatePatch(cwd, patches, raw);
},
'advance-plan': () => state.cmdStateAdvancePlan(cwd, raw),
'record-metric': () => {
const a = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
state.cmdStateRecordMetric(cwd, {
phase: strArg(a, 'phase'),
plan: strArg(a, 'plan'),
duration: strArg(a, 'duration'),
tasks: strArg(a, 'tasks'),
files: strArg(a, 'files'),
}, raw);
},
'update-progress': () => state.cmdStateUpdateProgress(cwd, raw),
'add-decision': () => {
const a = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
state.cmdStateAddDecision(cwd, {
phase: strArg(a, 'phase'),
summary: strArg(a, 'summary'),
summary_file: strArg(a, 'summary-file'),
rationale: strArg(a, 'rationale') || '',
rationale_file: strArg(a, 'rationale-file'),
}, raw);
},
'add-blocker': () => {
const a = parseNamedArgs(args, ['text', 'text-file']);
state.cmdStateAddBlocker(cwd, { text: strArg(a, 'text'), text_file: strArg(a, 'text-file') }, raw);
},
'resolve-blocker': () => state.cmdStateResolveBlocker(cwd, strArg(parseNamedArgs(args, ['text']), 'text'), raw),
'record-session': () => {
const a = parseNamedArgs(args, ['stopped-at', 'resume-file']);
// Pass resume_file as-is (undefined when --resume-file was not provided) so
// cmdStateRecordSession can distinguish "caller explicitly passed a value" from
// "option was not supplied" and apply the template-default-only replacement guard.
state.cmdStateRecordSession(cwd, { stopped_at: strArg(a, 'stopped-at'), resume_file: strArg(a, 'resume-file') }, raw);
},
'begin-phase': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStateBeginPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
},
'signal-waiting': () => {
const a = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
state.cmdSignalWaiting(cwd, strArg(a, 'type'), strArg(a, 'question'), strArg(a, 'options'), strArg(a, 'phase'), raw);
},
'signal-resume': () => state.cmdSignalResume(cwd, raw),
'planned-phase': () => {
const a = parseNamedArgs(args, ['phase', 'name', 'plans']);
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), parsePlans(strArg(a, 'plans')), raw);
},
validate: () => state.cmdStateValidate(cwd, raw),
sync: () => {
const a = parseNamedArgs(args, [], ['verify']);
state.cmdStateSync(cwd, { verify: a['verify'] }, raw);
},
prune: () => {
const a = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
},
// complete-phase: CJS-only — no SDK counterpart.
'complete-phase': () => {
const a = parseNamedArgs(args, ['phase']);
state.cmdStateCompletePhase(cwd, raw, strArg(a, 'phase') || args[2]);
},
'milestone-switch': fallback(
'state.milestone-switch',
args.slice(2),
args.slice(1),
null,
() => {
const a = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, strArg(a, 'milestone'), strArg(a, 'name'), raw);
},
),
'milestone-switch': () => {
const a = parseNamedArgs(args, ['milestone', 'name']);
state.cmdStateMilestoneSwitch(cwd, strArg(a, 'milestone'), strArg(a, 'name'), raw);
},
},
});
}