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:
152
sdk/src/cli.ts
152
sdk/src/cli.ts
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, join, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -284,49 +283,6 @@ function queryFallbackToCjsEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
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('./query/helpers.js');
|
||||
const filePath = await resolvePathUnderProject(projectDir, rel);
|
||||
jsonStr = await readFile(filePath, 'utf-8');
|
||||
}
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
|
||||
/** Map registry-style dotted command tokens to gsd-tools.cjs argv (space-separated subcommands). */
|
||||
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, env: { ...process.env } },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -389,100 +345,28 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
// ─── Query command ──────────────────────────────────────────────────────
|
||||
if (args.command === 'query') {
|
||||
const { createRegistry } = await import('./query/index.js');
|
||||
const { extractField, resolveQueryArgv } = await import('./query/registry.js');
|
||||
const { GSDToolsError } = await import('./gsd-tools.js');
|
||||
const { GSDError, exitCodeFor, ErrorClassification } = await import('./errors.js');
|
||||
|
||||
const queryArgs = args.queryArgv ?? [];
|
||||
|
||||
// Extract --pick before dispatch
|
||||
const pickIdx = queryArgs.indexOf('--pick');
|
||||
let pickField: string | undefined;
|
||||
if (pickIdx !== -1) {
|
||||
if (pickIdx + 1 >= queryArgs.length) {
|
||||
console.error('Error: --pick requires a field name');
|
||||
process.exitCode = 10;
|
||||
return;
|
||||
}
|
||||
pickField = queryArgs[pickIdx + 1];
|
||||
queryArgs.splice(pickIdx, 2);
|
||||
}
|
||||
|
||||
if (queryArgs.length === 0 || !queryArgs[0]) {
|
||||
console.error('Error: "gsd-sdk query" requires a command');
|
||||
process.exitCode = 10;
|
||||
return;
|
||||
}
|
||||
const { runQueryDispatch } = await import('./query/query-dispatch.js');
|
||||
const { resolveGsdToolsPath, GSDToolsError } = await import('./gsd-tools.js');
|
||||
const { GSDError, exitCodeFor } = await import('./errors.js');
|
||||
|
||||
try {
|
||||
const queryCommand = queryArgs[0];
|
||||
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgs.slice(1));
|
||||
if (!normCmd || !String(normCmd).trim()) {
|
||||
console.error('Error: "gsd-sdk query" requires a command');
|
||||
process.exitCode = 10;
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: args.projectDir,
|
||||
ws: args.ws,
|
||||
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
|
||||
resolveGsdToolsPath,
|
||||
dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, args.projectDir, args.ws),
|
||||
}, args.queryArgv ?? []);
|
||||
|
||||
for (const line of out.stderr) console.error(line);
|
||||
if (out.error) {
|
||||
console.error(out.error.message);
|
||||
process.exitCode = out.error.code;
|
||||
return;
|
||||
}
|
||||
const registry = createRegistry();
|
||||
const tokens = [normCmd, ...normArgs];
|
||||
const matched = resolveQueryArgv(tokens, registry);
|
||||
if (!matched) {
|
||||
if (!queryFallbackToCjsEnabled()) {
|
||||
throw new GSDError(
|
||||
`Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations. Set GSD_QUERY_FALLBACK=registered (default) to allow automatic fallback.`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
const { resolveGsdToolsPath } = await import('./gsd-tools.js');
|
||||
const gsdPath = resolveGsdToolsPath(args.projectDir);
|
||||
console.error(
|
||||
`[gsd-sdk] '${tokens.join(' ')}' not in native registry; falling back to gsd-tools.cjs.`,
|
||||
);
|
||||
console.error('[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.');
|
||||
const { stdout, stderr } = await execGsdToolsCjsQuery(
|
||||
args.projectDir,
|
||||
gsdPath,
|
||||
normCmd,
|
||||
normArgs,
|
||||
args.ws,
|
||||
);
|
||||
if (stderr.trim()) console.error(stderr.trimEnd());
|
||||
// #3026 CR (Major outside-diff): the gsd-tools.cjs fallback now
|
||||
// emits plain-text usage on --help / -h with exit 0, instead of
|
||||
// a JSON object. Wrap the JSON parse in a try/catch and forward
|
||||
// non-JSON stdout verbatim so subcommand help reaches the user.
|
||||
// (Previously this path JSON.parsed the help text and threw
|
||||
// "Unexpected token 'U'" — exitCode=1 — a regression introduced
|
||||
// alongside the --help passthrough fix.)
|
||||
let output: unknown;
|
||||
try {
|
||||
output = await parseCliQueryJsonOutput(stdout, args.projectDir);
|
||||
} catch {
|
||||
if (stdout.trim()) {
|
||||
process.stdout.write(stdout.endsWith('\n') ? stdout : stdout + '\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pickField) {
|
||||
output = extractField(output, pickField);
|
||||
}
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
} else {
|
||||
const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws);
|
||||
let output: unknown = result.data;
|
||||
|
||||
if (pickField) {
|
||||
output = extractField(output, pickField);
|
||||
}
|
||||
|
||||
// Handlers can signal format:'text' to emit a raw string (e.g. agent-skills
|
||||
// emits an <agent_skills> XML block workflows embed via $(...) substitution).
|
||||
if (!pickField && result.format === 'text' && typeof output === 'string') {
|
||||
process.stdout.write(output);
|
||||
} else {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
}
|
||||
if (out.stdout) process.stdout.write(out.stdout);
|
||||
} catch (err) {
|
||||
if (err instanceof GSDError) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
|
||||
Reference in New Issue
Block a user