Files
msd-core/sdk/src/query/query-command-semantics.ts
Tom Boucher 5975f06b6a refactor(query): extract command catalog seam for registry wiring (#3060)
* refactor(sdk): extract gsdtools transport seam with per-command policy

* refactor(query): centralize registry command catalog wiring

* refactor(query): unify command resolution seam across sdk callers

* fix(sdk): address CodeRabbit transport policy and timeout findings

* refactor(query): extract mutation event mapper seam

* refactor(query): converge mutation and transport policy data

* refactor(query): share fallback orchestration across cli and sdk

* refactor(query): split static registry catalog by domain clusters

* refactor(query): extract mutation event emission decorator seam

* refactor(query): extract alias-family handler catalog module

* refactor(query): extract cjs fallback execution adapter

* refactor(query): deepen command semantics seam

* refactor(query): extract deep dispatch seam

* refactor(query): deepen cjs fallback execution seam

* refactor(query): merge routing plan into dispatch seam

* fix(query): address CodeRabbit review findings on PR #3060

Critical: prevent double-execution race by checking timeout errors
before subprocess fallback (gsd-transport.ts).

Major: fix execRaw() to respect transport policy outputMode instead
of hardcoding 'raw' (gsd-tools.ts).

Major: add explicit 30s timeout to subprocess fallback execution
(query-fallback-executor.ts).

Major: remove raw args from stderr banner to prevent secret leakage
(query-fallback-executor.ts).

Minor: ensure native text output has trailing newline for CLI parity
(query-dispatch.ts).

Update gsd-tools.test.ts to match new execRaw() behavior.

* fix(tests): update CLI integration tests for catalog-based registration

The refactoring moved handler registration from inline registry.register()
calls to catalog-based registration (registerStaticCatalog/registerAliasCatalog).

- gsd-sdk-query-registry-integration.test.cjs: collectRegisteredNames() now
  also scans catalog files for handler names registered via the new system.
- bug-2492-context-coverage-gate.test.cjs: checks for catalog-based
  registration (DECISION_ROUTING_STATIC_CATALOG) instead of inline strings.
- bug-2524-sdk-query-ws-flag.test.cjs: checks for dispatchNative callback
  pattern instead of direct registry.dispatch() call.

* fix(query): address remaining CodeRabbit review findings

- query-command-semantics.ts: guard stats/progress rewrite so option
  tokens (e.g. --pick) are not turned into subcommands, preserving the
  top-level handler dispatch.

- query-dispatch.ts: formatOutput now skips --pick for text-format
  responses (matching CJS fallback behavior) and surfaces a proper error
  when extractField returns undefined instead of silently producing
  'undefined'.

- query-dispatch.ts: fix backwards error message — 'registered' is the
  restrictive policy that disables fallback, not enables it.

- tests/bug-2492-context-coverage-gate.test.cjs: check
  VERIFY_DECISION_STATIC_CATALOG (the correct catalog for plan-gate
  handlers) instead of DECISION_ROUTING_STATIC_CATALOG.

- tests/gsd-sdk-query-registry-integration.test.cjs: resolve catalog
  variable before loading entries so the drift guard checks each
  referenced catalog individually.

* refactor(query): deepen registry assembly module with strict invariants

- extract registry assembly into dedicated module
- split build vs mutation decoration internals
- add strict assembly invariants:
  1) no duplicate keys
  2) alias canonicals must have handlers
  3) mutation commands must be registered
  4) raw-output policy commands must be registered
- slim query index to thin re-export seam
- add focused registry assembly tests
- update drift-guard tests to target new seam

* test(query): add thin-seam coverage for query index re-exports

* fix(query): return structured native dispatch errors + tighten decisions.parse guard

- runQueryDispatch native path now catches adapter errors and returns
  QueryDispatchResult.error instead of throwing.
- preserve legacy CLI exit contract by using code=1 for native dispatch
  failures.
- strengthen bug-2492 guard: decisions.parse assertion now checks
  VERIFY_DECISION_STATIC_CATALOG OR explicit command token.
2026-05-03 13:57:32 -04:00

215 lines
6.9 KiB
TypeScript

import {
STATE_SUBCOMMANDS,
VERIFY_SUBCOMMANDS,
INIT_SUBCOMMANDS,
PHASE_SUBCOMMANDS,
PHASES_SUBCOMMANDS,
VALIDATE_SUBCOMMANDS,
ROADMAP_SUBCOMMANDS,
STATE_MUTATION_COMMANDS,
PHASE_MUTATION_COMMANDS,
PHASES_MUTATION_COMMANDS,
ROADMAP_MUTATION_COMMANDS,
} from './command-aliases.generated.js';
export interface QueryCommandRegistryLike {
has(command: string): boolean;
}
export type QueryMatchMode = 'dotted' | 'spaced';
export type QueryResolutionSource = 'normalized' | 'expanded';
export interface QueryCommandResolution {
cmd: string;
args: string[];
matchedBy: QueryMatchMode;
expanded: boolean;
source: QueryResolutionSource;
}
export interface QueryCommandNoMatch {
normalized: { command: string; args: string[]; tokens: string[] };
attempted: { dotted: string[]; spaced: string[]; expandedTokens: string[] | null };
}
const MERGE_FIRST_WITH_SUBCOMMAND = new Set<string>([
'state',
'template',
'frontmatter',
'verify',
'phase',
'requirements',
'init',
'workstream',
'intel',
'learnings',
'uat',
'todo',
'milestone',
'check',
'detect',
'route',
]);
export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [
...STATE_MUTATION_COMMANDS,
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
'commit', 'check-commit', 'commit-to-subrepo',
'template.fill', 'template.select', 'template select',
...PHASE_MUTATION_COMMANDS,
...PHASES_MUTATION_COMMANDS,
...ROADMAP_MUTATION_COMMANDS,
'requirements.mark-complete', 'requirements mark-complete',
'todo.complete', 'todo complete',
'milestone.complete', 'milestone complete',
'workstream.create', 'workstream.set', 'workstream.complete', 'workstream.progress',
'workstream create', 'workstream set', 'workstream complete', 'workstream progress',
'docs-init',
'learnings.copy', 'learnings copy',
'learnings.prune', 'learnings prune',
'learnings.delete', 'learnings delete',
'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta',
'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md',
] as const;
export const TRANSPORT_RAW_COMMANDS: readonly string[] = [
'commit',
'config-set',
'verify-summary',
'verify.summary',
'verify summary',
] as const;
const QUERY_MUTATION_COMMAND_SET = new Set(QUERY_MUTATION_COMMAND_LIST);
export function isQueryMutationCommand(command: string): boolean {
return QUERY_MUTATION_COMMAND_SET.has(command);
}
export function normalizeQueryCommand(command: string, args: string[]): [string, string[]] {
if (command === 'scaffold') return ['phase.scaffold', args];
if (command === 'state' && args.length === 0) return ['state.load', []];
if (command === 'state' && args.length > 0) {
const sub = args[0];
if (STATE_SUBCOMMANDS.has(sub)) return [`state.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'verify' && args.length > 0) {
const sub = args[0];
if (VERIFY_SUBCOMMANDS.has(sub)) return [`verify.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'init' && args.length > 0) {
const sub = args[0];
if (INIT_SUBCOMMANDS.has(sub)) return [`init.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'phase' && args.length > 0) {
const sub = args[0];
if (PHASE_SUBCOMMANDS.has(sub)) return [`phase.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'phases' && args.length > 0) {
const sub = args[0];
if (PHASES_SUBCOMMANDS.has(sub)) return [`phases.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'validate' && args.length > 0) {
const sub = args[0];
if (VALIDATE_SUBCOMMANDS.has(sub)) return [`validate.${sub}`, args.slice(1)];
return [command, args];
}
if (command === 'roadmap' && args.length > 0) {
const sub = args[0];
if (ROADMAP_SUBCOMMANDS.has(sub)) return [`roadmap.${sub}`, args.slice(1)];
return [command, args];
}
if (MERGE_FIRST_WITH_SUBCOMMAND.has(command) && args.length > 0) {
return [`${command}.${args[0]}`, args.slice(1)];
}
if ((command === 'progress' || command === 'stats') && args.length > 0 && !args[0].startsWith('-')) {
return [`${command}.${args[0]}`, args.slice(1)];
}
return [command, args];
}
function expandFirstDottedToken(tokens: string[]): string[] {
if (tokens.length === 0) return tokens;
const first = tokens[0];
if (first.startsWith('--') || !first.includes('.')) return tokens;
return [...first.split('.'), ...tokens.slice(1)];
}
function matchRegisteredPrefix(
tokens: string[],
registry: QueryCommandRegistryLike,
track?: { dotted: string[]; spaced: string[] },
): { cmd: string; args: string[]; matchedBy: QueryMatchMode } | null {
for (let i = tokens.length; i >= 1; i--) {
const head = tokens.slice(0, i);
const dotted = head.join('.');
const spaced = head.join(' ');
track?.dotted.push(dotted);
track?.spaced.push(spaced);
if (registry.has(dotted)) return { cmd: dotted, args: tokens.slice(i), matchedBy: 'dotted' };
if (registry.has(spaced)) return { cmd: spaced, args: tokens.slice(i), matchedBy: 'spaced' };
}
return null;
}
export function resolveQueryTokens(
tokens: string[],
registry: QueryCommandRegistryLike,
): QueryCommandResolution | null {
const direct = matchRegisteredPrefix(tokens, registry);
if (direct) return { ...direct, expanded: false, source: 'normalized' };
const expanded = expandFirstDottedToken(tokens);
if (expanded !== tokens) {
const afterExpand = matchRegisteredPrefix(expanded, registry);
if (afterExpand) return { ...afterExpand, expanded: true, source: 'expanded' };
}
return null;
}
export function resolveQueryCommand(
command: string,
args: string[],
registry: QueryCommandRegistryLike,
): QueryCommandResolution | null {
const [normCmd, normArgs] = normalizeQueryCommand(command, args);
return resolveQueryTokens([normCmd, ...normArgs], registry);
}
export function explainQueryCommandNoMatch(
command: string,
args: string[],
registry: QueryCommandRegistryLike,
): QueryCommandNoMatch {
const [normalizedCommand, normalizedArgs] = normalizeQueryCommand(command, args);
const normalizedTokens = [normalizedCommand, ...normalizedArgs];
const attempted = { dotted: [] as string[], spaced: [] as string[] };
matchRegisteredPrefix(normalizedTokens, registry, attempted);
const expandedTokens = expandFirstDottedToken(normalizedTokens);
if (expandedTokens !== normalizedTokens) {
matchRegisteredPrefix(expandedTokens, registry, attempted);
}
return {
normalized: {
command: normalizedCommand,
args: normalizedArgs,
tokens: normalizedTokens,
},
attempted: {
dotted: attempted.dotted,
spaced: attempted.spaced,
expandedTokens: expandedTokens !== normalizedTokens ? expandedTokens : null,
},
};
}