diff --git a/.changeset/happy-tigers-travel.md b/.changeset/happy-tigers-travel.md new file mode 100644 index 000000000..358ddb525 --- /dev/null +++ b/.changeset/happy-tigers-travel.md @@ -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. diff --git a/.changeset/humble-goats-swim.md b/.changeset/humble-goats-swim.md new file mode 100644 index 000000000..a48552146 --- /dev/null +++ b/.changeset/humble-goats-swim.md @@ -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. diff --git a/.changeset/merry-moles-chatter.md b/.changeset/merry-moles-chatter.md new file mode 100644 index 000000000..049555bf1 --- /dev/null +++ b/.changeset/merry-moles-chatter.md @@ -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. diff --git a/.changeset/noble-badgers-roar.md b/.changeset/noble-badgers-roar.md new file mode 100644 index 000000000..c57bc33c3 --- /dev/null +++ b/.changeset/noble-badgers-roar.md @@ -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. diff --git a/.changeset/quick-geese-hum.md b/.changeset/quick-geese-hum.md new file mode 100644 index 000000000..b84167fb4 --- /dev/null +++ b/.changeset/quick-geese-hum.md @@ -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. diff --git a/.changeset/rapid-goats-munch.md b/.changeset/rapid-goats-munch.md new file mode 100644 index 000000000..75f910bca --- /dev/null +++ b/.changeset/rapid-goats-munch.md @@ -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. diff --git a/.changeset/sturdy-jays-glide.md b/.changeset/sturdy-jays-glide.md new file mode 100644 index 000000000..b5e407524 --- /dev/null +++ b/.changeset/sturdy-jays-glide.md @@ -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. diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index d373dbaa7..ead7e6a08 100644 --- a/sdk/src/cli.ts +++ b/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 { - 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= 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 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}`); diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts index 0f439a2ea..e21a6b7da 100644 --- a/sdk/src/gsd-tools.test.ts +++ b/sdk/src/gsd-tools.test.ts @@ -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 () => { diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index b2f0ba06f..e362acdc9 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -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 { 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, ); } diff --git a/sdk/src/gsd-transport-policy.ts b/sdk/src/gsd-transport-policy.ts index 5b7a41e3e..3db7693a2 100644 --- a/sdk/src/gsd-transport-policy.ts +++ b/sdk/src/gsd-transport-policy.ts @@ -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> = { - // 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> = Object.fromEntries( + TRANSPORT_RAW_COMMANDS.map((command) => [command, { outputMode: 'raw' as const }]), +); const COMMAND_POLICY_OVERRIDES: Record> = {}; diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts index cde76a5b6..d2eefcfb2 100644 --- a/sdk/src/gsd-transport.test.ts +++ b/sdk/src/gsd-transport.test.ts @@ -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 } })); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 5978922cd..4b61f4093 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -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; } } diff --git a/sdk/src/query/command-catalog.ts b/sdk/src/query/command-catalog.ts new file mode 100644 index 000000000..2e9b051c1 --- /dev/null +++ b/sdk/src/query/command-catalog.ts @@ -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>, +): 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, +): void { + for (const [command, handler] of entries) { + registry.register(command, handler); + } +} diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts new file mode 100644 index 000000000..97f0df283 --- /dev/null +++ b/sdk/src/query/command-family-handlers.ts @@ -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>> = { + 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, + }, +}; diff --git a/sdk/src/query/command-resolution.test.ts b/sdk/src/query/command-resolution.test.ts new file mode 100644 index 000000000..df64f1f1b --- /dev/null +++ b/sdk/src/query/command-resolution.test.ts @@ -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'); + }); +}); diff --git a/sdk/src/query/command-resolution.ts b/sdk/src/query/command-resolution.ts new file mode 100644 index 000000000..99f0ba5b5 --- /dev/null +++ b/sdk/src/query/command-resolution.ts @@ -0,0 +1,10 @@ +export { + resolveQueryCommand, + resolveQueryTokens, + type QueryCommandRegistryLike, + type QueryCommandResolution, + type QueryMatchMode, + type QueryResolutionSource, + explainQueryCommandNoMatch, + type QueryCommandNoMatch, +} from './query-command-semantics.js'; diff --git a/sdk/src/query/command-static-catalog-domain.ts b/sdk/src/query/command-static-catalog-domain.ts new file mode 100644 index 000000000..3dc63091f --- /dev/null +++ b/sdk/src/query/command-static-catalog-domain.ts @@ -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 = [ + ['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; diff --git a/sdk/src/query/command-static-catalog-foundation.ts b/sdk/src/query/command-static-catalog-foundation.ts new file mode 100644 index 000000000..62fa650e9 --- /dev/null +++ b/sdk/src/query/command-static-catalog-foundation.ts @@ -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 = [ + ['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 = [ + ['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 = [ + ['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 = [ + ['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 = [ + ['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; diff --git a/sdk/src/query/index-thin-seam.test.ts b/sdk/src/query/index-thin-seam.test.ts new file mode 100644 index 000000000..0c7800584 --- /dev/null +++ b/sdk/src/query/index-thin-seam.test.ts @@ -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'); + }); +}); diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts index 5ef9a07f3..e0f09c29c 100644 --- a/sdk/src/query/index.ts +++ b/sdk/src/query/index.ts @@ -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([ - ...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 | 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 | 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 = { - '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 = { - '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 = { - '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 = { - '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 = { - '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 = { - '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 = { - '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; -} diff --git a/sdk/src/query/mutation-event-decorator.test.ts b/sdk/src/query/mutation-event-decorator.test.ts new file mode 100644 index 000000000..d9336bd14 --- /dev/null +++ b/sdk/src/query/mutation-event-decorator.test.ts @@ -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(); + }); +}); diff --git a/sdk/src/query/mutation-event-decorator.ts b/sdk/src/query/mutation-event-decorator.ts new file mode 100644 index 000000000..a7dc68516 --- /dev/null +++ b/sdk/src/query/mutation-event-decorator.ts @@ -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, + 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, +): number { + let count = 0; + for (const cmd of mutationCommands) { + if (registry.getHandler(cmd)) count++; + } + return count; +} diff --git a/sdk/src/query/mutation-event-mapper.test.ts b/sdk/src/query/mutation-event-mapper.test.ts new file mode 100644 index 000000000..91a1538a1 --- /dev/null +++ b/sdk/src/query/mutation-event-mapper.test.ts @@ -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); + }); +}); diff --git a/sdk/src/query/mutation-event-mapper.ts b/sdk/src/query/mutation-event-mapper.ts new file mode 100644 index 000000000..dd99774d9 --- /dev/null +++ b/sdk/src/query/mutation-event-mapper.ts @@ -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 | 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 | 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; + } +} diff --git a/sdk/src/query/normalize-query-command.ts b/sdk/src/query/normalize-query-command.ts index 7be25b192..6ee26d800 100644 --- a/sdk/src/query/normalize-query-command.ts +++ b/sdk/src/query/normalize-query-command.ts @@ -1,119 +1 @@ -/** - * Normalize `gsd-sdk query ` 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([ - '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'; diff --git a/sdk/src/query/policy-convergence.test.ts b/sdk/src/query/policy-convergence.test.ts new file mode 100644 index 000000000..f418ad3ab --- /dev/null +++ b/sdk/src/query/policy-convergence.test.ts @@ -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); + }); +}); diff --git a/sdk/src/query/policy-convergence.ts b/sdk/src/query/policy-convergence.ts new file mode 100644 index 000000000..2fe21791d --- /dev/null +++ b/sdk/src/query/policy-convergence.ts @@ -0,0 +1,5 @@ +export { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-command-semantics.js'; diff --git a/sdk/src/query/query-command-semantics.ts b/sdk/src/query/query-command-semantics.ts new file mode 100644 index 000000000..58336f95e --- /dev/null +++ b/sdk/src/query/query-command-semantics.ts @@ -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([ + '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, + }, + }; +} diff --git a/sdk/src/query/query-dispatch-contract.ts b/sdk/src/query/query-dispatch-contract.ts new file mode 100644 index 000000000..1bc995772 --- /dev/null +++ b/sdk/src/query/query-dispatch-contract.ts @@ -0,0 +1,10 @@ +export interface QueryDispatchError { + code: number; + message: string; +} + +export interface QueryDispatchResult { + stdout?: string; + stderr: string[]; + error?: QueryDispatchError; +} diff --git a/sdk/src/query/query-dispatch.test.ts b/sdk/src/query/query-dispatch.test.ts new file mode 100644 index 000000000..6cbff02ef --- /dev/null +++ b/sdk/src/query/query-dispatch.test.ts @@ -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 { + 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'); + }); +}); diff --git a/sdk/src/query/query-dispatch.ts b/sdk/src/query/query-dispatch.ts new file mode 100644 index 000000000..f7ca7f74b --- /dev/null +++ b/sdk/src/query/query-dispatch.ts @@ -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; +} + +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 { + 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}` }, + }; + } +} diff --git a/sdk/src/query/query-fallback-executor.test.ts b/sdk/src/query/query-fallback-executor.test.ts new file mode 100644 index 000000000..3e4fd7caf --- /dev/null +++ b/sdk/src/query/query-fallback-executor.test.ts @@ -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 { + 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'); + }); +}); diff --git a/sdk/src/query/query-fallback-executor.ts b/sdk/src/query/query-fallback-executor.ts new file mode 100644 index 000000000..449339ec4 --- /dev/null +++ b/sdk/src/query/query-fallback-executor.ts @@ -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 { + 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 { + 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 { + 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}` }, + }; + } +} diff --git a/sdk/src/query/registry-assembly-invariants.ts b/sdk/src/query/registry-assembly-invariants.ts new file mode 100644 index 000000000..d33a13604 --- /dev/null +++ b/sdk/src/query/registry-assembly-invariants.ts @@ -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>; +} + +export interface RegistryAssemblyStaticGroup { + name: string; + entries: ReadonlyArray; +} + +export interface RegistryAssemblyInputs { + staticGroups: readonly RegistryAssemblyStaticGroup[]; + aliasGroups: readonly RegistryAssemblyAliasGroup[]; + mutationCommands: ReadonlySet; + rawOutputPolicyCommands: readonly string[]; +} + +function toSortedList(values: Iterable): string[] { + return Array.from(values).sort((a, b) => a.localeCompare(b)); +} + +export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInputs): void { + const counts = new Map(); + + 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, +): 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(', ')}`); + } +} diff --git a/sdk/src/query/registry-assembly.test.ts b/sdk/src/query/registry-assembly.test.ts new file mode 100644 index 000000000..9fe40ffa4 --- /dev/null +++ b/sdk/src/query/registry-assembly.test.ts @@ -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(), + 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(), + 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(), + rawOutputPolicyCommands: [], + })).not.toThrow(); + expect(() => assertAliasCanonicalsHaveHandlers({ + staticGroups, + aliasGroups, + mutationCommands: new Set(), + rawOutputPolicyCommands: [], + })).not.toThrow(); + expect(() => assertMutationCommandsRegistered(registry, new Set(['one']))).not.toThrow(); + expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['canon'])).not.toThrow(); + }); +}); diff --git a/sdk/src/query/registry-assembly.ts b/sdk/src/query/registry-assembly.ts new file mode 100644 index 000000000..aabf89ae2 --- /dev/null +++ b/sdk/src/query/registry-assembly.ts @@ -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(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 }, + { family: 'roadmap', aliases: ROADMAP_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.roadmap as Record }, + { family: 'verify', aliases: VERIFY_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.verify as Record }, + { family: 'validate', aliases: VALIDATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.validate as Record }, + { family: 'phase', aliases: PHASE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phase as Record }, + { family: 'phases', aliases: PHASES_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phases as Record }, + { family: 'init', aliases: INIT_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.init as Record }, +] 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); + + registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG); + registerAliasCatalog(registry, ROADMAP_COMMAND_ALIASES, FAMILY_HANDLERS.roadmap as Record); + + registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG); + + registerAliasCatalog(registry, VERIFY_COMMAND_ALIASES, FAMILY_HANDLERS.verify as Record); + + registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG); + registerAliasCatalog(registry, VALIDATE_COMMAND_ALIASES, FAMILY_HANDLERS.validate as Record); + + registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG); + + registerAliasCatalog(registry, PHASE_COMMAND_ALIASES, FAMILY_HANDLERS.phase as Record); + + registerAliasCatalog(registry, PHASES_COMMAND_ALIASES, FAMILY_HANDLERS.phases as Record); + + registerAliasCatalog(registry, INIT_COMMAND_ALIASES, FAMILY_HANDLERS.init as Record); + + 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; +} diff --git a/sdk/src/query/registry.test.ts b/sdk/src/query/registry.test.ts index ea42b147b..573f92437 100644 --- a/sdk/src/query/registry.test.ts +++ b/sdk/src/query/registry.test.ts @@ -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' } }); }); diff --git a/sdk/src/query/registry.ts b/sdk/src/query/registry.ts index 01a143d05..7218effcb 100644 --- a/sdk/src/query/registry.ts +++ b/sdk/src/query/registry.ts @@ -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 }; } diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs index 26080dc13..cee82a61d 100644 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -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'); }); }); diff --git a/tests/bug-2524-sdk-query-ws-flag.test.cjs b/tests/bug-2524-sdk-query-ws-flag.test.cjs index 5ee6ff2af..8b85c2f7a 100644 --- a/tests/bug-2524-sdk-query-ws-flag.test.cjs +++ b/tests/bug-2524-sdk-query-ws-flag.test.cjs @@ -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', ); }); diff --git a/tests/gsd-sdk-query-registry-integration.test.cjs b/tests/gsd-sdk-query-registry-integration.test.cjs index f8be63f17..cf146c29f 100644 --- a/tests/gsd-sdk-query-registry-integration.test.cjs +++ b/tests/gsd-sdk-query-registry-integration.test.cjs @@ -1,6 +1,6 @@ /** * Drift guard: every `gsd-sdk query ` 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 ` 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') ); });