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:
125
sdk/src/query/query-dispatch.ts
Normal file
125
sdk/src/query/query-dispatch.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import { extractField } from './registry.js';
|
||||
import { normalizeQueryCommand } from './normalize-query-command.js';
|
||||
import { explainQueryCommandNoMatch, resolveQueryCommand, type QueryCommandResolution } from './command-resolution.js';
|
||||
import { runCjsFallbackDispatch } from './query-fallback-executor.js';
|
||||
import type { QueryResult } from './utils.js';
|
||||
import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js';
|
||||
|
||||
export interface QueryDispatchDeps {
|
||||
registry: QueryRegistry;
|
||||
projectDir: string;
|
||||
ws?: string;
|
||||
cjsFallbackEnabled: boolean;
|
||||
resolveGsdToolsPath: (projectDir: string) => string;
|
||||
dispatchNative: (cmd: string, args: string[]) => Promise<QueryResult>;
|
||||
}
|
||||
|
||||
type DispatchMode = 'native' | 'cjs' | 'error';
|
||||
|
||||
interface DispatchPlan {
|
||||
mode: DispatchMode;
|
||||
normalized: { command: string; args: string[]; tokens: string[] };
|
||||
matched: QueryCommandResolution | null;
|
||||
}
|
||||
|
||||
function planQueryDispatch(queryArgv: string[], registry: QueryRegistry, cjsFallbackEnabled: boolean): DispatchPlan {
|
||||
const queryCommand = queryArgv[0];
|
||||
if (!queryCommand) {
|
||||
return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null };
|
||||
}
|
||||
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
|
||||
const normalizedTokens = [normCmd, ...normArgs];
|
||||
const matched = resolveQueryCommand(queryCommand, queryArgv.slice(1), registry);
|
||||
if (matched) {
|
||||
return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched };
|
||||
}
|
||||
if (cjsFallbackEnabled) {
|
||||
return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
|
||||
}
|
||||
return { mode: 'error', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
|
||||
}
|
||||
|
||||
function extractPick(queryArgv: string[]): { queryArgs: string[]; pickField?: string; error?: QueryDispatchError } {
|
||||
const queryArgs = [...queryArgv];
|
||||
const pickIdx = queryArgs.indexOf('--pick');
|
||||
if (pickIdx === -1) return { queryArgs };
|
||||
if (pickIdx + 1 >= queryArgs.length) {
|
||||
return {
|
||||
queryArgs,
|
||||
error: { code: 10, message: 'Error: --pick requires a field name' },
|
||||
};
|
||||
}
|
||||
const pickField = queryArgs[pickIdx + 1];
|
||||
queryArgs.splice(pickIdx, 2);
|
||||
return { queryArgs, pickField };
|
||||
}
|
||||
|
||||
function formatOutput(data: unknown, format: QueryResult['format'], pickField?: string): string {
|
||||
// Text-format responses ignore --pick to match CJS fallback behavior.
|
||||
if (format === 'text' && typeof data === 'string') {
|
||||
return data.endsWith('\n') ? data : `${data}\n`;
|
||||
}
|
||||
let output: unknown = data;
|
||||
if (pickField) output = extractField(output, pickField);
|
||||
return `${JSON.stringify(output, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
|
||||
const picked = extractPick(queryArgv);
|
||||
if (picked.error) return { stderr: [], error: picked.error };
|
||||
|
||||
const { queryArgs, pickField } = picked;
|
||||
if (queryArgs.length === 0 || !queryArgs[0]) {
|
||||
return { stderr: [], error: { code: 10, message: 'Error: "gsd-sdk query" requires a command' } };
|
||||
}
|
||||
|
||||
const plan = planQueryDispatch(queryArgs, deps.registry, deps.cjsFallbackEnabled);
|
||||
const normCmd = plan.normalized.command;
|
||||
const normArgs = plan.normalized.args;
|
||||
|
||||
if (!normCmd || !String(normCmd).trim()) {
|
||||
return { stderr: [], error: { code: 10, message: 'Error: "gsd-sdk query" requires a command' } };
|
||||
}
|
||||
|
||||
if (plan.mode === 'error') {
|
||||
const noMatch = queryArgs[0]
|
||||
? explainQueryCommandNoMatch(queryArgs[0], queryArgs.slice(1), deps.registry)
|
||||
: null;
|
||||
return {
|
||||
stderr: [],
|
||||
error: {
|
||||
code: 10,
|
||||
message: `Error: Unknown command: "${[normCmd, ...normArgs].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. CJS fallback is disabled (GSD_QUERY_FALLBACK=registered). To enable fallback, unset GSD_QUERY_FALLBACK or set it to a non-restricted value.${noMatch ? ` Attempted dotted: ${noMatch.attempted.dotted.slice(0, 2).join(' | ')}.` : ''}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.mode === 'cjs') {
|
||||
const gsdPath = deps.resolveGsdToolsPath(deps.projectDir);
|
||||
return runCjsFallbackDispatch({
|
||||
projectDir: deps.projectDir,
|
||||
gsdToolsPath: gsdPath,
|
||||
normCmd,
|
||||
normArgs,
|
||||
ws: deps.ws,
|
||||
pickField,
|
||||
});
|
||||
}
|
||||
|
||||
const matched = plan.matched!;
|
||||
try {
|
||||
const result = await deps.dispatchNative(matched.cmd, matched.args);
|
||||
return {
|
||||
stderr: [],
|
||||
stdout: formatOutput(result.data, result.format, pickField),
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
stderr: [],
|
||||
error: { code: 1, message: `Error: ${msg}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user