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:
5
.changeset/happy-tigers-travel.md
Normal file
5
.changeset/happy-tigers-travel.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Query mutation event mapping moved to dedicated module** — preserves event payloads while improving registry locality and test surface.
|
||||
5
.changeset/humble-goats-swim.md
Normal file
5
.changeset/humble-goats-swim.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Alias-family handler maps moved to dedicated catalog module** — keeps command keys/order while reducing createRegistry coupling and improving family-level locality.
|
||||
5
.changeset/merry-moles-chatter.md
Normal file
5
.changeset/merry-moles-chatter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**CLI query CJS fallback execution extracted to dedicated adapter module** — preserves logs/help passthrough behavior while improving fallback locality and testability.
|
||||
5
.changeset/noble-badgers-roar.md
Normal file
5
.changeset/noble-badgers-roar.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Query mutation event emission now uses a dedicated decorator seam** — preserves fire-and-forget behavior while reducing registry coupling and improving testability.
|
||||
5
.changeset/quick-geese-hum.md
Normal file
5
.changeset/quick-geese-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Query fallback orchestration now shared** — CLI and SDK query dispatch now use one planning seam for native vs CJS fallback decisions with behavior parity preserved.
|
||||
5
.changeset/rapid-goats-munch.md
Normal file
5
.changeset/rapid-goats-munch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Query/transport policy data now converged in shared module** — mutation and raw-output policy wiring now share one source of truth to reduce drift.
|
||||
5
.changeset/sturdy-jays-glide.md
Normal file
5
.changeset/sturdy-jays-glide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3060
|
||||
---
|
||||
**Query static command registrations now split into domain catalog modules** — preserves command order/strings while improving registry locality and maintenance.
|
||||
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}`);
|
||||
|
||||
@@ -209,9 +209,9 @@ describe('GSDTools', () => {
|
||||
'state-load.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
// Script receives: state load --raw
|
||||
if (args[0] === 'state' && args[1] === 'load' && args.includes('--raw')) {
|
||||
process.stdout.write('phase=3\\nstatus=executing');
|
||||
// Script receives: state load (no --raw when policy is json)
|
||||
if (args[0] === 'state' && args[1] === 'load') {
|
||||
process.stdout.write(JSON.stringify({ phase: '3', status: 'executing' }));
|
||||
} else {
|
||||
process.stderr.write('unexpected args: ' + args.join(' '));
|
||||
process.exit(1);
|
||||
@@ -222,7 +222,7 @@ describe('GSDTools', () => {
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false });
|
||||
const result = await tools.stateLoad();
|
||||
|
||||
expect(result).toBe('phase=3\nstatus=executing');
|
||||
expect(result).toEqual({ phase: '3', status: 'executing' });
|
||||
});
|
||||
|
||||
it('commit() passes message and optional files', async () => {
|
||||
|
||||
@@ -20,8 +20,7 @@ import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis }
|
||||
import type { GSDEventStream } from './event-stream.js';
|
||||
import { GSDError, exitCodeFor } from './errors.js';
|
||||
import { createRegistry } from './query/index.js';
|
||||
import { resolveQueryArgv } from './query/registry.js';
|
||||
import { normalizeQueryCommand } from './query/normalize-query-command.js';
|
||||
import { resolveQueryCommand, type QueryCommandResolution } from './query/command-resolution.js';
|
||||
import { formatStateLoadRawStdout } from './query/state-project-load.js';
|
||||
import type { QueryResult } from './query/utils.js';
|
||||
import { GSDTransport } from './gsd-transport.js';
|
||||
@@ -152,10 +151,8 @@ export class GSDTools {
|
||||
return this.preferNativeQuery && !this.workstream;
|
||||
}
|
||||
|
||||
private nativeMatch(command: string, args: string[]) {
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(command, args);
|
||||
const tokens = [normCmd, ...normArgs];
|
||||
return resolveQueryArgv(tokens, this.registry);
|
||||
private nativeMatch(command: string, args: string[]): QueryCommandResolution | null {
|
||||
return resolveQueryCommand(command, args, this.registry);
|
||||
}
|
||||
|
||||
private toToolsError(command: string, args: string[], err: unknown): GSDToolsError {
|
||||
@@ -344,7 +341,7 @@ export class GSDTools {
|
||||
legacyArgs: args,
|
||||
registryCommand,
|
||||
registryArgs,
|
||||
mode: 'raw',
|
||||
mode: policy.outputMode,
|
||||
projectDir: this.projectDir,
|
||||
workstream: this.workstream,
|
||||
}, {
|
||||
@@ -565,21 +562,19 @@ export class GSDTools {
|
||||
*/
|
||||
export async function runGsdToolsQuery(projectDir: string, queryArgv: string[]): Promise<unknown> {
|
||||
const { createRegistry } = await import('./query/index.js');
|
||||
const { resolveQueryArgv } = await import('./query/registry.js');
|
||||
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
|
||||
const { resolveQueryCommand } = await import('./query/command-resolution.js');
|
||||
const { GSDError, ErrorClassification } = await import('./errors.js');
|
||||
|
||||
if (queryArgv.length === 0 || !queryArgv[0]) {
|
||||
throw new GSDError('runGsdToolsQuery requires a command', ErrorClassification.Validation);
|
||||
}
|
||||
const queryCommand = queryArgv[0];
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
|
||||
const registry = createRegistry();
|
||||
const tokens = [normCmd, ...normArgs];
|
||||
const matched = resolveQueryArgv(tokens, registry);
|
||||
const [normCmd, normArgs] = normalizeQueryCommand(queryArgv[0], queryArgv.slice(1));
|
||||
const matched = resolveQueryCommand(queryArgv[0], queryArgv.slice(1), registry);
|
||||
if (!matched) {
|
||||
throw new GSDError(
|
||||
`Unknown command: "${tokens.join(' ')}". No native handler registered.`,
|
||||
`Unknown command: "${[normCmd, ...normArgs].join(' ')}". No native handler registered.`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TRANSPORT_RAW_COMMANDS } from './query/policy-convergence.js';
|
||||
|
||||
export type TransportMode = 'json' | 'raw';
|
||||
|
||||
export interface TransportPolicy {
|
||||
@@ -12,17 +14,9 @@ const DEFAULT_POLICY: TransportPolicy = {
|
||||
outputMode: 'json',
|
||||
};
|
||||
|
||||
const BUILTIN_COMMAND_POLICY: Record<string, Partial<TransportPolicy>> = {
|
||||
// raw stdout contracts
|
||||
commit: { outputMode: 'raw' },
|
||||
'config-set': { outputMode: 'raw' },
|
||||
'verify-summary': { outputMode: 'raw' },
|
||||
'verify.summary': { outputMode: 'raw' },
|
||||
'verify summary': { outputMode: 'raw' },
|
||||
|
||||
// native-first/hard-fail examples (can expand later)
|
||||
// 'state.load': { allowFallbackToSubprocess: false, outputMode: 'raw' },
|
||||
};
|
||||
const BUILTIN_COMMAND_POLICY: Record<string, Partial<TransportPolicy>> = Object.fromEntries(
|
||||
TRANSPORT_RAW_COMMANDS.map((command) => [command, { outputMode: 'raw' as const }]),
|
||||
);
|
||||
|
||||
const COMMAND_POLICY_OVERRIDES: Record<string, Partial<TransportPolicy>> = {};
|
||||
|
||||
|
||||
@@ -174,7 +174,6 @@ describe('GSDTransport', () => {
|
||||
expect(result).toBe('');
|
||||
expect(adapters.execSubprocessRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces subprocess when workstream present', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
registry.register('state.load', async () => ({ data: { ok: true } }));
|
||||
|
||||
@@ -50,8 +50,11 @@ export class GSDTransport {
|
||||
}
|
||||
return native.data;
|
||||
} catch (error) {
|
||||
if (isTimeoutLikeError(error)) throw error;
|
||||
if (!policy.allowFallbackToSubprocess) throw error;
|
||||
// Do not subprocess-fallback after a timed-out native dispatch:
|
||||
// the timeout does not cancel the native handler, so falling through
|
||||
// would run the same command twice (double-execution race).
|
||||
if (isTimeoutLikeError(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
sdk/src/query/command-catalog.ts
Normal file
31
sdk/src/query/command-catalog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
export interface AliasCatalogEntry {
|
||||
canonical: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export function registerAliasCatalog(
|
||||
registry: QueryRegistry,
|
||||
aliases: readonly AliasCatalogEntry[],
|
||||
handlers: Readonly<Record<string, QueryHandler>>,
|
||||
): void {
|
||||
for (const entry of aliases) {
|
||||
const handler = handlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStaticCatalog(
|
||||
registry: QueryRegistry,
|
||||
entries: ReadonlyArray<readonly [command: string, handler: QueryHandler]>,
|
||||
): void {
|
||||
for (const [command, handler] of entries) {
|
||||
registry.register(command, handler);
|
||||
}
|
||||
}
|
||||
117
sdk/src/query/command-family-handlers.ts
Normal file
117
sdk/src/query/command-family-handlers.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
import { stateProjectLoad } from './state-project-load.js';
|
||||
import { stateJson, stateGet } from './state.js';
|
||||
import {
|
||||
stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan,
|
||||
stateRecordMetric, stateUpdateProgress, stateAddDecision,
|
||||
stateAddBlocker, stateResolveBlocker, stateRecordSession,
|
||||
stateSignalWaiting, stateSignalResume, statePlannedPhase,
|
||||
stateValidate, stateSync, statePrune, stateMilestoneSwitch,
|
||||
stateAddRoadmapEvolution,
|
||||
} from './state-mutation.js';
|
||||
import { roadmapAnalyze, roadmapGetPhase, roadmapAnnotateDependencies } from './roadmap.js';
|
||||
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
|
||||
import {
|
||||
verifyPlanStructure, verifyPhaseCompleteness, verifyReferences,
|
||||
verifyCommits, verifyArtifacts, verifySchemaDrift,
|
||||
verifyCodebaseDrift,
|
||||
} from './verify.js';
|
||||
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
|
||||
import {
|
||||
phaseListPlans, phaseListArtifacts,
|
||||
} from './phase-list-queries.js';
|
||||
import {
|
||||
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
|
||||
phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive,
|
||||
} from './phase-lifecycle.js';
|
||||
import {
|
||||
initExecutePhase, initPlanPhase, initNewMilestone, initQuick,
|
||||
initIngestDocs, initResume, initVerifyWork, initPhaseOp, initTodos,
|
||||
initMilestoneOp, initMapCodebase, initNewWorkspace,
|
||||
initListWorkspaces, initRemoveWorkspace,
|
||||
} from './init.js';
|
||||
import { initNewProject, initProgress, initManager } from './init-complex.js';
|
||||
|
||||
export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandler>>> = {
|
||||
state: {
|
||||
'state.load': stateProjectLoad,
|
||||
'state.json': stateJson,
|
||||
'state.get': stateGet,
|
||||
'state.update': stateUpdate,
|
||||
'state.patch': statePatch,
|
||||
'state.begin-phase': stateBeginPhase,
|
||||
'state.advance-plan': stateAdvancePlan,
|
||||
'state.record-metric': stateRecordMetric,
|
||||
'state.update-progress': stateUpdateProgress,
|
||||
'state.add-decision': stateAddDecision,
|
||||
'state.add-blocker': stateAddBlocker,
|
||||
'state.resolve-blocker': stateResolveBlocker,
|
||||
'state.record-session': stateRecordSession,
|
||||
'state.signal-waiting': stateSignalWaiting,
|
||||
'state.signal-resume': stateSignalResume,
|
||||
'state.planned-phase': statePlannedPhase,
|
||||
'state.validate': stateValidate,
|
||||
'state.sync': stateSync,
|
||||
'state.prune': statePrune,
|
||||
'state.milestone-switch': stateMilestoneSwitch,
|
||||
'state.add-roadmap-evolution': stateAddRoadmapEvolution,
|
||||
},
|
||||
roadmap: {
|
||||
'roadmap.analyze': roadmapAnalyze,
|
||||
'roadmap.get-phase': roadmapGetPhase,
|
||||
'roadmap.update-plan-progress': roadmapUpdatePlanProgress,
|
||||
'roadmap.annotate-dependencies': roadmapAnnotateDependencies,
|
||||
},
|
||||
verify: {
|
||||
'verify.plan-structure': verifyPlanStructure,
|
||||
'verify.phase-completeness': verifyPhaseCompleteness,
|
||||
'verify.references': verifyReferences,
|
||||
'verify.commits': verifyCommits,
|
||||
'verify.artifacts': verifyArtifacts,
|
||||
'verify.key-links': verifyKeyLinks,
|
||||
'verify.schema-drift': verifySchemaDrift,
|
||||
'verify.codebase-drift': verifyCodebaseDrift,
|
||||
},
|
||||
validate: {
|
||||
'validate.consistency': validateConsistency,
|
||||
'validate.health': validateHealth,
|
||||
'validate.agents': validateAgents,
|
||||
'validate.context': validateContext,
|
||||
},
|
||||
phase: {
|
||||
'phase.list-plans': phaseListPlans,
|
||||
'phase.list-artifacts': phaseListArtifacts,
|
||||
'phase.add': phaseAdd,
|
||||
'phase.add-batch': phaseAddBatch,
|
||||
'phase.insert': phaseInsert,
|
||||
'phase.remove': phaseRemove,
|
||||
'phase.complete': phaseComplete,
|
||||
'phase.scaffold': phaseScaffold,
|
||||
'phase.next-decimal': phaseNextDecimal,
|
||||
},
|
||||
phases: {
|
||||
'phases.list': phasesList,
|
||||
'phases.clear': phasesClear,
|
||||
'phases.archive': phasesArchive,
|
||||
},
|
||||
init: {
|
||||
'init.execute-phase': initExecutePhase,
|
||||
'init.plan-phase': initPlanPhase,
|
||||
'init.new-project': initNewProject,
|
||||
'init.new-milestone': initNewMilestone,
|
||||
'init.quick': initQuick,
|
||||
'init.ingest-docs': initIngestDocs,
|
||||
'init.resume': initResume,
|
||||
'init.verify-work': initVerifyWork,
|
||||
'init.phase-op': initPhaseOp,
|
||||
'init.todos': initTodos,
|
||||
'init.milestone-op': initMilestoneOp,
|
||||
'init.map-codebase': initMapCodebase,
|
||||
'init.progress': initProgress,
|
||||
'init.manager': initManager,
|
||||
'init.new-workspace': initNewWorkspace,
|
||||
'init.list-workspaces': initListWorkspaces,
|
||||
'init.remove-workspace': initRemoveWorkspace,
|
||||
},
|
||||
};
|
||||
70
sdk/src/query/command-resolution.test.ts
Normal file
70
sdk/src/query/command-resolution.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRegistry } from './index.js';
|
||||
import { explainQueryCommandNoMatch, resolveQueryCommand, resolveQueryTokens } from './command-resolution.js';
|
||||
|
||||
describe('command resolution', () => {
|
||||
it('resolves normalized tokens with metadata', () => {
|
||||
const registry = createRegistry();
|
||||
const resolved = resolveQueryTokens(['state', 'update', 'status', 'X'], registry);
|
||||
expect(resolved).toEqual({
|
||||
cmd: 'state.update',
|
||||
args: ['status', 'X'],
|
||||
matchedBy: 'dotted',
|
||||
expanded: false,
|
||||
source: 'normalized',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves dotted token directly when canonical is registered', () => {
|
||||
const registry = createRegistry();
|
||||
const resolved = resolveQueryTokens(['init.execute-phase', '1'], registry);
|
||||
expect(resolved).toEqual({
|
||||
cmd: 'init.execute-phase',
|
||||
args: ['1'],
|
||||
matchedBy: 'dotted',
|
||||
expanded: false,
|
||||
source: 'normalized',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks expanded source when only spaced command exists', () => {
|
||||
const registry = {
|
||||
has(command: string) {
|
||||
return command === 'init execute-phase';
|
||||
},
|
||||
};
|
||||
const resolved = resolveQueryTokens(['init.execute-phase', '1'], registry);
|
||||
expect(resolved).toEqual({
|
||||
cmd: 'init execute-phase',
|
||||
args: ['1'],
|
||||
matchedBy: 'spaced',
|
||||
expanded: true,
|
||||
source: 'expanded',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves from raw command+args using normalize rules', () => {
|
||||
const registry = createRegistry();
|
||||
const resolved = resolveQueryCommand('state', ['json'], registry);
|
||||
expect(resolved?.cmd).toBe('state.json');
|
||||
expect(resolved?.args).toEqual([]);
|
||||
expect(resolved?.source).toBe('normalized');
|
||||
});
|
||||
|
||||
it('returns null for unknown command', () => {
|
||||
const registry = createRegistry();
|
||||
expect(resolveQueryCommand('totally-unknown', ['x'], registry)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns structured no-match metadata', () => {
|
||||
const registry = createRegistry();
|
||||
const noMatch = explainQueryCommandNoMatch('state', ['made-up-op', 'x'], registry);
|
||||
expect(noMatch.normalized).toEqual({
|
||||
command: 'state',
|
||||
args: ['made-up-op', 'x'],
|
||||
tokens: ['state', 'made-up-op', 'x'],
|
||||
});
|
||||
expect(noMatch.attempted.dotted[0]).toBe('state.made-up-op.x');
|
||||
expect(noMatch.attempted.spaced[0]).toBe('state made-up-op x');
|
||||
});
|
||||
});
|
||||
10
sdk/src/query/command-resolution.ts
Normal file
10
sdk/src/query/command-resolution.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
resolveQueryCommand,
|
||||
resolveQueryTokens,
|
||||
type QueryCommandRegistryLike,
|
||||
type QueryCommandResolution,
|
||||
type QueryMatchMode,
|
||||
type QueryResolutionSource,
|
||||
explainQueryCommandNoMatch,
|
||||
type QueryCommandNoMatch,
|
||||
} from './query-command-semantics.js';
|
||||
106
sdk/src/query/command-static-catalog-domain.ts
Normal file
106
sdk/src/query/command-static-catalog-domain.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { agentSkills } from './skills.js';
|
||||
import { requirementsMarkComplete } from './roadmap.js';
|
||||
import { todoMatchPhase, statsJson, statsTable, progressBar, progressTable, listTodos, todoComplete } from './progress.js';
|
||||
import { milestoneComplete } from './phase-lifecycle.js';
|
||||
import { summaryExtract, historyDigest } from './summary.js';
|
||||
import { commitToSubrepo } from './commit.js';
|
||||
import { workstreamGet, workstreamList, workstreamCreate, workstreamSet, workstreamStatus, workstreamComplete, workstreamProgress } from './workstream.js';
|
||||
import { docsInit } from './docs-init.js';
|
||||
import { websearch } from './websearch.js';
|
||||
import { learningsCopy, learningsQuery, learningsListHandler, learningsPrune, learningsDelete, extractMessages, scanSessions, profileSample, profileQuestionnaire } from './profile.js';
|
||||
import { skillManifest } from './skill-manifest.js';
|
||||
import { auditOpen } from './audit-open.js';
|
||||
import { detectCustomFiles } from './detect-custom-files.js';
|
||||
import { uatRenderCheckpoint, auditUat } from './uat.js';
|
||||
import { intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery, intelExtractExports, intelPatchMeta, intelUpdate } from './intel.js';
|
||||
import { writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd } from './profile-output.js';
|
||||
|
||||
export const DOMAIN_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['agent-skills', agentSkills],
|
||||
['requirements.mark-complete', requirementsMarkComplete],
|
||||
['requirements mark-complete', requirementsMarkComplete],
|
||||
['todo.match-phase', todoMatchPhase],
|
||||
['todo match-phase', todoMatchPhase],
|
||||
['list-todos', listTodos],
|
||||
['list.todos', listTodos],
|
||||
['todo.complete', todoComplete],
|
||||
['todo complete', todoComplete],
|
||||
['milestone.complete', milestoneComplete],
|
||||
['milestone complete', milestoneComplete],
|
||||
['summary.extract', summaryExtract],
|
||||
['summary extract', summaryExtract],
|
||||
['summary-extract', summaryExtract],
|
||||
['history.digest', historyDigest],
|
||||
['history digest', historyDigest],
|
||||
['history-digest', historyDigest],
|
||||
['stats', statsJson],
|
||||
['stats.json', statsJson],
|
||||
['stats json', statsJson],
|
||||
['stats.table', statsTable],
|
||||
['stats table', statsTable],
|
||||
['commit-to-subrepo', commitToSubrepo],
|
||||
['progress.bar', progressBar],
|
||||
['progress bar', progressBar],
|
||||
['progress.table', progressTable],
|
||||
['progress table', progressTable],
|
||||
['workstream.get', workstreamGet],
|
||||
['workstream get', workstreamGet],
|
||||
['workstream.list', workstreamList],
|
||||
['workstream list', workstreamList],
|
||||
['workstream.create', workstreamCreate],
|
||||
['workstream create', workstreamCreate],
|
||||
['workstream.set', workstreamSet],
|
||||
['workstream set', workstreamSet],
|
||||
['workstream.status', workstreamStatus],
|
||||
['workstream status', workstreamStatus],
|
||||
['workstream.complete', workstreamComplete],
|
||||
['workstream complete', workstreamComplete],
|
||||
['workstream.progress', workstreamProgress],
|
||||
['workstream progress', workstreamProgress],
|
||||
['docs-init', docsInit],
|
||||
['websearch', websearch],
|
||||
['learnings.copy', learningsCopy],
|
||||
['learnings copy', learningsCopy],
|
||||
['learnings.query', learningsQuery],
|
||||
['learnings query', learningsQuery],
|
||||
['learnings.list', learningsListHandler],
|
||||
['learnings list', learningsListHandler],
|
||||
['learnings.prune', learningsPrune],
|
||||
['learnings prune', learningsPrune],
|
||||
['learnings.delete', learningsDelete],
|
||||
['learnings delete', learningsDelete],
|
||||
['skill-manifest', skillManifest],
|
||||
['skill manifest', skillManifest],
|
||||
['audit-open', auditOpen],
|
||||
['audit open', auditOpen],
|
||||
['detect-custom-files', detectCustomFiles],
|
||||
['extract-messages', extractMessages],
|
||||
['extract.messages', extractMessages],
|
||||
['audit-uat', auditUat],
|
||||
['uat.render-checkpoint', uatRenderCheckpoint],
|
||||
['uat render-checkpoint', uatRenderCheckpoint],
|
||||
['intel.diff', intelDiff],
|
||||
['intel diff', intelDiff],
|
||||
['intel.snapshot', intelSnapshot],
|
||||
['intel snapshot', intelSnapshot],
|
||||
['intel.validate', intelValidate],
|
||||
['intel validate', intelValidate],
|
||||
['intel.status', intelStatus],
|
||||
['intel status', intelStatus],
|
||||
['intel.query', intelQuery],
|
||||
['intel query', intelQuery],
|
||||
['intel.extract-exports', intelExtractExports],
|
||||
['intel extract-exports', intelExtractExports],
|
||||
['intel.patch-meta', intelPatchMeta],
|
||||
['intel patch-meta', intelPatchMeta],
|
||||
['intel.update', intelUpdate],
|
||||
['intel update', intelUpdate],
|
||||
['generate-claude-profile', generateClaudeProfile],
|
||||
['generate-dev-preferences', generateDevPreferences],
|
||||
['write-profile', writeProfile],
|
||||
['profile-questionnaire', profileQuestionnaire],
|
||||
['profile-sample', profileSample],
|
||||
['scan-sessions', scanSessions],
|
||||
['generate-claude-md', generateClaudeMd],
|
||||
] as const;
|
||||
98
sdk/src/query/command-static-catalog-foundation.ts
Normal file
98
sdk/src/query/command-static-catalog-foundation.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { generateSlug, currentTimestamp } from './utils.js';
|
||||
import { frontmatterGet } from './frontmatter.js';
|
||||
import { configGet, configPath, resolveModel } from './config-query.js';
|
||||
import { stateSnapshot } from './state.js';
|
||||
import { findPhase, phasePlanIndex } from './phase.js';
|
||||
import { planTaskStructure } from './plan-task-structure.js';
|
||||
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
|
||||
import { progressJson } from './progress.js';
|
||||
import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js';
|
||||
import { configSet, configSetModelProfile, configNewProject, configEnsureSection } from './config-mutation.js';
|
||||
import { commit, checkCommit } from './commit.js';
|
||||
import { templateFill, templateSelect } from './template.js';
|
||||
import { verifySummary, verifyPathExists } from './verify.js';
|
||||
import { decisionsParse } from './decisions.js';
|
||||
import { checkDecisionCoveragePlan, checkDecisionCoverageVerify } from './check-decision-coverage.js';
|
||||
import { checkConfigGates } from './config-gates.js';
|
||||
import { checkAutoMode } from './check-auto-mode.js';
|
||||
import { checkPhaseReady } from './phase-ready.js';
|
||||
import { routeNextAction } from './route-next-action.js';
|
||||
import { detectPhaseType } from './detect-phase-type.js';
|
||||
import { checkCompletion } from './check-completion.js';
|
||||
import { checkGates } from './check-gates.js';
|
||||
import { checkVerificationStatus } from './check-verification-status.js';
|
||||
import { checkShipReady } from './check-ship-ready.js';
|
||||
|
||||
export const FOUNDATION_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['generate-slug', generateSlug],
|
||||
['current-timestamp', currentTimestamp],
|
||||
['frontmatter.get', frontmatterGet],
|
||||
['config-get', configGet],
|
||||
['config-path', configPath],
|
||||
['resolve-model', resolveModel],
|
||||
] as const;
|
||||
|
||||
export const STATE_SUPPORT_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['state-snapshot', stateSnapshot],
|
||||
['find-phase', findPhase],
|
||||
['phase-plan-index', phasePlanIndex],
|
||||
['plan.task-structure', planTaskStructure],
|
||||
['plan task-structure', planTaskStructure],
|
||||
['requirements.extract-from-plans', requirementsExtractFromPlans],
|
||||
['requirements extract-from-plans', requirementsExtractFromPlans],
|
||||
] as const;
|
||||
|
||||
export const MUTATION_SURFACES_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['progress', progressJson],
|
||||
['progress.json', progressJson],
|
||||
['frontmatter.set', frontmatterSet],
|
||||
['frontmatter.merge', frontmatterMerge],
|
||||
['frontmatter.validate', frontmatterValidate],
|
||||
['frontmatter validate', frontmatterValidate],
|
||||
['config-set', configSet],
|
||||
['config-set-model-profile', configSetModelProfile],
|
||||
['config-new-project', configNewProject],
|
||||
['config-ensure-section', configEnsureSection],
|
||||
['commit', commit],
|
||||
['check-commit', checkCommit],
|
||||
['template.fill', templateFill],
|
||||
['template.select', templateSelect],
|
||||
['template select', templateSelect],
|
||||
] as const;
|
||||
|
||||
export const VERIFY_DECISION_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['verify-summary', verifySummary],
|
||||
['verify.summary', verifySummary],
|
||||
['verify summary', verifySummary],
|
||||
['verify-path-exists', verifyPathExists],
|
||||
['verify.path-exists', verifyPathExists],
|
||||
['verify path-exists', verifyPathExists],
|
||||
['decisions.parse', decisionsParse],
|
||||
['decisions parse', decisionsParse],
|
||||
['check.decision-coverage-plan', checkDecisionCoveragePlan],
|
||||
['check decision-coverage-plan', checkDecisionCoveragePlan],
|
||||
['check.decision-coverage-verify', checkDecisionCoverageVerify],
|
||||
['check decision-coverage-verify', checkDecisionCoverageVerify],
|
||||
] as const;
|
||||
|
||||
export const DECISION_ROUTING_STATIC_CATALOG: ReadonlyArray<readonly [string, QueryHandler]> = [
|
||||
['check.config-gates', checkConfigGates],
|
||||
['check config-gates', checkConfigGates],
|
||||
['check.auto-mode', checkAutoMode],
|
||||
['check auto-mode', checkAutoMode],
|
||||
['check.phase-ready', checkPhaseReady],
|
||||
['check phase-ready', checkPhaseReady],
|
||||
['route.next-action', routeNextAction],
|
||||
['route next-action', routeNextAction],
|
||||
['detect.phase-type', detectPhaseType],
|
||||
['detect phase-type', detectPhaseType],
|
||||
['check.completion', checkCompletion],
|
||||
['check completion', checkCompletion],
|
||||
['check.gates', checkGates],
|
||||
['check gates', checkGates],
|
||||
['check.verification-status', checkVerificationStatus],
|
||||
['check verification-status', checkVerificationStatus],
|
||||
['check.ship-ready', checkShipReady],
|
||||
['check ship-ready', checkShipReady],
|
||||
] as const;
|
||||
16
sdk/src/query/index-thin-seam.test.ts
Normal file
16
sdk/src/query/index-thin-seam.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as query from './index.js';
|
||||
|
||||
describe('query index thin seam', () => {
|
||||
it('re-exports registry assembly seam', () => {
|
||||
expect(typeof query.createRegistry).toBe('function');
|
||||
expect(typeof query.buildRegistry).toBe('function');
|
||||
expect(typeof query.decorateRegistryMutations).toBe('function');
|
||||
expect(query.QUERY_MUTATION_COMMANDS).toBeInstanceOf(Set);
|
||||
});
|
||||
|
||||
it('re-exports shared query helpers', () => {
|
||||
expect(typeof query.extractField).toBe('function');
|
||||
expect(typeof query.normalizeQueryCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -1,609 +1,7 @@
|
||||
/**
|
||||
* Query module entry point — factory and re-exports.
|
||||
*
|
||||
* The `createRegistry()` factory creates a fully-wired `QueryRegistry`
|
||||
* with all native handlers registered. New handlers are added here
|
||||
* as they are migrated from gsd-tools.cjs.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createRegistry } from './query/index.js';
|
||||
*
|
||||
* const registry = createRegistry();
|
||||
* const result = await registry.dispatch('generate-slug', ['My Phase'], projectDir);
|
||||
* ```
|
||||
*/
|
||||
/** Query module entry point — thin seam. */
|
||||
|
||||
import { QueryRegistry } from './registry.js';
|
||||
import { generateSlug, currentTimestamp } from './utils.js';
|
||||
import { frontmatterGet } from './frontmatter.js';
|
||||
import { configGet, configPath, resolveModel } from './config-query.js';
|
||||
import { stateJson, stateGet, stateSnapshot } from './state.js';
|
||||
import { stateProjectLoad } from './state-project-load.js';
|
||||
import {
|
||||
STATE_COMMAND_ALIASES,
|
||||
STATE_MUTATION_COMMANDS,
|
||||
VERIFY_COMMAND_ALIASES,
|
||||
INIT_COMMAND_ALIASES,
|
||||
PHASE_COMMAND_ALIASES,
|
||||
PHASE_MUTATION_COMMANDS,
|
||||
PHASES_COMMAND_ALIASES,
|
||||
PHASES_MUTATION_COMMANDS,
|
||||
VALIDATE_COMMAND_ALIASES,
|
||||
ROADMAP_COMMAND_ALIASES,
|
||||
ROADMAP_MUTATION_COMMANDS,
|
||||
} from './command-aliases.generated.js';
|
||||
import { findPhase, phasePlanIndex } from './phase.js';
|
||||
import { phaseListPlans, phaseListArtifacts } from './phase-list-queries.js';
|
||||
import { planTaskStructure } from './plan-task-structure.js';
|
||||
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
|
||||
import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js';
|
||||
import { progressJson } from './progress.js';
|
||||
import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js';
|
||||
import {
|
||||
stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan,
|
||||
stateRecordMetric, stateUpdateProgress, stateAddDecision,
|
||||
stateAddBlocker, stateResolveBlocker, stateRecordSession,
|
||||
stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune,
|
||||
stateMilestoneSwitch, stateAddRoadmapEvolution,
|
||||
} from './state-mutation.js';
|
||||
import {
|
||||
configSet, configSetModelProfile, configNewProject, configEnsureSection,
|
||||
} from './config-mutation.js';
|
||||
import { commit, checkCommit } from './commit.js';
|
||||
import { templateFill, templateSelect } from './template.js';
|
||||
import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts, verifyCommits, verifyReferences, verifySummary, verifyPathExists } from './verify.js';
|
||||
import { decisionsParse } from './decisions.js';
|
||||
import { checkDecisionCoveragePlan, checkDecisionCoverageVerify } from './check-decision-coverage.js';
|
||||
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js';
|
||||
import {
|
||||
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
|
||||
phaseScaffold, phasesClear, phasesArchive,
|
||||
phasesList, phaseNextDecimal,
|
||||
} from './phase-lifecycle.js';
|
||||
import {
|
||||
initExecutePhase, initPlanPhase, initNewMilestone, initQuick,
|
||||
initResume, initVerifyWork, initPhaseOp, initTodos, initMilestoneOp,
|
||||
initMapCodebase, initNewWorkspace, initListWorkspaces, initRemoveWorkspace,
|
||||
initIngestDocs,
|
||||
} from './init.js';
|
||||
import { initNewProject, initProgress, initManager } from './init-complex.js';
|
||||
import { agentSkills } from './skills.js';
|
||||
import { requirementsMarkComplete, roadmapAnnotateDependencies } from './roadmap.js';
|
||||
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
|
||||
import { statePlannedPhase } from './state-mutation.js';
|
||||
import { verifySchemaDrift, verifyCodebaseDrift } from './verify.js';
|
||||
import {
|
||||
todoMatchPhase, statsJson, statsTable, progressBar, progressTable, listTodos, todoComplete,
|
||||
} from './progress.js';
|
||||
import { milestoneComplete } from './phase-lifecycle.js';
|
||||
import { summaryExtract, historyDigest } from './summary.js';
|
||||
import { commitToSubrepo } from './commit.js';
|
||||
import {
|
||||
workstreamGet, workstreamList, workstreamCreate, workstreamSet, workstreamStatus,
|
||||
workstreamComplete, workstreamProgress,
|
||||
} from './workstream.js';
|
||||
import { docsInit } from './docs-init.js';
|
||||
import { uatRenderCheckpoint, auditUat } from './uat.js';
|
||||
import { websearch } from './websearch.js';
|
||||
import {
|
||||
intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery,
|
||||
intelExtractExports, intelPatchMeta, intelUpdate,
|
||||
} from './intel.js';
|
||||
import {
|
||||
learningsCopy, learningsQuery, learningsListHandler, learningsPrune, learningsDelete,
|
||||
extractMessages, scanSessions, profileSample, profileQuestionnaire,
|
||||
} from './profile.js';
|
||||
import {
|
||||
writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd,
|
||||
} from './profile-output.js';
|
||||
import { skillManifest } from './skill-manifest.js';
|
||||
import { auditOpen } from './audit-open.js';
|
||||
import { detectCustomFiles } from './detect-custom-files.js';
|
||||
import { checkConfigGates } from './config-gates.js';
|
||||
import { checkAutoMode } from './check-auto-mode.js';
|
||||
import { checkPhaseReady } from './phase-ready.js';
|
||||
import { routeNextAction } from './route-next-action.js';
|
||||
import { detectPhaseType } from './detect-phase-type.js';
|
||||
import { checkCompletion } from './check-completion.js';
|
||||
import { checkGates } from './check-gates.js';
|
||||
import { checkVerificationStatus } from './check-verification-status.js';
|
||||
import { checkShipReady } from './check-ship-ready.js';
|
||||
import { GSDEventStream } from '../event-stream.js';
|
||||
import {
|
||||
GSDEventType,
|
||||
type GSDEvent,
|
||||
type GSDStateMutationEvent,
|
||||
type GSDConfigMutationEvent,
|
||||
type GSDFrontmatterMutationEvent,
|
||||
type GSDGitCommitEvent,
|
||||
type GSDTemplateFillEvent,
|
||||
} from '../types.js';
|
||||
import type { QueryHandler, QueryResult } from './utils.js';
|
||||
|
||||
// ─── Re-exports ────────────────────────────────────────────────────────────
|
||||
export { createRegistry, buildRegistry, decorateRegistryMutations, QUERY_MUTATION_COMMANDS } from './registry-assembly.js';
|
||||
|
||||
export type { QueryResult, QueryHandler } from './utils.js';
|
||||
export { extractField } from './registry.js';
|
||||
/** Same argv normalization as `gsd-sdk query` — use when calling `registry.dispatch()` with CLI-style `command` + `args`. */
|
||||
export { normalizeQueryCommand } from './normalize-query-command.js';
|
||||
|
||||
// ─── Mutation commands set ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Command names that perform durable writes (disk, git, or global profile store).
|
||||
* Used to wire event emission after successful dispatch. Both dotted and
|
||||
* space-delimited aliases must be listed when both exist.
|
||||
*
|
||||
* See QUERY-HANDLERS.md for semantics. Init composition handlers are omitted
|
||||
* (they emit JSON for workflows; agents perform writes).
|
||||
*/
|
||||
export const QUERY_MUTATION_COMMANDS = new Set<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',
|
||||
]);
|
||||
|
||||
// ─── Event builder ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a mutation event based on the command prefix and result.
|
||||
*
|
||||
* @param correlationSessionId - Optional session correlation id (from {@link createRegistry})
|
||||
*/
|
||||
function buildMutationEvent(
|
||||
correlationSessionId: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
result: QueryResult,
|
||||
): GSDEvent {
|
||||
const base = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: correlationSessionId,
|
||||
};
|
||||
|
||||
if (cmd.startsWith('template.') || cmd.startsWith('template ')) {
|
||||
const data = result.data as Record<string, unknown> | null;
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.TemplateFill,
|
||||
templateType: (data?.template as string) ?? args[0] ?? '',
|
||||
path: (data?.path as string) ?? args[1] ?? '',
|
||||
created: (data?.created as boolean) ?? false,
|
||||
} as GSDTemplateFillEvent;
|
||||
}
|
||||
|
||||
if (cmd === 'commit' || cmd === 'check-commit' || cmd === 'commit-to-subrepo') {
|
||||
const data = result.data as Record<string, unknown> | null;
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.GitCommit,
|
||||
hash: (data?.hash as string) ?? null,
|
||||
committed: (data?.committed as boolean) ?? false,
|
||||
reason: (data?.reason as string) ?? '',
|
||||
} as GSDGitCommitEvent;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('frontmatter.') || cmd.startsWith('frontmatter ')) {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.FrontmatterMutation,
|
||||
command: cmd,
|
||||
file: args[0] ?? '',
|
||||
fields: args.slice(1),
|
||||
success: true,
|
||||
} as GSDFrontmatterMutationEvent;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('config-')) {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.ConfigMutation,
|
||||
command: cmd,
|
||||
key: args[0] ?? '',
|
||||
success: true,
|
||||
} as GSDConfigMutationEvent;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('validate.') || cmd.startsWith('validate ')) {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.ConfigMutation,
|
||||
command: cmd,
|
||||
key: args[0] ?? '',
|
||||
success: true,
|
||||
} as GSDConfigMutationEvent;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ')) {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.StateMutation,
|
||||
command: cmd,
|
||||
fields: args.slice(0, 2),
|
||||
success: true,
|
||||
} as GSDStateMutationEvent;
|
||||
}
|
||||
|
||||
if (cmd.startsWith('state.') || cmd.startsWith('state ')) {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.StateMutation,
|
||||
command: cmd,
|
||||
fields: args.slice(0, 2),
|
||||
success: true,
|
||||
} as GSDStateMutationEvent;
|
||||
}
|
||||
|
||||
// roadmap, requirements, todo, milestone, workstream, intel, profile, learnings, docs-init
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.StateMutation,
|
||||
command: cmd,
|
||||
fields: args.slice(0, 2),
|
||||
success: true,
|
||||
} as GSDStateMutationEvent;
|
||||
}
|
||||
|
||||
// ─── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a fully-wired QueryRegistry with all native handlers registered.
|
||||
*
|
||||
* @param eventStream - Optional event stream for mutation event emission
|
||||
* @param correlationSessionId - Optional session id threaded into mutation-related events
|
||||
* @returns A QueryRegistry instance with all handlers registered
|
||||
*/
|
||||
export function createRegistry(
|
||||
eventStream?: GSDEventStream,
|
||||
correlationSessionId?: string,
|
||||
): QueryRegistry {
|
||||
const mutationSessionId = correlationSessionId ?? '';
|
||||
const registry = new QueryRegistry();
|
||||
|
||||
registry.register('generate-slug', generateSlug);
|
||||
registry.register('current-timestamp', currentTimestamp);
|
||||
registry.register('frontmatter.get', frontmatterGet);
|
||||
registry.register('config-get', configGet);
|
||||
registry.register('config-path', configPath);
|
||||
registry.register('resolve-model', resolveModel);
|
||||
const stateHandlers: Record<string, QueryHandler> = {
|
||||
'state.load': stateProjectLoad,
|
||||
'state.json': stateJson,
|
||||
'state.get': stateGet,
|
||||
'state.update': stateUpdate,
|
||||
'state.patch': statePatch,
|
||||
'state.begin-phase': stateBeginPhase,
|
||||
'state.advance-plan': stateAdvancePlan,
|
||||
'state.record-metric': stateRecordMetric,
|
||||
'state.update-progress': stateUpdateProgress,
|
||||
'state.add-decision': stateAddDecision,
|
||||
'state.add-blocker': stateAddBlocker,
|
||||
'state.resolve-blocker': stateResolveBlocker,
|
||||
'state.record-session': stateRecordSession,
|
||||
'state.signal-waiting': stateSignalWaiting,
|
||||
'state.signal-resume': stateSignalResume,
|
||||
'state.planned-phase': statePlannedPhase,
|
||||
'state.validate': stateValidate,
|
||||
'state.sync': stateSync,
|
||||
'state.prune': statePrune,
|
||||
'state.milestone-switch': stateMilestoneSwitch,
|
||||
'state.add-roadmap-evolution': stateAddRoadmapEvolution,
|
||||
};
|
||||
|
||||
for (const entry of STATE_COMMAND_ALIASES) {
|
||||
const handler = stateHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
registry.register('state-snapshot', stateSnapshot);
|
||||
registry.register('find-phase', findPhase);
|
||||
registry.register('phase-plan-index', phasePlanIndex);
|
||||
registry.register('plan.task-structure', planTaskStructure);
|
||||
registry.register('plan task-structure', planTaskStructure);
|
||||
registry.register('requirements.extract-from-plans', requirementsExtractFromPlans);
|
||||
registry.register('requirements extract-from-plans', requirementsExtractFromPlans);
|
||||
const roadmapHandlers: Record<string, QueryHandler> = {
|
||||
'roadmap.analyze': roadmapAnalyze,
|
||||
'roadmap.get-phase': roadmapGetPhase,
|
||||
'roadmap.update-plan-progress': roadmapUpdatePlanProgress,
|
||||
'roadmap.annotate-dependencies': roadmapAnnotateDependencies,
|
||||
};
|
||||
|
||||
for (const entry of ROADMAP_COMMAND_ALIASES) {
|
||||
const handler = roadmapHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
registry.register('progress', progressJson);
|
||||
registry.register('progress.json', progressJson);
|
||||
|
||||
// Frontmatter mutation handlers
|
||||
registry.register('frontmatter.set', frontmatterSet);
|
||||
registry.register('frontmatter.merge', frontmatterMerge);
|
||||
registry.register('frontmatter.validate', frontmatterValidate);
|
||||
registry.register('frontmatter validate', frontmatterValidate);
|
||||
|
||||
// Config mutation handlers
|
||||
registry.register('config-set', configSet);
|
||||
registry.register('config-set-model-profile', configSetModelProfile);
|
||||
registry.register('config-new-project', configNewProject);
|
||||
registry.register('config-ensure-section', configEnsureSection);
|
||||
|
||||
// Git commit handlers
|
||||
registry.register('commit', commit);
|
||||
registry.register('check-commit', checkCommit);
|
||||
|
||||
// Template handlers
|
||||
registry.register('template.fill', templateFill);
|
||||
registry.register('template.select', templateSelect);
|
||||
registry.register('template select', templateSelect);
|
||||
|
||||
const verifyHandlers: Record<string, QueryHandler> = {
|
||||
'verify.plan-structure': verifyPlanStructure,
|
||||
'verify.phase-completeness': verifyPhaseCompleteness,
|
||||
'verify.references': verifyReferences,
|
||||
'verify.commits': verifyCommits,
|
||||
'verify.artifacts': verifyArtifacts,
|
||||
'verify.key-links': verifyKeyLinks,
|
||||
'verify.schema-drift': verifySchemaDrift,
|
||||
'verify.codebase-drift': verifyCodebaseDrift,
|
||||
};
|
||||
|
||||
for (const entry of VERIFY_COMMAND_ALIASES) {
|
||||
const handler = verifyHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
registry.register('verify-summary', verifySummary);
|
||||
registry.register('verify.summary', verifySummary);
|
||||
registry.register('verify summary', verifySummary);
|
||||
registry.register('verify-path-exists', verifyPathExists);
|
||||
registry.register('verify.path-exists', verifyPathExists);
|
||||
registry.register('verify path-exists', verifyPathExists);
|
||||
|
||||
// Decision coverage gates (issue #2492)
|
||||
registry.register('decisions.parse', decisionsParse);
|
||||
registry.register('decisions parse', decisionsParse);
|
||||
registry.register('check.decision-coverage-plan', checkDecisionCoveragePlan);
|
||||
registry.register('check decision-coverage-plan', checkDecisionCoveragePlan);
|
||||
registry.register('check.decision-coverage-verify', checkDecisionCoverageVerify);
|
||||
registry.register('check decision-coverage-verify', checkDecisionCoverageVerify);
|
||||
const validateHandlers: Record<string, QueryHandler> = {
|
||||
'validate.consistency': validateConsistency,
|
||||
'validate.health': validateHealth,
|
||||
'validate.agents': validateAgents,
|
||||
'validate.context': validateContext,
|
||||
};
|
||||
|
||||
for (const entry of VALIDATE_COMMAND_ALIASES) {
|
||||
const handler = validateHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Decision routing (SDK-only — no `gsd-tools.cjs` mirror yet; see QUERY-HANDLERS.md)
|
||||
registry.register('check.config-gates', checkConfigGates);
|
||||
registry.register('check config-gates', checkConfigGates);
|
||||
registry.register('check.auto-mode', checkAutoMode);
|
||||
registry.register('check auto-mode', checkAutoMode);
|
||||
registry.register('check.phase-ready', checkPhaseReady);
|
||||
registry.register('check phase-ready', checkPhaseReady);
|
||||
registry.register('route.next-action', routeNextAction);
|
||||
registry.register('route next-action', routeNextAction);
|
||||
registry.register('detect.phase-type', detectPhaseType);
|
||||
registry.register('detect phase-type', detectPhaseType);
|
||||
registry.register('check.completion', checkCompletion);
|
||||
registry.register('check completion', checkCompletion);
|
||||
registry.register('check.gates', checkGates);
|
||||
registry.register('check gates', checkGates);
|
||||
registry.register('check.verification-status', checkVerificationStatus);
|
||||
registry.register('check verification-status', checkVerificationStatus);
|
||||
registry.register('check.ship-ready', checkShipReady);
|
||||
registry.register('check ship-ready', checkShipReady);
|
||||
|
||||
const phaseHandlers: Record<string, QueryHandler> = {
|
||||
'phase.list-plans': phaseListPlans,
|
||||
'phase.list-artifacts': phaseListArtifacts,
|
||||
'phase.add': phaseAdd,
|
||||
'phase.add-batch': phaseAddBatch,
|
||||
'phase.insert': phaseInsert,
|
||||
'phase.remove': phaseRemove,
|
||||
'phase.complete': phaseComplete,
|
||||
'phase.scaffold': phaseScaffold,
|
||||
'phase.next-decimal': phaseNextDecimal,
|
||||
};
|
||||
|
||||
for (const entry of PHASE_COMMAND_ALIASES) {
|
||||
const handler = phaseHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
const phasesHandlers: Record<string, QueryHandler> = {
|
||||
'phases.list': phasesList,
|
||||
'phases.clear': phasesClear,
|
||||
'phases.archive': phasesArchive,
|
||||
};
|
||||
|
||||
for (const entry of PHASES_COMMAND_ALIASES) {
|
||||
const handler = phasesHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
const initHandlers: Record<string, QueryHandler> = {
|
||||
'init.execute-phase': initExecutePhase,
|
||||
'init.plan-phase': initPlanPhase,
|
||||
'init.new-project': initNewProject,
|
||||
'init.new-milestone': initNewMilestone,
|
||||
'init.quick': initQuick,
|
||||
'init.ingest-docs': initIngestDocs,
|
||||
'init.resume': initResume,
|
||||
'init.verify-work': initVerifyWork,
|
||||
'init.phase-op': initPhaseOp,
|
||||
'init.todos': initTodos,
|
||||
'init.milestone-op': initMilestoneOp,
|
||||
'init.map-codebase': initMapCodebase,
|
||||
'init.progress': initProgress,
|
||||
'init.manager': initManager,
|
||||
'init.new-workspace': initNewWorkspace,
|
||||
'init.list-workspaces': initListWorkspaces,
|
||||
'init.remove-workspace': initRemoveWorkspace,
|
||||
};
|
||||
|
||||
for (const entry of INIT_COMMAND_ALIASES) {
|
||||
const handler = initHandlers[entry.canonical];
|
||||
if (!handler) continue;
|
||||
registry.register(entry.canonical, handler);
|
||||
for (const alias of entry.aliases) {
|
||||
registry.register(alias, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain-specific handlers (fully implemented)
|
||||
registry.register('agent-skills', agentSkills);
|
||||
registry.register('requirements.mark-complete', requirementsMarkComplete);
|
||||
registry.register('requirements mark-complete', requirementsMarkComplete);
|
||||
registry.register('todo.match-phase', todoMatchPhase);
|
||||
registry.register('todo match-phase', todoMatchPhase);
|
||||
registry.register('list-todos', listTodos);
|
||||
registry.register('list.todos', listTodos);
|
||||
registry.register('todo.complete', todoComplete);
|
||||
registry.register('todo complete', todoComplete);
|
||||
registry.register('milestone.complete', milestoneComplete);
|
||||
registry.register('milestone complete', milestoneComplete);
|
||||
registry.register('summary.extract', summaryExtract);
|
||||
registry.register('summary extract', summaryExtract);
|
||||
registry.register('summary-extract', summaryExtract);
|
||||
registry.register('history.digest', historyDigest);
|
||||
registry.register('history digest', historyDigest);
|
||||
registry.register('history-digest', historyDigest);
|
||||
registry.register('stats', statsJson);
|
||||
registry.register('stats.json', statsJson);
|
||||
registry.register('stats json', statsJson);
|
||||
registry.register('stats.table', statsTable);
|
||||
registry.register('stats table', statsTable);
|
||||
registry.register('commit-to-subrepo', commitToSubrepo);
|
||||
registry.register('progress.bar', progressBar);
|
||||
registry.register('progress bar', progressBar);
|
||||
registry.register('progress.table', progressTable);
|
||||
registry.register('progress table', progressTable);
|
||||
registry.register('workstream.get', workstreamGet);
|
||||
registry.register('workstream get', workstreamGet);
|
||||
registry.register('workstream.list', workstreamList);
|
||||
registry.register('workstream list', workstreamList);
|
||||
registry.register('workstream.create', workstreamCreate);
|
||||
registry.register('workstream create', workstreamCreate);
|
||||
registry.register('workstream.set', workstreamSet);
|
||||
registry.register('workstream set', workstreamSet);
|
||||
registry.register('workstream.status', workstreamStatus);
|
||||
registry.register('workstream status', workstreamStatus);
|
||||
registry.register('workstream.complete', workstreamComplete);
|
||||
registry.register('workstream complete', workstreamComplete);
|
||||
registry.register('workstream.progress', workstreamProgress);
|
||||
registry.register('workstream progress', workstreamProgress);
|
||||
registry.register('docs-init', docsInit);
|
||||
registry.register('websearch', websearch);
|
||||
registry.register('learnings.copy', learningsCopy);
|
||||
registry.register('learnings copy', learningsCopy);
|
||||
registry.register('learnings.query', learningsQuery);
|
||||
registry.register('learnings query', learningsQuery);
|
||||
registry.register('learnings.list', learningsListHandler);
|
||||
registry.register('learnings list', learningsListHandler);
|
||||
registry.register('learnings.prune', learningsPrune);
|
||||
registry.register('learnings prune', learningsPrune);
|
||||
registry.register('learnings.delete', learningsDelete);
|
||||
registry.register('learnings delete', learningsDelete);
|
||||
registry.register('skill-manifest', skillManifest);
|
||||
registry.register('skill manifest', skillManifest);
|
||||
registry.register('audit-open', auditOpen);
|
||||
registry.register('audit open', auditOpen);
|
||||
registry.register('detect-custom-files', detectCustomFiles);
|
||||
registry.register('extract-messages', extractMessages);
|
||||
registry.register('extract.messages', extractMessages);
|
||||
registry.register('audit-uat', auditUat);
|
||||
registry.register('uat.render-checkpoint', uatRenderCheckpoint);
|
||||
registry.register('uat render-checkpoint', uatRenderCheckpoint);
|
||||
registry.register('intel.diff', intelDiff);
|
||||
registry.register('intel diff', intelDiff);
|
||||
registry.register('intel.snapshot', intelSnapshot);
|
||||
registry.register('intel snapshot', intelSnapshot);
|
||||
registry.register('intel.validate', intelValidate);
|
||||
registry.register('intel validate', intelValidate);
|
||||
registry.register('intel.status', intelStatus);
|
||||
registry.register('intel status', intelStatus);
|
||||
registry.register('intel.query', intelQuery);
|
||||
registry.register('intel query', intelQuery);
|
||||
registry.register('intel.extract-exports', intelExtractExports);
|
||||
registry.register('intel extract-exports', intelExtractExports);
|
||||
registry.register('intel.patch-meta', intelPatchMeta);
|
||||
registry.register('intel patch-meta', intelPatchMeta);
|
||||
registry.register('intel.update', intelUpdate);
|
||||
registry.register('intel update', intelUpdate);
|
||||
registry.register('generate-claude-profile', generateClaudeProfile);
|
||||
registry.register('generate-dev-preferences', generateDevPreferences);
|
||||
registry.register('write-profile', writeProfile);
|
||||
registry.register('profile-questionnaire', profileQuestionnaire);
|
||||
registry.register('profile-sample', profileSample);
|
||||
registry.register('scan-sessions', scanSessions);
|
||||
registry.register('generate-claude-md', generateClaudeMd);
|
||||
|
||||
// Wire event emission for mutation commands
|
||||
if (eventStream) {
|
||||
for (const cmd of QUERY_MUTATION_COMMANDS) {
|
||||
const original = registry.getHandler(cmd);
|
||||
if (original) {
|
||||
registry.register(cmd, async (args: string[], projectDir: string) => {
|
||||
const result = await original(args, projectDir);
|
||||
try {
|
||||
const event = buildMutationEvent(mutationSessionId, cmd, args, result);
|
||||
eventStream.emitEvent(event);
|
||||
} catch {
|
||||
// T-11-12: Event emission is fire-and-forget; never block mutation success
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
45
sdk/src/query/mutation-event-decorator.test.ts
Normal file
45
sdk/src/query/mutation-event-decorator.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { QueryRegistry } from './registry.js';
|
||||
import { decorateMutationsWithEvents } from './mutation-event-decorator.js';
|
||||
|
||||
describe('decorateMutationsWithEvents', () => {
|
||||
it('wraps registered mutation handler and emits event', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
const eventStream = { emitEvent: vi.fn() } as unknown as import('../event-stream.js').GSDEventStream;
|
||||
|
||||
registry.register('template.fill', async () => ({ data: { template: 'phase', path: 'x', created: true } }));
|
||||
|
||||
decorateMutationsWithEvents(registry, new Set(['template.fill']), eventStream, 'sid-1');
|
||||
|
||||
const result = await registry.dispatch('template.fill', ['phase', 'x'], '/tmp');
|
||||
expect(result.data).toEqual({ template: 'phase', path: 'x', created: true });
|
||||
expect(eventStream.emitEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not throw when event emission fails (fire-and-forget)', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
const eventStream = {
|
||||
emitEvent: vi.fn(() => {
|
||||
throw new Error('stream down');
|
||||
}),
|
||||
} as unknown as import('../event-stream.js').GSDEventStream;
|
||||
|
||||
registry.register('state.update', async () => ({ data: { ok: true } }));
|
||||
|
||||
decorateMutationsWithEvents(registry, new Set(['state.update']), eventStream, 'sid-2');
|
||||
|
||||
const result = await registry.dispatch('state.update', ['k', 'v'], '/tmp');
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect(eventStream.emitEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips commands not registered in registry', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
const eventStream = { emitEvent: vi.fn() } as unknown as import('../event-stream.js').GSDEventStream;
|
||||
|
||||
decorateMutationsWithEvents(registry, new Set(['unknown.command']), eventStream, 'sid-3');
|
||||
|
||||
await expect(registry.dispatch('unknown.command', [], '/tmp')).rejects.toThrow('Unknown command');
|
||||
expect(eventStream.emitEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
37
sdk/src/query/mutation-event-decorator.ts
Normal file
37
sdk/src/query/mutation-event-decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import type { GSDEventStream } from '../event-stream.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { buildMutationEvent } from './mutation-event-mapper.js';
|
||||
|
||||
export function decorateMutationsWithEvents(
|
||||
registry: QueryRegistry,
|
||||
mutationCommands: Set<string>,
|
||||
eventStream: GSDEventStream,
|
||||
correlationSessionId: string,
|
||||
): void {
|
||||
for (const cmd of mutationCommands) {
|
||||
const original = registry.getHandler(cmd);
|
||||
if (!original) continue;
|
||||
registry.register(cmd, async (args: string[], projectDir: string, workstream?: string) => {
|
||||
const result = await original(args, projectDir, workstream);
|
||||
try {
|
||||
const event = buildMutationEvent(correlationSessionId, cmd, args, result);
|
||||
eventStream.emitEvent(event);
|
||||
} catch {
|
||||
// Event emission is fire-and-forget; never block mutation success
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function countDecoratedMutationHandlers(
|
||||
registry: QueryRegistry,
|
||||
mutationCommands: Set<string>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const cmd of mutationCommands) {
|
||||
if (registry.getHandler(cmd)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
33
sdk/src/query/mutation-event-mapper.test.ts
Normal file
33
sdk/src/query/mutation-event-mapper.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GSDEventType } from '../types.js';
|
||||
import { buildMutationEvent } from './mutation-event-mapper.js';
|
||||
|
||||
describe('buildMutationEvent', () => {
|
||||
const sid = 'corr-1';
|
||||
|
||||
it('maps template family', () => {
|
||||
const e = buildMutationEvent(sid, 'template.fill', ['project', '/tmp/x'], { data: { created: true } });
|
||||
expect(e.type).toBe(GSDEventType.TemplateFill);
|
||||
});
|
||||
|
||||
it('maps git family', () => {
|
||||
const e = buildMutationEvent(sid, 'commit', [], { data: { hash: 'abc', committed: true } });
|
||||
expect(e.type).toBe(GSDEventType.GitCommit);
|
||||
});
|
||||
|
||||
it('maps frontmatter family', () => {
|
||||
const e = buildMutationEvent(sid, 'frontmatter.set', ['file.md', 'k=v'], { data: null });
|
||||
expect(e.type).toBe(GSDEventType.FrontmatterMutation);
|
||||
});
|
||||
|
||||
it('maps config + validate to config mutation', () => {
|
||||
expect(buildMutationEvent(sid, 'config-set', ['x'], { data: null }).type).toBe(GSDEventType.ConfigMutation);
|
||||
expect(buildMutationEvent(sid, 'validate.context', ['x'], { data: null }).type).toBe(GSDEventType.ConfigMutation);
|
||||
});
|
||||
|
||||
it('maps phase/state/default to state mutation', () => {
|
||||
expect(buildMutationEvent(sid, 'phase.add', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
|
||||
expect(buildMutationEvent(sid, 'state.update', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
|
||||
expect(buildMutationEvent(sid, 'roadmap.update-plan-progress', ['x'], { data: null }).type).toBe(GSDEventType.StateMutation);
|
||||
});
|
||||
});
|
||||
102
sdk/src/query/mutation-event-mapper.ts
Normal file
102
sdk/src/query/mutation-event-mapper.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
GSDEventType,
|
||||
type GSDEvent,
|
||||
type GSDStateMutationEvent,
|
||||
type GSDConfigMutationEvent,
|
||||
type GSDFrontmatterMutationEvent,
|
||||
type GSDGitCommitEvent,
|
||||
type GSDTemplateFillEvent,
|
||||
} from '../types.js';
|
||||
import type { QueryResult } from './utils.js';
|
||||
|
||||
interface EventBase {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type EventFamily =
|
||||
| 'template'
|
||||
| 'git'
|
||||
| 'frontmatter'
|
||||
| 'config'
|
||||
| 'validate'
|
||||
| 'phase'
|
||||
| 'state'
|
||||
| 'default';
|
||||
|
||||
const FAMILY_RULES: Array<{ family: EventFamily; matches: (cmd: string) => boolean }> = [
|
||||
{ family: 'template', matches: (cmd) => cmd.startsWith('template.') || cmd.startsWith('template ') },
|
||||
{ family: 'git', matches: (cmd) => cmd === 'commit' || cmd === 'check-commit' || cmd === 'commit-to-subrepo' },
|
||||
{ family: 'frontmatter', matches: (cmd) => cmd.startsWith('frontmatter.') || cmd.startsWith('frontmatter ') },
|
||||
{ family: 'config', matches: (cmd) => cmd.startsWith('config-') },
|
||||
{ family: 'validate', matches: (cmd) => cmd.startsWith('validate.') || cmd.startsWith('validate ') },
|
||||
{ family: 'phase', matches: (cmd) => cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ') },
|
||||
{ family: 'state', matches: (cmd) => cmd.startsWith('state.') || cmd.startsWith('state ') },
|
||||
];
|
||||
|
||||
function resolveFamily(cmd: string): EventFamily {
|
||||
return FAMILY_RULES.find((rule) => rule.matches(cmd))?.family ?? 'default';
|
||||
}
|
||||
|
||||
export function buildMutationEvent(
|
||||
correlationSessionId: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
result: QueryResult,
|
||||
): GSDEvent {
|
||||
const base: EventBase = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: correlationSessionId,
|
||||
};
|
||||
|
||||
switch (resolveFamily(cmd)) {
|
||||
case 'template': {
|
||||
const data = result.data as Record<string, unknown> | null;
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.TemplateFill,
|
||||
templateType: (data?.template as string) ?? args[0] ?? '',
|
||||
path: (data?.path as string) ?? args[1] ?? '',
|
||||
created: (data?.created as boolean) ?? false,
|
||||
} as GSDTemplateFillEvent;
|
||||
}
|
||||
case 'git': {
|
||||
const data = result.data as Record<string, unknown> | null;
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.GitCommit,
|
||||
hash: (data?.hash as string) ?? null,
|
||||
committed: (data?.committed as boolean) ?? false,
|
||||
reason: (data?.reason as string) ?? '',
|
||||
} as GSDGitCommitEvent;
|
||||
}
|
||||
case 'frontmatter':
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.FrontmatterMutation,
|
||||
command: cmd,
|
||||
file: args[0] ?? '',
|
||||
fields: args.slice(1),
|
||||
success: true,
|
||||
} as GSDFrontmatterMutationEvent;
|
||||
case 'config':
|
||||
case 'validate':
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.ConfigMutation,
|
||||
command: cmd,
|
||||
key: args[0] ?? '',
|
||||
success: true,
|
||||
} as GSDConfigMutationEvent;
|
||||
case 'phase':
|
||||
case 'state':
|
||||
case 'default':
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.StateMutation,
|
||||
command: cmd,
|
||||
fields: args.slice(0, 2),
|
||||
success: true,
|
||||
} as GSDStateMutationEvent;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1 @@
|
||||
/**
|
||||
* Normalize `gsd-sdk query <argv...>` command tokens to match `createRegistry()` keys.
|
||||
*
|
||||
* `gsd-tools` takes a top-level command plus a subcommand (`state json`, `init execute-phase 9`).
|
||||
* The SDK CLI originally passed only argv[0] as the registry key, so `query state json` dispatched
|
||||
* `state` (unknown) instead of `state.json`. This module merges the same prefixes gsd-tools nests
|
||||
* under `runCommand()` so two-token (and longer) invocations resolve to dotted registry names.
|
||||
*/
|
||||
|
||||
import {
|
||||
STATE_SUBCOMMANDS,
|
||||
VERIFY_SUBCOMMANDS,
|
||||
INIT_SUBCOMMANDS,
|
||||
PHASE_SUBCOMMANDS,
|
||||
PHASES_SUBCOMMANDS,
|
||||
VALIDATE_SUBCOMMANDS,
|
||||
ROADMAP_SUBCOMMANDS,
|
||||
} from './command-aliases.generated.js';
|
||||
|
||||
const MERGE_FIRST_WITH_SUBCOMMAND = new Set<string>([
|
||||
'state',
|
||||
'template',
|
||||
'frontmatter',
|
||||
'verify',
|
||||
'phase',
|
||||
'requirements',
|
||||
'init',
|
||||
'workstream',
|
||||
'intel',
|
||||
'learnings',
|
||||
'uat',
|
||||
'todo',
|
||||
'milestone',
|
||||
'check',
|
||||
'detect',
|
||||
'route',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param command - First token after `query` (e.g. `state`, `init`, `config-get`)
|
||||
* @param args - Remaining tokens (flags like `--pick` should already be stripped)
|
||||
* @returns Registry command string and handler args
|
||||
*/
|
||||
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) {
|
||||
const sub = args[0];
|
||||
return [`${command}.${sub}`, args.slice(1)];
|
||||
}
|
||||
|
||||
if ((command === 'progress' || command === 'stats') && args.length > 0) {
|
||||
return [`${command}.${args[0]}`, args.slice(1)];
|
||||
}
|
||||
|
||||
return [command, args];
|
||||
}
|
||||
export { normalizeQueryCommand } from './query-command-semantics.js';
|
||||
|
||||
27
sdk/src/query/policy-convergence.test.ts
Normal file
27
sdk/src/query/policy-convergence.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCommand } from './policy-convergence.js';
|
||||
|
||||
describe('policy convergence', () => {
|
||||
it('contains expected raw transport aliases', () => {
|
||||
expect(TRANSPORT_RAW_COMMANDS).toEqual([
|
||||
'commit',
|
||||
'config-set',
|
||||
'verify-summary',
|
||||
'verify.summary',
|
||||
'verify summary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('contains key mutation commands and aliases', () => {
|
||||
expect(QUERY_MUTATION_COMMAND_LIST).toContain('state.update');
|
||||
expect(QUERY_MUTATION_COMMAND_LIST).toContain('phase complete');
|
||||
expect(QUERY_MUTATION_COMMAND_LIST).toContain('roadmap.update-plan-progress');
|
||||
expect(QUERY_MUTATION_COMMAND_LIST).toContain('workstream.progress');
|
||||
expect(QUERY_MUTATION_COMMAND_LIST).toContain('learnings prune');
|
||||
});
|
||||
|
||||
it('classifies mutation commands via semantic helper', () => {
|
||||
expect(isQueryMutationCommand('state.update')).toBe(true);
|
||||
expect(isQueryMutationCommand('state.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
5
sdk/src/query/policy-convergence.ts
Normal file
5
sdk/src/query/policy-convergence.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
QUERY_MUTATION_COMMAND_LIST,
|
||||
TRANSPORT_RAW_COMMANDS,
|
||||
isQueryMutationCommand,
|
||||
} from './query-command-semantics.js';
|
||||
214
sdk/src/query/query-command-semantics.ts
Normal file
214
sdk/src/query/query-command-semantics.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
10
sdk/src/query/query-dispatch-contract.ts
Normal file
10
sdk/src/query/query-dispatch-contract.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface QueryDispatchError {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QueryDispatchResult {
|
||||
stdout?: string;
|
||||
stderr: string[];
|
||||
error?: QueryDispatchError;
|
||||
}
|
||||
99
sdk/src/query/query-dispatch.test.ts
Normal file
99
sdk/src/query/query-dispatch.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRegistry } from './index.js';
|
||||
import { runQueryDispatch } from './query-dispatch.js';
|
||||
|
||||
describe('runQueryDispatch', () => {
|
||||
let tmpDir: string;
|
||||
let fixtureDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `query-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
fixtureDir = join(tmpDir, 'fixtures');
|
||||
await mkdir(fixtureDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createScript(name: string, code: string): Promise<string> {
|
||||
const scriptPath = join(fixtureDir, name);
|
||||
await writeFile(scriptPath, code, { mode: 0o755 });
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
it('runs native dispatch and formats json', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: { ok: true } }),
|
||||
}, ['state', 'json']);
|
||||
|
||||
expect(out.error).toBeUndefined();
|
||||
expect(out.stdout).toBe('{\n "ok": true\n}\n');
|
||||
});
|
||||
|
||||
it('applies --pick to native json output', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: { nested: { value: 7 } } }),
|
||||
}, ['state', 'json', '--pick', 'nested.value']);
|
||||
|
||||
expect(out.error).toBeUndefined();
|
||||
expect(out.stdout).toBe('7\n');
|
||||
});
|
||||
|
||||
it('returns structured error for unknown command when fallback disabled', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: false,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
}, ['unknown-cmd']);
|
||||
|
||||
expect(out.error?.code).toBe(10);
|
||||
expect(out.error?.message).toContain('Unknown command: "unknown-cmd"');
|
||||
expect(out.error?.message).toContain('Attempted dotted:');
|
||||
});
|
||||
|
||||
it('runs cjs fallback and formats text mode', async () => {
|
||||
const script = await createScript('text.cjs', "process.stdout.write('USAGE: help text');");
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => script,
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
}, ['unknown-cmd', '--help']);
|
||||
|
||||
expect(out.error).toBeUndefined();
|
||||
expect(out.stdout).toBe('USAGE: help text\n');
|
||||
expect(out.stderr[0]).toContain('falling back to gsd-tools.cjs');
|
||||
});
|
||||
|
||||
it('returns requires-command error for empty argv', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => ({ data: {} }),
|
||||
}, []);
|
||||
expect(out.error?.code).toBe(10);
|
||||
expect(out.error?.message).toContain('requires a command');
|
||||
});
|
||||
});
|
||||
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}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
72
sdk/src/query/query-fallback-executor.test.ts
Normal file
72
sdk/src/query/query-fallback-executor.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runCjsFallbackDispatch } from './query-fallback-executor.js';
|
||||
|
||||
describe('runCjsFallbackDispatch', () => {
|
||||
let tmpDir: string;
|
||||
let fixtureDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `fallback-exec-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
fixtureDir = join(tmpDir, 'fixtures');
|
||||
await mkdir(fixtureDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createScript(name: string, code: string): Promise<string> {
|
||||
const scriptPath = join(fixtureDir, name);
|
||||
await writeFile(scriptPath, code, { mode: 0o755 });
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
it('returns json output', async () => {
|
||||
const script = await createScript('json.cjs', "process.stdout.write(JSON.stringify({ok:true}));");
|
||||
const result = await runCjsFallbackDispatch({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: script,
|
||||
normCmd: 'state',
|
||||
normArgs: ['load'],
|
||||
});
|
||||
expect(result.stdout).toBe('{\n "ok": true\n}\n');
|
||||
});
|
||||
|
||||
it('returns text output with trailing newline', async () => {
|
||||
const script = await createScript('text.cjs', "process.stdout.write('USAGE: help text');");
|
||||
const result = await runCjsFallbackDispatch({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: script,
|
||||
normCmd: 'phase',
|
||||
normArgs: ['add', '--help'],
|
||||
});
|
||||
expect(result.stdout).toBe('USAGE: help text\n');
|
||||
});
|
||||
|
||||
it('passes ws flag to cjs command', async () => {
|
||||
const script = await createScript('ws.cjs', "const args=process.argv.slice(2); process.stdout.write(JSON.stringify({args}));");
|
||||
const result = await runCjsFallbackDispatch({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: script,
|
||||
normCmd: 'state',
|
||||
normArgs: ['load'],
|
||||
ws: 'ws-1',
|
||||
pickField: 'args',
|
||||
});
|
||||
expect(result.stdout).toBe('[\n "state",\n "load",\n "--ws",\n "ws-1"\n]\n');
|
||||
});
|
||||
|
||||
it('returns structured error when subprocess fails', async () => {
|
||||
const result = await runCjsFallbackDispatch({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: join(fixtureDir, 'missing.cjs'),
|
||||
normCmd: 'state',
|
||||
normArgs: ['load'],
|
||||
});
|
||||
expect(result.error?.code).toBe(1);
|
||||
expect(result.error?.message).toContain('fallback failed');
|
||||
});
|
||||
});
|
||||
112
sdk/src/query/query-fallback-executor.ts
Normal file
112
sdk/src/query/query-fallback-executor.ts
Normal 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}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
88
sdk/src/query/registry-assembly-invariants.ts
Normal file
88
sdk/src/query/registry-assembly-invariants.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { QueryRegistry } from './registry.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import type { AliasCatalogEntry } from './command-catalog.js';
|
||||
|
||||
export interface RegistryAssemblyAliasGroup {
|
||||
family: string;
|
||||
aliases: readonly AliasCatalogEntry[];
|
||||
handlers: Readonly<Record<string, QueryHandler>>;
|
||||
}
|
||||
|
||||
export interface RegistryAssemblyStaticGroup {
|
||||
name: string;
|
||||
entries: ReadonlyArray<readonly [command: string, handler: QueryHandler]>;
|
||||
}
|
||||
|
||||
export interface RegistryAssemblyInputs {
|
||||
staticGroups: readonly RegistryAssemblyStaticGroup[];
|
||||
aliasGroups: readonly RegistryAssemblyAliasGroup[];
|
||||
mutationCommands: ReadonlySet<string>;
|
||||
rawOutputPolicyCommands: readonly string[];
|
||||
}
|
||||
|
||||
function toSortedList(values: Iterable<string>): string[] {
|
||||
return Array.from(values).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInputs): void {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const group of inputs.staticGroups) {
|
||||
for (const [command] of group.entries) {
|
||||
counts.set(command, (counts.get(command) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of inputs.aliasGroups) {
|
||||
for (const entry of group.aliases) {
|
||||
counts.set(entry.canonical, (counts.get(entry.canonical) ?? 0) + 1);
|
||||
for (const alias of entry.aliases) {
|
||||
counts.set(alias, (counts.get(alias) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = toSortedList(
|
||||
Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([command]) => command),
|
||||
);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`registry assembly invariant failed: duplicate command keys: ${duplicates.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAliasCanonicalsHaveHandlers(inputs: RegistryAssemblyInputs): void {
|
||||
const missing: string[] = [];
|
||||
for (const group of inputs.aliasGroups) {
|
||||
for (const entry of group.aliases) {
|
||||
if (!group.handlers[entry.canonical]) {
|
||||
missing.push(`${group.family}:${entry.canonical}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`registry assembly invariant failed: alias canonical missing handler: ${toSortedList(missing).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMutationCommandsRegistered(
|
||||
registry: QueryRegistry,
|
||||
mutationCommands: ReadonlySet<string>,
|
||||
): void {
|
||||
const missing = toSortedList(Array.from(mutationCommands).filter((command) => !registry.has(command)));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`registry assembly invariant failed: mutation command missing from registry: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRawOutputPolicyCommandsRegistered(
|
||||
registry: QueryRegistry,
|
||||
rawOutputPolicyCommands: readonly string[],
|
||||
): void {
|
||||
const missing = toSortedList(rawOutputPolicyCommands.filter((command) => !registry.has(command)));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`registry assembly invariant failed: raw-output policy command missing from registry: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
109
sdk/src/query/registry-assembly.test.ts
Normal file
109
sdk/src/query/registry-assembly.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { QueryRegistry } from './registry.js';
|
||||
import {
|
||||
buildRegistry,
|
||||
createRegistry,
|
||||
decorateRegistryMutations,
|
||||
QUERY_MUTATION_COMMANDS,
|
||||
} from './registry-assembly.js';
|
||||
import {
|
||||
assertAliasCanonicalsHaveHandlers,
|
||||
assertMutationCommandsRegistered,
|
||||
assertNoDuplicateRegisteredCommands,
|
||||
assertRawOutputPolicyCommandsRegistered,
|
||||
type RegistryAssemblyAliasGroup,
|
||||
type RegistryAssemblyStaticGroup,
|
||||
} from './registry-assembly-invariants.js';
|
||||
|
||||
const noop = async () => ({ data: null });
|
||||
|
||||
describe('registry assembly', () => {
|
||||
it('buildRegistry returns registered registry', () => {
|
||||
const registry = buildRegistry();
|
||||
expect(registry.has('state.load')).toBe(true);
|
||||
expect(registry.has('verify-summary')).toBe(true);
|
||||
expect(registry.has('verify.summary')).toBe(true);
|
||||
expect(registry.has('verify summary')).toBe(true);
|
||||
});
|
||||
|
||||
it('createRegistry keeps public seam', () => {
|
||||
const registry = createRegistry();
|
||||
expect(registry).toBeInstanceOf(QueryRegistry);
|
||||
expect(registry.commands().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('decorateRegistryMutations is no-op without event stream', () => {
|
||||
const registry = buildRegistry();
|
||||
expect(() => decorateRegistryMutations(registry, undefined, 's')).not.toThrow();
|
||||
});
|
||||
|
||||
it('QUERY_MUTATION_COMMANDS entries are present in registry', () => {
|
||||
const registry = buildRegistry();
|
||||
for (const command of QUERY_MUTATION_COMMANDS) {
|
||||
expect(registry.has(command), `missing mutation command: ${command}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry assembly invariants', () => {
|
||||
const staticGroups: RegistryAssemblyStaticGroup[] = [
|
||||
{ name: 'S1', entries: [['one', noop]] },
|
||||
];
|
||||
const aliasGroups: RegistryAssemblyAliasGroup[] = [
|
||||
{ family: 'f', aliases: [{ canonical: 'canon', aliases: ['alias'] }], handlers: { canon: noop } },
|
||||
];
|
||||
|
||||
it('fails on duplicate command keys', () => {
|
||||
expect(() => assertNoDuplicateRegisteredCommands({
|
||||
staticGroups: [
|
||||
{ name: 'S1', entries: [['dup', noop]] },
|
||||
{ name: 'S2', entries: [['dup', noop]] },
|
||||
],
|
||||
aliasGroups: [],
|
||||
mutationCommands: new Set<string>(),
|
||||
rawOutputPolicyCommands: [],
|
||||
})).toThrow(/duplicate command keys/i);
|
||||
});
|
||||
|
||||
it('fails on alias canonical without handler', () => {
|
||||
expect(() => assertAliasCanonicalsHaveHandlers({
|
||||
staticGroups: [],
|
||||
aliasGroups: [
|
||||
{ family: 'f', aliases: [{ canonical: 'missing', aliases: [] }], handlers: {} },
|
||||
],
|
||||
mutationCommands: new Set<string>(),
|
||||
rawOutputPolicyCommands: [],
|
||||
})).toThrow(/alias canonical missing handler/i);
|
||||
});
|
||||
|
||||
it('fails when mutation command missing from registry', () => {
|
||||
const registry = new QueryRegistry();
|
||||
expect(() => assertMutationCommandsRegistered(registry, new Set(['missing.cmd']))).toThrow(/mutation command missing from registry/i);
|
||||
});
|
||||
|
||||
it('fails when raw-output policy command missing from registry', () => {
|
||||
const registry = new QueryRegistry();
|
||||
expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['verify-summary'])).toThrow(/raw-output policy command missing from registry/i);
|
||||
});
|
||||
|
||||
it('passes happy path', () => {
|
||||
const registry = new QueryRegistry();
|
||||
registry.register('one', noop);
|
||||
registry.register('canon', noop);
|
||||
registry.register('alias', noop);
|
||||
expect(() => assertNoDuplicateRegisteredCommands({
|
||||
staticGroups,
|
||||
aliasGroups,
|
||||
mutationCommands: new Set<string>(),
|
||||
rawOutputPolicyCommands: [],
|
||||
})).not.toThrow();
|
||||
expect(() => assertAliasCanonicalsHaveHandlers({
|
||||
staticGroups,
|
||||
aliasGroups,
|
||||
mutationCommands: new Set<string>(),
|
||||
rawOutputPolicyCommands: [],
|
||||
})).not.toThrow();
|
||||
expect(() => assertMutationCommandsRegistered(registry, new Set(['one']))).not.toThrow();
|
||||
expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['canon'])).not.toThrow();
|
||||
});
|
||||
});
|
||||
120
sdk/src/query/registry-assembly.ts
Normal file
120
sdk/src/query/registry-assembly.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { QueryRegistry } from './registry.js';
|
||||
import {
|
||||
STATE_COMMAND_ALIASES,
|
||||
VERIFY_COMMAND_ALIASES,
|
||||
INIT_COMMAND_ALIASES,
|
||||
PHASE_COMMAND_ALIASES,
|
||||
PHASES_COMMAND_ALIASES,
|
||||
VALIDATE_COMMAND_ALIASES,
|
||||
ROADMAP_COMMAND_ALIASES,
|
||||
} from './command-aliases.generated.js';
|
||||
import { GSDEventStream } from '../event-stream.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import { registerAliasCatalog, registerStaticCatalog } from './command-catalog.js';
|
||||
import {
|
||||
FOUNDATION_STATIC_CATALOG,
|
||||
STATE_SUPPORT_STATIC_CATALOG,
|
||||
MUTATION_SURFACES_STATIC_CATALOG,
|
||||
VERIFY_DECISION_STATIC_CATALOG,
|
||||
DECISION_ROUTING_STATIC_CATALOG,
|
||||
} from './command-static-catalog-foundation.js';
|
||||
import { DOMAIN_STATIC_CATALOG } from './command-static-catalog-domain.js';
|
||||
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './policy-convergence.js';
|
||||
import { decorateMutationsWithEvents } from './mutation-event-decorator.js';
|
||||
import { FAMILY_HANDLERS } from './command-family-handlers.js';
|
||||
import {
|
||||
assertAliasCanonicalsHaveHandlers,
|
||||
assertMutationCommandsRegistered,
|
||||
assertNoDuplicateRegisteredCommands,
|
||||
assertRawOutputPolicyCommandsRegistered,
|
||||
type RegistryAssemblyAliasGroup,
|
||||
type RegistryAssemblyStaticGroup,
|
||||
} from './registry-assembly-invariants.js';
|
||||
|
||||
/**
|
||||
* Command names that perform durable writes (disk, git, or global profile store).
|
||||
*/
|
||||
export const QUERY_MUTATION_COMMANDS = new Set<string>(QUERY_MUTATION_COMMAND_LIST);
|
||||
|
||||
const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [
|
||||
{ name: 'FOUNDATION_STATIC_CATALOG', entries: FOUNDATION_STATIC_CATALOG },
|
||||
{ name: 'STATE_SUPPORT_STATIC_CATALOG', entries: STATE_SUPPORT_STATIC_CATALOG },
|
||||
{ name: 'MUTATION_SURFACES_STATIC_CATALOG', entries: MUTATION_SURFACES_STATIC_CATALOG },
|
||||
{ name: 'VERIFY_DECISION_STATIC_CATALOG', entries: VERIFY_DECISION_STATIC_CATALOG },
|
||||
{ name: 'DECISION_ROUTING_STATIC_CATALOG', entries: DECISION_ROUTING_STATIC_CATALOG },
|
||||
{ name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG },
|
||||
] as const;
|
||||
|
||||
const ALIAS_GROUPS: readonly RegistryAssemblyAliasGroup[] = [
|
||||
{ family: 'state', aliases: STATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.state as Record<string, QueryHandler> },
|
||||
{ family: 'roadmap', aliases: ROADMAP_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.roadmap as Record<string, QueryHandler> },
|
||||
{ family: 'verify', aliases: VERIFY_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.verify as Record<string, QueryHandler> },
|
||||
{ family: 'validate', aliases: VALIDATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.validate as Record<string, QueryHandler> },
|
||||
{ family: 'phase', aliases: PHASE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phase as Record<string, QueryHandler> },
|
||||
{ family: 'phases', aliases: PHASES_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phases as Record<string, QueryHandler> },
|
||||
{ family: 'init', aliases: INIT_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.init as Record<string, QueryHandler> },
|
||||
] as const;
|
||||
|
||||
export function buildRegistry(): QueryRegistry {
|
||||
assertAliasCanonicalsHaveHandlers({
|
||||
staticGroups: STATIC_CATALOG_GROUPS,
|
||||
aliasGroups: ALIAS_GROUPS,
|
||||
mutationCommands: QUERY_MUTATION_COMMANDS,
|
||||
rawOutputPolicyCommands: TRANSPORT_RAW_COMMANDS,
|
||||
});
|
||||
assertNoDuplicateRegisteredCommands({
|
||||
staticGroups: STATIC_CATALOG_GROUPS,
|
||||
aliasGroups: ALIAS_GROUPS,
|
||||
mutationCommands: QUERY_MUTATION_COMMANDS,
|
||||
rawOutputPolicyCommands: TRANSPORT_RAW_COMMANDS,
|
||||
});
|
||||
|
||||
const registry = new QueryRegistry();
|
||||
|
||||
registerStaticCatalog(registry, FOUNDATION_STATIC_CATALOG);
|
||||
registerAliasCatalog(registry, STATE_COMMAND_ALIASES, FAMILY_HANDLERS.state as Record<string, QueryHandler>);
|
||||
|
||||
registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG);
|
||||
registerAliasCatalog(registry, ROADMAP_COMMAND_ALIASES, FAMILY_HANDLERS.roadmap as Record<string, QueryHandler>);
|
||||
|
||||
registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG);
|
||||
|
||||
registerAliasCatalog(registry, VERIFY_COMMAND_ALIASES, FAMILY_HANDLERS.verify as Record<string, QueryHandler>);
|
||||
|
||||
registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG);
|
||||
registerAliasCatalog(registry, VALIDATE_COMMAND_ALIASES, FAMILY_HANDLERS.validate as Record<string, QueryHandler>);
|
||||
|
||||
registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG);
|
||||
|
||||
registerAliasCatalog(registry, PHASE_COMMAND_ALIASES, FAMILY_HANDLERS.phase as Record<string, QueryHandler>);
|
||||
|
||||
registerAliasCatalog(registry, PHASES_COMMAND_ALIASES, FAMILY_HANDLERS.phases as Record<string, QueryHandler>);
|
||||
|
||||
registerAliasCatalog(registry, INIT_COMMAND_ALIASES, FAMILY_HANDLERS.init as Record<string, QueryHandler>);
|
||||
|
||||
registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG);
|
||||
|
||||
assertMutationCommandsRegistered(registry, QUERY_MUTATION_COMMANDS);
|
||||
assertRawOutputPolicyCommandsRegistered(registry, TRANSPORT_RAW_COMMANDS);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function decorateRegistryMutations(
|
||||
registry: QueryRegistry,
|
||||
eventStream?: GSDEventStream,
|
||||
correlationSessionId?: string,
|
||||
): void {
|
||||
if (!eventStream) return;
|
||||
const mutationSessionId = correlationSessionId ?? '';
|
||||
decorateMutationsWithEvents(registry, QUERY_MUTATION_COMMANDS, eventStream, mutationSessionId);
|
||||
}
|
||||
|
||||
export function createRegistry(
|
||||
eventStream?: GSDEventStream,
|
||||
correlationSessionId?: string,
|
||||
): QueryRegistry {
|
||||
const registry = buildRegistry();
|
||||
decorateRegistryMutations(registry, eventStream, correlationSessionId);
|
||||
return registry;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ describe('QueryRegistry', () => {
|
||||
|
||||
const result = await registry.dispatch('test-cmd', ['arg1'], '/tmp');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp');
|
||||
expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp', undefined);
|
||||
expect(result).toEqual({ data: { value: 'arg1' } });
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import type { QueryResult, QueryHandler } from './utils.js';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { resolveQueryTokens } from './command-resolution.js';
|
||||
|
||||
// ─── extractField ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,48 +127,6 @@ export class QueryRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the first token contains a dot (e.g. `init.execute-phase`), split it into
|
||||
* segments and prepend those segments in place of the original token. Args that
|
||||
* follow the dotted token are preserved.
|
||||
*
|
||||
* Examples:
|
||||
* ['init.new-project'] -> ['init', 'new-project']
|
||||
* ['init.execute-phase', '1'] -> ['init', 'execute-phase', '1']
|
||||
* ['state.update', 'status', 'X'] -> ['state', 'update', 'status', 'X']
|
||||
*
|
||||
* Returns the original array (by reference) when no expansion applies so callers
|
||||
* can detect "nothing changed" via identity comparison.
|
||||
*/
|
||||
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: QueryRegistry,
|
||||
): { cmd: string; args: string[] } | null {
|
||||
for (let i = tokens.length; i >= 1; i--) {
|
||||
const head = tokens.slice(0, i);
|
||||
const dotted = head.join('.');
|
||||
const spaced = head.join(' ');
|
||||
if (registry.has(dotted)) {
|
||||
return { cmd: dotted, args: tokens.slice(i) };
|
||||
}
|
||||
if (registry.has(spaced)) {
|
||||
return { cmd: spaced, args: tokens.slice(i) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map argv after `gsd-sdk query` to a registered handler key and remaining args.
|
||||
* Longest-prefix match on dotted (`a.b.c`) and spaced (`a b c`) keys; if no match,
|
||||
@@ -177,12 +136,7 @@ export function resolveQueryArgv(
|
||||
tokens: string[],
|
||||
registry: QueryRegistry,
|
||||
): { cmd: string; args: string[] } | null {
|
||||
let matched = matchRegisteredPrefix(tokens, registry);
|
||||
if (!matched) {
|
||||
const expanded = expandFirstDottedToken(tokens);
|
||||
if (expanded !== tokens) {
|
||||
matched = matchRegisteredPrefix(expanded, registry);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
const resolved = resolveQueryTokens(tokens, registry);
|
||||
if (!resolved) return null;
|
||||
return { cmd: resolved.cmd, args: resolved.args };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'co
|
||||
// #2653 — allowlist moved to shared schema module.
|
||||
const CONFIG_SCHEMA_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-schema.ts');
|
||||
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
|
||||
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts');
|
||||
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly.ts');
|
||||
|
||||
describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
|
||||
@@ -168,10 +168,12 @@ describe('SDK wiring for #2492 gates', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('query index.ts registers the new handlers', () => {
|
||||
test('query registry assembly registers the new handlers', () => {
|
||||
const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8');
|
||||
assert.ok(c.includes('check.decision-coverage-plan'), 'check.decision-coverage-plan handler must be registered');
|
||||
assert.ok(c.includes('check.decision-coverage-verify'), 'check.decision-coverage-verify handler must be registered');
|
||||
assert.ok(c.includes('decisions.parse'), 'decisions.parse handler must be registered');
|
||||
// Handlers are now registered through catalog-based registration.
|
||||
assert.ok(c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decision-coverage-plan handler must be registered via the verify-decision catalog');
|
||||
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-plan handler must be registered');
|
||||
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-verify handler must be registered');
|
||||
assert.ok(c.includes('decisions.parse') || c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decisions.parse handler must be registered');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,8 +89,9 @@ describe('QueryRegistry.dispatch() workstream threading', () => {
|
||||
|
||||
describe('CLI forwards --ws to registry.dispatch()', () => {
|
||||
test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => {
|
||||
// The CLI now uses a dispatchNative callback pattern.
|
||||
assert.ok(
|
||||
cliTs.includes('registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws)'),
|
||||
cliTs.includes('dispatchNative') && cliTs.includes('args.ws'),
|
||||
'cli.ts must forward args.ws to registry.dispatch() as the workstream argument',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Drift guard: every `gsd-sdk query <cmd>` reference in the repo must
|
||||
* resolve to a handler registered in sdk/src/query/index.ts.
|
||||
* resolve to a handler registered in sdk/src/query/registry-assembly.ts.
|
||||
*
|
||||
* The set of commands workflows/agents/commands call must equal the set
|
||||
* the SDK registry exposes. New references with no handler — or handlers
|
||||
@@ -13,7 +13,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'index.ts');
|
||||
const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'registry-assembly.ts');
|
||||
const COMMAND_ALIASES_FILE = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
|
||||
|
||||
// Prose tokens that repeatedly appear after `gsd-sdk query` in English
|
||||
@@ -43,11 +43,43 @@ function collectRegisteredNames() {
|
||||
const src = fs.readFileSync(REGISTRY_FILE, 'utf8');
|
||||
const names = new Set();
|
||||
|
||||
// Static registrations in index.ts
|
||||
// Static registrations in index.ts (legacy style, may still exist)
|
||||
const re = /registry\.register\(\s*['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) names.add(m[1]);
|
||||
|
||||
// Catalog-based registrations: extract handler names from registerStaticCatalog calls.
|
||||
const catalogRe = /registerStaticCatalog\(registry, (\w+)\)/g;
|
||||
let cm;
|
||||
while ((cm = catalogRe.exec(src)) !== null) {
|
||||
const catalogVarName = cm[1];
|
||||
// Map variable names to their source files.
|
||||
const catalogFileByVar = {
|
||||
FOUNDATION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
STATE_SUPPORT_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
MUTATION_SURFACES_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
VERIFY_DECISION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DECISION_ROUTING_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DOMAIN_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-domain.ts'),
|
||||
};
|
||||
const cf = catalogFileByVar[catalogVarName];
|
||||
if (!cf) continue;
|
||||
try {
|
||||
const catSrc = fs.readFileSync(cf, 'utf8');
|
||||
// Only extract names from the catalog matching this variable.
|
||||
const exportRe = new RegExp(`export const ${catalogVarName}:`, 'm');
|
||||
if (!exportRe.test(catSrc)) continue;
|
||||
// Match: [[name, handler], ...]
|
||||
const entryRe = /\[\s*['"]([^'"]+)['"]/g;
|
||||
let em;
|
||||
while ((em = entryRe.exec(catSrc)) !== null) {
|
||||
names.add(em[1]);
|
||||
}
|
||||
} catch {
|
||||
// File not found, skip.
|
||||
}
|
||||
}
|
||||
|
||||
// Manifest-generated family aliases registered via loop in index.ts.
|
||||
// Keep this in sync with command-manifest-driven routing seams.
|
||||
try {
|
||||
@@ -159,7 +191,7 @@ describe('gsd-sdk query registry integration', () => {
|
||||
assert.strictEqual(
|
||||
offenders.length, 0,
|
||||
'Referenced `gsd-sdk query <cmd>` tokens with no handler in ' +
|
||||
'sdk/src/query/index.ts. Either register the handler or remove ' +
|
||||
'sdk/src/query/registry-assembly.ts. Either register the handler or remove ' +
|
||||
'the reference.\n\n' + offenders.join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user