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.
This commit is contained in:
Tom Boucher
2026-05-03 13:57:32 -04:00
committed by GitHub
parent 0f98952a3d
commit 5975f06b6a
42 changed files with 1773 additions and 948 deletions

View File

@@ -0,0 +1,112 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { extractField } from './registry.js';
import type { QueryDispatchResult } from './query-dispatch-contract.js';
interface CjsFallbackQueryResult {
mode: 'json' | 'text';
output: unknown;
stderr: string;
}
export interface RunCjsFallbackDispatchInput {
projectDir: string;
gsdToolsPath: string;
normCmd: string;
normArgs: string[];
ws?: string;
pickField?: string;
}
function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] {
if (normCmd.includes('.')) return [...normCmd.split('.'), ...normArgs];
return [normCmd, ...normArgs];
}
function execGsdToolsCjsQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<{ stdout: string; stderr: string }> {
const cjsArgv = dottedCommandToCjsArgv(normCmd, normArgs);
const wsSuffix = ws ? ['--ws', ws] : [];
const fullArgv = [gsdToolsPath, ...cjsArgv, ...wsSuffix];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgv,
{ cwd: projectDir, maxBuffer: 10 * 1024 * 1024, timeout: 30_000, killSignal: 'SIGKILL', env: { ...process.env } },
(err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
async function parseCliQueryJsonOutput(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
async function runCjsFallbackQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<CjsFallbackQueryResult> {
const { stdout, stderr } = await execGsdToolsCjsQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws);
try {
const output = await parseCliQueryJsonOutput(stdout, projectDir);
return { mode: 'json', output, stderr };
} catch {
return { mode: 'text', output: stdout, stderr };
}
}
function formatFallbackOutput(data: unknown, mode: 'json' | 'text', pickField?: string): string | undefined {
if (mode === 'text') {
const text = String(data ?? '');
if (!text.trim()) return undefined;
return text.endsWith('\n') ? text : `${text}\n`;
}
let output: unknown = data;
if (pickField) output = extractField(output, pickField);
return `${JSON.stringify(output, null, 2)}\n`;
}
export async function runCjsFallbackDispatch(input: RunCjsFallbackDispatchInput): Promise<QueryDispatchResult> {
const { projectDir, gsdToolsPath, normCmd, normArgs, ws, pickField } = input;
const stderr = [
`[gsd-sdk] '${normCmd}' not in native registry; falling back to gsd-tools.cjs.`,
'[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.',
];
try {
const fallback = await runCjsFallbackQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws);
if (fallback.stderr.trim()) stderr.push(fallback.stderr.trimEnd());
return {
stderr,
stdout: formatFallbackOutput(fallback.output, fallback.mode, pickField),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
stderr,
error: { code: 1, message: `Error: gsd-tools.cjs fallback failed: ${msg}` },
};
}
}