diff --git a/.changeset/brave-mice-build.md b/.changeset/brave-mice-build.md new file mode 100644 index 000000000..59c4749c3 --- /dev/null +++ b/.changeset/brave-mice-build.md @@ -0,0 +1,8 @@ +--- +type: Changed +pr: 3069 +--- + +**query command metadata now flows through a canonical Command Definition Module seam** — registry assembly, mutation semantics, and alias generation consume one Interface (`family`, `canonical`, `aliases`, `mutation`, `output_mode`, `handler_key`) to improve locality and reduce drift. + +**query fallback error mapping cleanup** — the CJS fallback catch path now passes original `err` to `mapFallbackDispatchError` (follow-up to prior review feedback missed in PR #3066). diff --git a/CONTEXT.md b/CONTEXT.md index fadc675d3..bba6c800f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,3 +12,6 @@ Canonical error kind set: - `fallback_failure` - `validation_error` - `internal_error` + +### Command Definition Module +Canonical command metadata Interface powering alias, catalog, and semantics generation. diff --git a/sdk/scripts/gen-command-aliases.ts b/sdk/scripts/gen-command-aliases.ts index 0a61c0c00..d862e7a6e 100644 --- a/sdk/scripts/gen-command-aliases.ts +++ b/sdk/scripts/gen-command-aliases.ts @@ -10,13 +10,7 @@ import { writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { STATE_COMMAND_MANIFEST } from '../src/query/command-manifest.state.js'; -import { VERIFY_COMMAND_MANIFEST } from '../src/query/command-manifest.verify.js'; -import { INIT_COMMAND_MANIFEST } from '../src/query/command-manifest.init.js'; -import { PHASE_COMMAND_MANIFEST } from '../src/query/command-manifest.phase.js'; -import { PHASES_COMMAND_MANIFEST } from '../src/query/command-manifest.phases.js'; -import { VALIDATE_COMMAND_MANIFEST } from '../src/query/command-manifest.validate.js'; -import { ROADMAP_COMMAND_MANIFEST } from '../src/query/command-manifest.roadmap.js'; +import { COMMAND_DEFINITIONS_BY_FAMILY } from '../src/query/command-definition.js'; function toSubcommand(canonical: string, family: 'state' | 'verify' | 'init' | 'phase' | 'phases' | 'validate' | 'roadmap'): string { const prefix = `${family}.`; @@ -24,49 +18,49 @@ function toSubcommand(canonical: string, family: 'state' | 'verify' | 'init' | ' } async function main(): Promise { - const stateEntries = STATE_COMMAND_MANIFEST.map((entry) => ({ + const stateEntries = COMMAND_DEFINITIONS_BY_FAMILY.state.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'state'), mutation: entry.mutation, })); - const verifyEntries = VERIFY_COMMAND_MANIFEST.map((entry) => ({ + const verifyEntries = COMMAND_DEFINITIONS_BY_FAMILY.verify.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'verify'), mutation: entry.mutation, })); - const initEntries = INIT_COMMAND_MANIFEST.map((entry) => ({ + const initEntries = COMMAND_DEFINITIONS_BY_FAMILY.init.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'init'), mutation: entry.mutation, })); - const phaseEntries = PHASE_COMMAND_MANIFEST.map((entry) => ({ + const phaseEntries = COMMAND_DEFINITIONS_BY_FAMILY.phase.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'phase'), mutation: entry.mutation, })); - const phasesEntries = PHASES_COMMAND_MANIFEST.map((entry) => ({ + const phasesEntries = COMMAND_DEFINITIONS_BY_FAMILY.phases.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'phases'), mutation: entry.mutation, })); - const validateEntries = VALIDATE_COMMAND_MANIFEST.map((entry) => ({ + const validateEntries = COMMAND_DEFINITIONS_BY_FAMILY.validate.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'validate'), mutation: entry.mutation, })); - const roadmapEntries = ROADMAP_COMMAND_MANIFEST.map((entry) => ({ + const roadmapEntries = COMMAND_DEFINITIONS_BY_FAMILY.roadmap.map((entry) => ({ canonical: entry.canonical, aliases: entry.aliases, subcommand: toSubcommand(entry.canonical, 'roadmap'), @@ -98,22 +92,7 @@ async function main(): Promise { 'export const VALIDATE_SUBCOMMANDS = new Set(VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand));', 'export const ROADMAP_SUBCOMMANDS = new Set(ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand));', '', - 'export const STATE_MUTATION_COMMANDS: readonly string[] = STATE_COMMAND_ALIASES', - ' .filter((entry) => entry.mutation)', - ' .flatMap((entry) => [entry.canonical, ...entry.aliases]);', - '', - 'export const PHASE_MUTATION_COMMANDS: readonly string[] = PHASE_COMMAND_ALIASES', - ' .filter((entry) => entry.mutation)', - ' .flatMap((entry) => [entry.canonical, ...entry.aliases]);', - '', - 'export const PHASES_MUTATION_COMMANDS: readonly string[] = PHASES_COMMAND_ALIASES', - ' .filter((entry) => entry.mutation)', - ' .flatMap((entry) => [entry.canonical, ...entry.aliases]);', - '', - 'export const ROADMAP_MUTATION_COMMANDS: readonly string[] = ROADMAP_COMMAND_ALIASES', - ' .filter((entry) => entry.mutation)', - ' .flatMap((entry) => [entry.canonical, ...entry.aliases]);', - '', + ].join('\n'); await writeFile(outPath, header + body, 'utf-8'); } diff --git a/sdk/src/query/command-aliases.generated.ts b/sdk/src/query/command-aliases.generated.ts index 9692c3446..3e4713c70 100644 --- a/sdk/src/query/command-aliases.generated.ts +++ b/sdk/src/query/command-aliases.generated.ts @@ -105,18 +105,3 @@ export const PHASES_SUBCOMMANDS = new Set(PHASES_COMMAND_ALIASES.map((en export const VALIDATE_SUBCOMMANDS = new Set(VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand)); export const ROADMAP_SUBCOMMANDS = new Set(ROADMAP_COMMAND_ALIASES.map((entry) => entry.subcommand)); -export const STATE_MUTATION_COMMANDS: readonly string[] = STATE_COMMAND_ALIASES - .filter((entry) => entry.mutation) - .flatMap((entry) => [entry.canonical, ...entry.aliases]); - -export const PHASE_MUTATION_COMMANDS: readonly string[] = PHASE_COMMAND_ALIASES - .filter((entry) => entry.mutation) - .flatMap((entry) => [entry.canonical, ...entry.aliases]); - -export const PHASES_MUTATION_COMMANDS: readonly string[] = PHASES_COMMAND_ALIASES - .filter((entry) => entry.mutation) - .flatMap((entry) => [entry.canonical, ...entry.aliases]); - -export const ROADMAP_MUTATION_COMMANDS: readonly string[] = ROADMAP_COMMAND_ALIASES - .filter((entry) => entry.mutation) - .flatMap((entry) => [entry.canonical, ...entry.aliases]); diff --git a/sdk/src/query/command-definition.test.ts b/sdk/src/query/command-definition.test.ts new file mode 100644 index 000000000..814a4a508 --- /dev/null +++ b/sdk/src/query/command-definition.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { COMMAND_DEFINITIONS, COMMAND_DEFINITIONS_BY_FAMILY, FAMILY_MUTATION_COMMANDS } from './command-definition.js'; +import { COMMAND_MANIFEST } from './command-manifest.js'; + +describe('command-definition module', () => { + it('exposes canonical metadata with handler_key normalization contract', () => { + expect(COMMAND_DEFINITIONS).toHaveLength(COMMAND_MANIFEST.length); + for (const [index, manifestEntry] of COMMAND_MANIFEST.entries()) { + const definition = COMMAND_DEFINITIONS[index]; + expect(definition.handler_key).toBe(manifestEntry.handlerKey ?? manifestEntry.canonical); + expect(definition.canonical).toBe(manifestEntry.canonical); + expect(definition.aliases).toEqual(manifestEntry.aliases); + expect(definition.canonical).toContain('.'); + expect(Array.isArray(definition.aliases)).toBe(true); + } + }); + + it('keeps family index canonicals in sync with flat list', () => { + const indexed = Object.values(COMMAND_DEFINITIONS_BY_FAMILY).flat(); + expect(indexed).toHaveLength(COMMAND_DEFINITIONS.length); + expect(indexed.map((entry) => entry.canonical).sort()).toEqual( + [...COMMAND_DEFINITIONS.map((entry) => entry.canonical)].sort(), + ); + }); + + it('derives family mutation command aliases from one source', () => { + expect(FAMILY_MUTATION_COMMANDS).toContain('state.update'); + expect(FAMILY_MUTATION_COMMANDS).toContain('phase complete'); + expect(FAMILY_MUTATION_COMMANDS).toContain('roadmap.update-plan-progress'); + }); +}); diff --git a/sdk/src/query/command-definition.ts b/sdk/src/query/command-definition.ts new file mode 100644 index 000000000..c64503675 --- /dev/null +++ b/sdk/src/query/command-definition.ts @@ -0,0 +1,42 @@ +import { COMMAND_MANIFEST } from './command-manifest.js'; +import type { CommandFamily, OutputMode } from './command-manifest.types.js'; + +export interface CommandDefinition { + family: CommandFamily; + canonical: string; + aliases: string[]; + mutation: boolean; + output_mode: OutputMode; + handler_key: string; +} + +export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFEST.map((entry) => ({ + family: entry.family, + canonical: entry.canonical, + aliases: [...entry.aliases], + mutation: entry.mutation, + output_mode: entry.outputMode, + handler_key: entry.handlerKey ?? entry.canonical, +})) as readonly CommandDefinition[]; + +function byFamily(family: CommandFamily): readonly CommandDefinition[] { + return COMMAND_DEFINITIONS.filter((entry) => entry.family === family); +} + +export const COMMAND_DEFINITIONS_BY_FAMILY: Readonly> = { + state: byFamily('state'), + verify: byFamily('verify'), + init: byFamily('init'), + phase: byFamily('phase'), + phases: byFamily('phases'), + validate: byFamily('validate'), + roadmap: byFamily('roadmap'), +} as const; + +export const FAMILY_MUTATION_COMMANDS: readonly string[] = COMMAND_DEFINITIONS + .filter((entry) => entry.mutation) + .flatMap((entry) => [entry.canonical, ...entry.aliases]); + +export const FAMILY_RAW_OUTPUT_COMMANDS: readonly string[] = COMMAND_DEFINITIONS + .filter((entry) => entry.output_mode === 'raw') + .flatMap((entry) => [entry.canonical, ...entry.aliases]); diff --git a/sdk/src/query/command-manifest.types.ts b/sdk/src/query/command-manifest.types.ts index 807f7664f..dbe51fb39 100644 --- a/sdk/src/query/command-manifest.types.ts +++ b/sdk/src/query/command-manifest.types.ts @@ -8,4 +8,6 @@ export interface CommandManifestEntry { aliases: string[]; mutation: boolean; outputMode: OutputMode; + /** Optional explicit handler key (defaults to canonical). */ + handlerKey?: string; } diff --git a/sdk/src/query/command-resolution.ts b/sdk/src/query/command-resolution.ts index 99f0ba5b5..a847ced8a 100644 --- a/sdk/src/query/command-resolution.ts +++ b/sdk/src/query/command-resolution.ts @@ -7,4 +7,4 @@ export { type QueryResolutionSource, explainQueryCommandNoMatch, type QueryCommandNoMatch, -} from './query-command-semantics.js'; +} from './query-command-resolution-strategy.js'; diff --git a/sdk/src/query/normalize-query-command.ts b/sdk/src/query/normalize-query-command.ts index 6ee26d800..b34a8b97b 100644 --- a/sdk/src/query/normalize-query-command.ts +++ b/sdk/src/query/normalize-query-command.ts @@ -1 +1 @@ -export { normalizeQueryCommand } from './query-command-semantics.js'; +export { normalizeQueryCommand } from './query-command-resolution-strategy.js'; diff --git a/sdk/src/query/policy-convergence.test.ts b/sdk/src/query/policy-convergence.test.ts index f418ad3ab..f937ce355 100644 --- a/sdk/src/query/policy-convergence.test.ts +++ b/sdk/src/query/policy-convergence.test.ts @@ -4,6 +4,7 @@ import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCom describe('policy convergence', () => { it('contains expected raw transport aliases', () => { expect(TRANSPORT_RAW_COMMANDS).toEqual([ + 'state.load', 'commit', 'config-set', 'verify-summary', diff --git a/sdk/src/query/policy-convergence.ts b/sdk/src/query/policy-convergence.ts index 2fe21791d..b7d559218 100644 --- a/sdk/src/query/policy-convergence.ts +++ b/sdk/src/query/policy-convergence.ts @@ -1,5 +1,6 @@ export { + QUERY_POLICY_SNAPSHOT, QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCommand, -} from './query-command-semantics.js'; +} from './query-policy-snapshot.js'; diff --git a/sdk/src/query/query-command-diagnosis.test.ts b/sdk/src/query/query-command-diagnosis.test.ts new file mode 100644 index 000000000..cd4c62857 --- /dev/null +++ b/sdk/src/query/query-command-diagnosis.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { createRegistry } from './index.js'; +import { diagnoseUnknownCommand } from './query-command-diagnosis.js'; + +describe('query-command-diagnosis', () => { + it('returns structured diagnosis and rendered message with restricted fallback', () => { + const registry = createRegistry(); + const out = diagnoseUnknownCommand('unknown-cmd', [], registry, true); + + expect(out.normalized).toBe('unknown-cmd'); + expect(Array.isArray(out.hints)).toBe(true); + expect(out.hints.length).toBeGreaterThan(0); + expect(out.message).toContain('Unknown command: "unknown-cmd"'); + expect(out.message).toContain('CJS fallback is disabled'); + }); + + it('omits disabled-fallback clause when fallback is not restricted', () => { + const registry = createRegistry(); + const out = diagnoseUnknownCommand('unknown-cmd', [], registry, false); + expect(out.message).not.toContain('CJS fallback is disabled'); + }); +}); diff --git a/sdk/src/query/query-command-diagnosis.ts b/sdk/src/query/query-command-diagnosis.ts new file mode 100644 index 000000000..ef8ee8cd9 --- /dev/null +++ b/sdk/src/query/query-command-diagnosis.ts @@ -0,0 +1,32 @@ +import { explainQueryCommandNoMatch, type QueryCommandRegistryLike } from './query-command-semantics.js'; +import { UNKNOWN_COMMAND_HINTS } from './query-unknown-command-hints.js'; +import { describeFallbackDisabledPolicy } from './query-fallback-policy.js'; + +export interface UnknownCommandDiagnosis { + normalized: string; + attempted: string[]; + hints: string[]; + message: string; +} + +export function diagnoseUnknownCommand( + command: string, + args: string[], + registry: QueryCommandRegistryLike, + fallbackRestricted: boolean, +): UnknownCommandDiagnosis { + const noMatch = explainQueryCommandNoMatch(command, args, registry); + const normalized = [noMatch.normalized.command, ...noMatch.normalized.args].join(' '); + const attempted = noMatch.attempted.dotted.slice(0, 2); + const hints = [...UNKNOWN_COMMAND_HINTS]; + const attemptedSuffix = attempted.length > 0 ? ` Attempted dotted: ${attempted.join(' | ')}.` : ''; + const fallbackClause = fallbackRestricted ? `${describeFallbackDisabledPolicy()} ` : ''; + const message = `Error: Unknown command: "${normalized}". ${hints[0]} ${hints[1]} ${fallbackClause}${hints[2]}${attemptedSuffix}`; + + return { + normalized, + attempted, + hints, + message, + }; +} diff --git a/sdk/src/query/query-command-resolution-strategy.test.ts b/sdk/src/query/query-command-resolution-strategy.test.ts new file mode 100644 index 000000000..232f768ee --- /dev/null +++ b/sdk/src/query/query-command-resolution-strategy.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { createRegistry } from './index.js'; +import { + normalizeQueryCommand, + resolveQueryCommand, + explainQueryCommandNoMatch, +} from './query-command-resolution-strategy.js'; + +describe('query-command-resolution-strategy', () => { + it('normalizes family subcommands', () => { + expect(normalizeQueryCommand('state', ['json'])).toEqual(['state.json', []]); + }); + + it('resolves registered command', () => { + const registry = createRegistry(); + const out = resolveQueryCommand('state', ['json'], registry); + expect(out?.cmd).toBe('state.json'); + }); + + it('resolves expanded-token mapping', () => { + const registry = createRegistry(); + registry.register('custom op', async () => ({ data: { ok: true } })); + const out = resolveQueryCommand('custom.op', [], registry); + expect(out?.source).toBe('expanded'); + expect(out?.expanded).toBe(true); + }); + + it('provides attempted variants via no-match explainer', () => { + const registry = createRegistry(); + const noMatch = explainQueryCommandNoMatch('state', ['made-up-op', 'x'], registry); + expect(noMatch.attempted.dotted.length).toBeGreaterThan(0); + expect(noMatch.normalized.command).toBe('state'); + }); +}); diff --git a/sdk/src/query/query-command-resolution-strategy.ts b/sdk/src/query/query-command-resolution-strategy.ts new file mode 100644 index 000000000..f42f1dc3a --- /dev/null +++ b/sdk/src/query/query-command-resolution-strategy.ts @@ -0,0 +1,121 @@ +import { + STATE_SUBCOMMANDS, + VERIFY_SUBCOMMANDS, + INIT_SUBCOMMANDS, + PHASE_SUBCOMMANDS, + PHASES_SUBCOMMANDS, + VALIDATE_SUBCOMMANDS, + ROADMAP_SUBCOMMANDS, +} 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 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) { + if (STATE_SUBCOMMANDS.has(args[0])) return [`state.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'verify' && args.length > 0) { + if (VERIFY_SUBCOMMANDS.has(args[0])) return [`verify.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'init' && args.length > 0) { + if (INIT_SUBCOMMANDS.has(args[0])) return [`init.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'phase' && args.length > 0) { + if (PHASE_SUBCOMMANDS.has(args[0])) return [`phase.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'phases' && args.length > 0) { + if (PHASES_SUBCOMMANDS.has(args[0])) return [`phases.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'validate' && args.length > 0) { + if (VALIDATE_SUBCOMMANDS.has(args[0])) return [`validate.${args[0]}`, args.slice(1)]; + return [command, args]; + } + if (command === 'roadmap' && args.length > 0) { + if (ROADMAP_SUBCOMMANDS.has(args[0])) return [`roadmap.${args[0]}`, args.slice(1)]; + return [command, args]; + } + + if (MERGE_FIRST_WITH_SUBCOMMAND.has(command) && args.length > 0 && !args[0].startsWith('-')) 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-command-semantics.ts b/sdk/src/query/query-command-semantics.ts index 58336f95e..9d4625ccf 100644 --- a/sdk/src/query/query-command-semantics.ts +++ b/sdk/src/query/query-command-semantics.ts @@ -1,65 +1,11 @@ -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', -]); +import { FAMILY_MUTATION_COMMANDS, FAMILY_RAW_OUTPUT_COMMANDS } from './command-definition.js'; export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [ - ...STATE_MUTATION_COMMANDS, + ...FAMILY_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', @@ -73,7 +19,7 @@ export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [ 'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md', ] as const; -export const TRANSPORT_RAW_COMMANDS: readonly string[] = [ +const NON_FAMILY_RAW_OUTPUT_COMMANDS = [ 'commit', 'config-set', 'verify-summary', @@ -81,134 +27,25 @@ export const TRANSPORT_RAW_COMMANDS: readonly string[] = [ 'verify summary', ] as const; +export const TRANSPORT_RAW_COMMANDS: readonly string[] = [ + ...FAMILY_RAW_OUTPUT_COMMANDS, + ...NON_FAMILY_RAW_OUTPUT_COMMANDS, +] 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, - }, - }; -} +export { + normalizeQueryCommand, + resolveQueryTokens, + resolveQueryCommand, + explainQueryCommandNoMatch, + type QueryCommandRegistryLike, + type QueryCommandResolution, + type QueryMatchMode, + type QueryResolutionSource, + type QueryCommandNoMatch, +} from './query-command-resolution-strategy.js'; diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index cdb28e58d..e12f89458 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,46 +1,25 @@ -import type { QueryDispatchError, QueryDispatchErrorKind, QueryDispatchResult } from './query-dispatch-contract.js'; +import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; +import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; +import { dispatchFailure } from './query-dispatch-result-builder.js'; export function toDispatchFailure( error: QueryDispatchError, stderr: string[] = [], ): QueryDispatchResult { - return { - ok: false, - stderr, - exit_code: error.code, - error, - }; + return dispatchFailure(error, stderr); } export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { const message = error instanceof Error ? error.message : String(error); - const kind: QueryDispatchErrorKind = message.includes('timed out after') - ? 'native_timeout' - : 'native_failure'; - return { - kind, - code: 1, - message: `Error: ${message}`, - details: { - command, - args, - ...(kind === 'native_timeout' ? { timeout_ms: parseTimeoutMs(message) } : {}), - }, - }; + if (/timed out after/i.test(message)) { + return nativeTimeoutError({ message, command, args, timeoutMs: parseTimeoutMs(message) }); + } + return nativeFailureError({ message, command, args }); } export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { const message = error instanceof Error ? error.message : String(error); - return { - kind: 'fallback_failure', - code: 1, - message: `Error: gsd-tools.cjs fallback failed: ${message}`, - details: { - command, - args, - backend: 'cjs', - }, - }; + return fallbackFailureError({ message, command, args, backend: 'cjs' }); } function parseTimeoutMs(message: string): number | undefined { diff --git a/sdk/src/query/query-dispatch-formatting.test.ts b/sdk/src/query/query-dispatch-formatting.test.ts new file mode 100644 index 000000000..b5da8a293 --- /dev/null +++ b/sdk/src/query/query-dispatch-formatting.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { formatPick, formatSuccess } from './query-dispatch-formatting.js'; + +describe('query-dispatch-formatting', () => { + it('formats text with trailing newline', () => { + expect(formatSuccess('USAGE', 'text')).toBe('USAGE\n'); + }); + + it('formats json with pretty printing', () => { + expect(formatSuccess({ nested: { value: 3 } }, 'json')).toBe([ + '{', + ' "nested": {', + ' "value": 3', + ' }', + '}', + '', + ].join('\n')); + }); + + it('formats json and applies pick', () => { + expect(formatSuccess({ nested: { value: 3 } }, 'json', 'nested.value')).toBe('3\n'); + }); + + it('formatPick returns input when no pickField', () => { + const input = { ok: true }; + expect(formatPick(input)).toBe(input); + }); +}); diff --git a/sdk/src/query/query-dispatch-formatting.ts b/sdk/src/query/query-dispatch-formatting.ts new file mode 100644 index 000000000..3b8cac188 --- /dev/null +++ b/sdk/src/query/query-dispatch-formatting.ts @@ -0,0 +1,16 @@ +import { extractField } from './registry.js'; + +export type DispatchSuccessFormat = 'json' | 'text' | undefined; + +export function formatPick(data: unknown, pickField?: string): unknown { + if (!pickField) return data; + return extractField(data, pickField); +} + +export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string { + if (format === 'text' && typeof data === 'string') { + return data.endsWith('\n') ? data : `${data}\n`; + } + const output = formatPick(data, pickField); + return `${JSON.stringify(output, null, 2)}\n`; +} diff --git a/sdk/src/query/query-dispatch-input-validation.test.ts b/sdk/src/query/query-dispatch-input-validation.test.ts new file mode 100644 index 000000000..78640b465 --- /dev/null +++ b/sdk/src/query/query-dispatch-input-validation.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { validateQueryDispatchInput } from './query-dispatch-input-validation.js'; + +describe('query-dispatch-input-validation', () => { + it('fails when --pick value missing', () => { + const out = validateQueryDispatchInput(['state', 'json', '--pick']); + expect(out.error?.ok).toBe(false); + }); + + it('extracts pick field and query args', () => { + const out = validateQueryDispatchInput(['state', 'json', '--pick', 'x.y']); + expect(out.error).toBeUndefined(); + expect(out.queryArgs).toEqual(['state', 'json']); + expect(out.pickField).toBe('x.y'); + }); + + it('fails when --pick is the only command token', () => { + const out = validateQueryDispatchInput(['--pick', 'x.y']); + expect(out.error?.ok).toBe(false); + if (out.error?.ok) throw new Error('expected failure'); + expect(out.error?.error.kind).toBe('validation_error'); + }); +}); diff --git a/sdk/src/query/query-dispatch-input-validation.ts b/sdk/src/query/query-dispatch-input-validation.ts new file mode 100644 index 000000000..e0ded9c22 --- /dev/null +++ b/sdk/src/query/query-dispatch-input-validation.ts @@ -0,0 +1,49 @@ +import type { QueryDispatchResult } from './query-dispatch-contract.js'; +import { validationError } from './query-error-taxonomy.js'; +import { dispatchFailure } from './query-dispatch-result-builder.js'; + +export interface DispatchInputValidationResult { + queryArgs: string[]; + pickField?: string; + error?: QueryDispatchResult; +} + +export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult { + const queryArgs = [...queryArgv]; + const pickIdx = queryArgs.indexOf('--pick'); + if (pickIdx !== -1) { + if (pickIdx + 1 >= queryArgs.length) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: --pick requires a field name', + details: { field: '--pick', reason: 'missing_value' }, + })), + }; + } + const pickField = queryArgs[pickIdx + 1]; + queryArgs.splice(pickIdx, 2); + if (queryArgs.length === 0 || !queryArgs[0]) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: "gsd-sdk query" requires a command', + details: { reason: 'missing_command' }, + })), + }; + } + return { queryArgs, pickField }; + } + + if (queryArgs.length === 0 || !queryArgs[0]) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: "gsd-sdk query" requires a command', + details: { reason: 'missing_command' }, + })), + }; + } + + return { queryArgs }; +} diff --git a/sdk/src/query/query-dispatch-observability.test.ts b/sdk/src/query/query-dispatch-observability.test.ts new file mode 100644 index 000000000..7eca73dcc --- /dev/null +++ b/sdk/src/query/query-dispatch-observability.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { fallbackBridgeNotices } from './query-dispatch-observability.js'; + +describe('query-dispatch-observability', () => { + it('builds fallback notices', () => { + const notes = fallbackBridgeNotices('unknown-cmd'); + expect(notes[0]).toContain('unknown-cmd'); + expect(notes.length).toBe(2); + }); +}); diff --git a/sdk/src/query/query-dispatch-observability.ts b/sdk/src/query/query-dispatch-observability.ts new file mode 100644 index 000000000..9b949d7ac --- /dev/null +++ b/sdk/src/query/query-dispatch-observability.ts @@ -0,0 +1,6 @@ +export function fallbackBridgeNotices(command: string): string[] { + return [ + `[gsd-sdk] '${command}' not in native registry; falling back to gsd-tools.cjs.`, + '[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.', + ]; +} diff --git a/sdk/src/query/query-dispatch-plan.test.ts b/sdk/src/query/query-dispatch-plan.test.ts new file mode 100644 index 000000000..507af4f06 --- /dev/null +++ b/sdk/src/query/query-dispatch-plan.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { createRegistry } from './index.js'; +import { planQueryDispatch } from './query-dispatch-plan.js'; + +describe('query-dispatch-plan', () => { + it('selects native mode for registered commands', () => { + const registry = createRegistry(); + const plan = planQueryDispatch(['state', 'json'], registry, true); + expect(plan.mode).toBe('native'); + expect(plan.normalized.command).toBe('state.json'); + }); + + it('selects cjs mode for unknown command when fallback enabled', () => { + const registry = createRegistry(); + const plan = planQueryDispatch(['unknown-cmd'], registry, true); + expect(plan.mode).toBe('cjs'); + }); + + it('selects error mode for unknown command when fallback disabled', () => { + const registry = createRegistry(); + const plan = planQueryDispatch(['unknown-cmd'], registry, false); + expect(plan.mode).toBe('error'); + }); +}); diff --git a/sdk/src/query/query-dispatch-plan.ts b/sdk/src/query/query-dispatch-plan.ts new file mode 100644 index 000000000..cca96ae67 --- /dev/null +++ b/sdk/src/query/query-dispatch-plan.ts @@ -0,0 +1,33 @@ +import type { QueryRegistry } from './registry.js'; +import { normalizeQueryCommand } from './normalize-query-command.js'; +import { resolveQueryCommand, type QueryCommandResolution } from './command-resolution.js'; + +export type DispatchMode = 'native' | 'cjs' | 'error'; + +export interface DispatchPlan { + mode: DispatchMode; + normalized: { command: string; args: string[]; tokens: string[] }; + matched: QueryCommandResolution | null; +} + +export 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 }; +} diff --git a/sdk/src/query/query-dispatch-result-builder.test.ts b/sdk/src/query/query-dispatch-result-builder.test.ts new file mode 100644 index 000000000..29203cd1e --- /dev/null +++ b/sdk/src/query/query-dispatch-result-builder.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { dispatchFailure, dispatchSuccess } from './query-dispatch-result-builder.js'; + +describe('query-dispatch-result-builder', () => { + it('builds success result', () => { + expect(dispatchSuccess('ok\n')).toEqual({ ok: true, stdout: 'ok\n', stderr: [], exit_code: 0 }); + }); + + it('builds failure result from error code', () => { + const out = dispatchFailure({ kind: 'internal_error', code: 7, message: 'Error: x' }, ['warn']); + expect(out.ok).toBe(false); + if (out.ok) throw new Error('expected failure'); + expect(out.exit_code).toBe(7); + expect(out.stderr).toEqual(['warn']); + }); +}); diff --git a/sdk/src/query/query-dispatch-result-builder.ts b/sdk/src/query/query-dispatch-result-builder.ts new file mode 100644 index 000000000..07f8ad3b9 --- /dev/null +++ b/sdk/src/query/query-dispatch-result-builder.ts @@ -0,0 +1,19 @@ +import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; + +export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { + return { + ok: false, + error, + stderr, + exit_code: error.code, + }; +} + +export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult { + return { + ok: true, + stdout, + stderr, + exit_code: 0, + }; +} diff --git a/sdk/src/query/query-dispatch.ts b/sdk/src/query/query-dispatch.ts index 319a010cb..44b338e68 100644 --- a/sdk/src/query/query-dispatch.ts +++ b/sdk/src/query/query-dispatch.ts @@ -1,11 +1,15 @@ 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 { QueryDispatchResult } from './query-dispatch-contract.js'; import type { QueryResult } from './utils.js'; -import type { QueryDispatchResult, QueryDispatchErrorKind } from './query-dispatch-contract.js'; -import { mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js'; +import { mapFallbackDispatchError, mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js'; +import { formatSuccess } from './query-dispatch-formatting.js'; +import { diagnoseUnknownCommand } from './query-command-diagnosis.js'; +import { unknownCommandError, validationError } from './query-error-taxonomy.js'; +import { planQueryDispatch } from './query-dispatch-plan.js'; +import { validateQueryDispatchInput } from './query-dispatch-input-validation.js'; +import { dispatchSuccess } from './query-dispatch-result-builder.js'; +import { canUseCjsFallback } from './query-fallback-policy.js'; export interface QueryDispatchDeps { registry: QueryRegistry; @@ -16,125 +20,62 @@ export interface QueryDispatchDeps { 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 fail(error: ReturnType | ReturnType, stderr: string[] = []): QueryDispatchResult { + return toDispatchFailure(error, stderr); } -function fail( - kind: QueryDispatchErrorKind, - code: number, - message: string, - details?: Record, - stderr: string[] = [], -): QueryDispatchResult { - return toDispatchFailure({ kind, code, message, details }, stderr); -} - -function success(stdout: string, stderr: string[] = []): QueryDispatchResult { - return { ok: true, stdout, stderr, exit_code: 0 }; -} - -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?: QueryDispatchResult } { - const queryArgs = [...queryArgv]; - const pickIdx = queryArgs.indexOf('--pick'); - if (pickIdx === -1) return { queryArgs }; - if (pickIdx + 1 >= queryArgs.length) { - return { - queryArgs, - error: fail('validation_error', 10, 'Error: --pick requires a field name', { field: '--pick', reason: 'missing_value' }), - }; - } - 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 picked.error; + const validated = validateQueryDispatchInput(queryArgv); + if (validated.error) return validated.error; - const { queryArgs, pickField } = picked; - if (queryArgs.length === 0 || !queryArgs[0]) { - return fail('validation_error', 10, 'Error: "gsd-sdk query" requires a command', { reason: 'missing_command' }); - } + const { queryArgs, pickField } = validated; const plan = planQueryDispatch(queryArgs, deps.registry, deps.cjsFallbackEnabled); const normCmd = plan.normalized.command; const normArgs = plan.normalized.args; if (!normCmd || !String(normCmd).trim()) { - return fail('validation_error', 10, 'Error: "gsd-sdk query" requires a command', { reason: 'empty_normalized_command' }); + return fail(validationError({ message: 'Error: "gsd-sdk query" requires a command', details: { reason: 'empty_normalized_command' } })); } if (plan.mode === 'error') { - const noMatch = queryArgs[0] - ? explainQueryCommandNoMatch(queryArgs[0], queryArgs.slice(1), deps.registry) - : null; - return fail( - 'unknown_command', - 10, - `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(' | ')}.` : ''}`, - { normalized: [normCmd, ...normArgs].join(' '), attempted: noMatch?.attempted.dotted.slice(0, 2) ?? [] }, - ); + const diagnosis = diagnoseUnknownCommand(queryArgs[0] ?? normCmd, queryArgs.slice(1), deps.registry, !deps.cjsFallbackEnabled); + return fail(unknownCommandError({ + message: diagnosis.message, + normalized: diagnosis.normalized, + attempted: diagnosis.attempted, + hints: diagnosis.hints, + })); } if (plan.mode === 'cjs') { - try { - const gsdPath = deps.resolveGsdToolsPath(deps.projectDir); - return await runCjsFallbackDispatch({ - projectDir: deps.projectDir, - gsdToolsPath: gsdPath, - normCmd, - normArgs, - ws: deps.ws, - pickField, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return fail('fallback_failure', 1, `Error: gsd-tools.cjs fallback failed: ${msg}`, { - command: normCmd, - args: normArgs, - backend: 'cjs', - }); + if (canUseCjsFallback({ cjsFallbackEnabled: deps.cjsFallbackEnabled })) { + try { + const gsdPath = deps.resolveGsdToolsPath(deps.projectDir); + return await runCjsFallbackDispatch({ + projectDir: deps.projectDir, + gsdToolsPath: gsdPath, + normCmd, + normArgs, + ws: deps.ws, + pickField, + }); + } catch (e) { + return toDispatchFailure(mapFallbackDispatchError(e, normCmd, normArgs)); + } } + return toDispatchFailure(mapFallbackDispatchError(new Error('CJS fallback denied by policy'), normCmd, normArgs)); } - const matched = plan.matched!; + const matched = plan.matched; + if (!matched) { + return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs)); + } try { const result = await deps.dispatchNative(matched.cmd, matched.args); - return success(formatOutput(result.data, result.format, pickField)); + return dispatchSuccess(formatSuccess(result.data, result.format, pickField)); } catch (e) { return toDispatchFailure(mapNativeDispatchError(e, matched.cmd, matched.args)); } diff --git a/sdk/src/query/query-error-details-schema.ts b/sdk/src/query/query-error-details-schema.ts new file mode 100644 index 000000000..150e830e4 --- /dev/null +++ b/sdk/src/query/query-error-details-schema.ts @@ -0,0 +1,29 @@ +export interface UnknownCommandDetails { + normalized: string; + attempted: string[]; + hints: string[]; +} + +export interface NativeErrorDetails { + command: string; + args: string[]; + timeout_ms?: number; +} + +export interface FallbackErrorDetails { + command: string; + args: string[]; + backend: 'cjs'; +} + +export function unknownCommandDetails(input: UnknownCommandDetails): UnknownCommandDetails { + return input; +} + +export function nativeErrorDetails(input: NativeErrorDetails): NativeErrorDetails { + return input; +} + +export function fallbackErrorDetails(input: FallbackErrorDetails): FallbackErrorDetails { + return input; +} diff --git a/sdk/src/query/query-error-taxonomy.test.ts b/sdk/src/query/query-error-taxonomy.test.ts new file mode 100644 index 000000000..3f900d736 --- /dev/null +++ b/sdk/src/query/query-error-taxonomy.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { + fallbackFailureError, + internalError, + nativeFailureError, + nativeTimeoutError, + unknownCommandError, + validationError, +} from './query-error-taxonomy.js'; + +describe('query-error-taxonomy', () => { + it('builds unknown_command error', () => { + const err = unknownCommandError({ + message: 'Error: Unknown command: "x"', + normalized: 'x', + attempted: ['x'], + hints: ['h1'], + }); + expect(err.kind).toBe('unknown_command'); + expect(err.code).toBe(10); + expect(err.details).toMatchObject({ normalized: 'x', attempted: ['x'], hints: ['h1'] }); + }); + + it('builds native/fallback/validation/internal errors', () => { + expect(nativeFailureError({ message: 'boom', command: 'state.load', args: [] }).kind).toBe('native_failure'); + expect(nativeTimeoutError({ message: 'timeout', command: 'state.load', args: [], timeoutMs: 30000 }).kind).toBe('native_timeout'); + expect(fallbackFailureError({ message: 'spawn', command: 'state', args: ['load'] }).kind).toBe('fallback_failure'); + expect(validationError({ message: 'bad', details: { r: 'x' } }).kind).toBe('validation_error'); + expect(internalError({ message: 'bad' }).kind).toBe('internal_error'); + }); +}); diff --git a/sdk/src/query/query-error-taxonomy.ts b/sdk/src/query/query-error-taxonomy.ts new file mode 100644 index 000000000..c43e440e1 --- /dev/null +++ b/sdk/src/query/query-error-taxonomy.ts @@ -0,0 +1,98 @@ +import type { QueryDispatchError } from './query-dispatch-contract.js'; +import { fallbackErrorDetails, nativeErrorDetails, unknownCommandDetails } from './query-error-details-schema.js'; + +export function unknownCommandError(input: { + message: string; + normalized: string; + attempted: string[]; + hints: string[]; +}): QueryDispatchError { + return { + kind: 'unknown_command', + code: 10, + message: input.message, + details: unknownCommandDetails({ + normalized: input.normalized, + attempted: input.attempted, + hints: input.hints, + }) as unknown as Record, + }; +} + +export function nativeFailureError(input: { + message: string; + command: string; + args: string[]; +}): QueryDispatchError { + return { + kind: 'native_failure', + code: 1, + message: `Error: ${input.message}`, + details: nativeErrorDetails({ + command: input.command, + args: input.args, + }) as unknown as Record, + }; +} + +export function nativeTimeoutError(input: { + message: string; + command: string; + args: string[]; + timeoutMs?: number; +}): QueryDispatchError { + return { + kind: 'native_timeout', + code: 1, + message: `Error: ${input.message}`, + details: nativeErrorDetails({ + command: input.command, + args: input.args, + ...(input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {}), + }) as unknown as Record, + }; +} + +export function fallbackFailureError(input: { + message: string; + command: string; + args: string[]; + backend?: 'cjs'; +}): QueryDispatchError { + return { + kind: 'fallback_failure', + code: 1, + message: `Error: gsd-tools.cjs fallback failed: ${input.message}`, + details: fallbackErrorDetails({ + command: input.command, + args: input.args, + backend: input.backend ?? 'cjs', + }) as unknown as Record, + }; +} + +export function validationError(input: { + message: string; + code?: number; + details?: Record; +}): QueryDispatchError { + return { + kind: 'validation_error', + code: input.code ?? 10, + message: input.message, + details: input.details, + }; +} + +export function internalError(input: { + message: string; + code?: number; + details?: Record; +}): QueryDispatchError { + return { + kind: 'internal_error', + code: input.code ?? 1, + message: input.message, + details: input.details, + }; +} diff --git a/sdk/src/query/query-fallback-bridge-adapter.test.ts b/sdk/src/query/query-fallback-bridge-adapter.test.ts new file mode 100644 index 000000000..8db4525da --- /dev/null +++ b/sdk/src/query/query-fallback-bridge-adapter.test.ts @@ -0,0 +1,32 @@ +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 { runFallbackBridge } from './query-fallback-bridge-adapter.js'; + +describe('query-fallback-bridge-adapter', () => { + let tmpDir: string; + let fixtureDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `fallback-bridge-${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 }); + }); + + it('includes stderr text when bridge subprocess fails', async () => { + const scriptPath = join(fixtureDir, 'fail.cjs'); + await writeFile(scriptPath, "process.stderr.write('bridge boom'); process.exit(2);", { mode: 0o755 }); + + await expect(runFallbackBridge({ + projectDir: tmpDir, + gsdToolsPath: scriptPath, + normCmd: 'state', + normArgs: ['load'], + })).rejects.toThrow(/bridge boom/); + }); +}); diff --git a/sdk/src/query/query-fallback-bridge-adapter.ts b/sdk/src/query/query-fallback-bridge-adapter.ts new file mode 100644 index 000000000..9e4a03a24 --- /dev/null +++ b/sdk/src/query/query-fallback-bridge-adapter.ts @@ -0,0 +1,54 @@ +import { execFile } from 'node:child_process'; +import { classifyFallbackOutput } from './query-fallback-output-classifier.js'; + +export interface FallbackBridgeRunInput { + projectDir: string; + gsdToolsPath: string; + normCmd: string; + normArgs: string[]; + ws?: string; +} + +export interface FallbackBridgeOutput { + mode: 'json' | 'text'; + output: unknown; + stderr: string; +} + +function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] { + if (normCmd.includes('.')) return [...normCmd.split('.'), ...normArgs]; + return [normCmd, ...normArgs]; +} + +function execBridge(input: FallbackBridgeRunInput): Promise<{ stdout: string; stderr: string }> { + const cjsArgv = dottedCommandToCjsArgv(input.normCmd, input.normArgs); + const wsSuffix = input.ws ? ['--ws', input.ws] : []; + const fullArgv = [input.gsdToolsPath, ...cjsArgv, ...wsSuffix]; + + return new Promise((resolve, reject) => { + execFile( + process.execPath, + fullArgv, + { cwd: input.projectDir, maxBuffer: 10 * 1024 * 1024, timeout: 30_000, killSignal: 'SIGKILL', env: { ...process.env } }, + (err, stdout, stderr) => { + const stdoutText = stdout?.toString() ?? ''; + const stderrText = stderr?.toString() ?? ''; + if (err) { + if (stderrText.trim()) { + reject(new Error(`${err.message}\n${stderrText.trimEnd()}`)); + return; + } + reject(err); + return; + } + resolve({ stdout: stdoutText, stderr: stderrText }); + }, + ); + }); +} + +export async function runFallbackBridge(input: FallbackBridgeRunInput): Promise { + const { stdout, stderr } = await execBridge(input); + const classified = await classifyFallbackOutput(stdout, input.projectDir); + return { ...classified, stderr }; +} diff --git a/sdk/src/query/query-fallback-executor.ts b/sdk/src/query/query-fallback-executor.ts index 879d5960b..6bd029a1e 100644 --- a/sdk/src/query/query-fallback-executor.ts +++ b/sdk/src/query/query-fallback-executor.ts @@ -1,14 +1,8 @@ -import { execFile } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { extractField } from './registry.js'; +import { formatSuccess } from './query-dispatch-formatting.js'; import type { QueryDispatchResult } from './query-dispatch-contract.js'; import { mapFallbackDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js'; - -interface CjsFallbackQueryResult { - mode: 'json' | 'text'; - output: unknown; - stderr: string; -} +import { runFallbackBridge } from './query-fallback-bridge-adapter.js'; +import { fallbackBridgeNotices } from './query-dispatch-observability.js'; export interface RunCjsFallbackDispatchInput { projectDir: string; @@ -19,85 +13,21 @@ export interface RunCjsFallbackDispatchInput { 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`; + return formatSuccess(data, mode, pickField); } 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.', - ]; + const stderr = fallbackBridgeNotices(normCmd); try { - const fallback = await runCjsFallbackQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws); + const fallback = await runFallbackBridge({ projectDir, gsdToolsPath, normCmd, normArgs, ws }); if (fallback.stderr.trim()) stderr.push(fallback.stderr.trimEnd()); return { ok: true, @@ -106,9 +36,8 @@ export async function runCjsFallbackDispatch(input: RunCjsFallbackDispatchInput) exit_code: 0, }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); return toDispatchFailure( - mapFallbackDispatchError(msg, normCmd, normArgs), + mapFallbackDispatchError(err, normCmd, normArgs), stderr, ); } diff --git a/sdk/src/query/query-fallback-output-classifier.test.ts b/sdk/src/query/query-fallback-output-classifier.test.ts new file mode 100644 index 000000000..f2e1d0b81 --- /dev/null +++ b/sdk/src/query/query-fallback-output-classifier.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { classifyFallbackOutput } from './query-fallback-output-classifier.js'; + +describe('query-fallback-output-classifier', () => { + it('classifies json output', async () => { + const out = await classifyFallbackOutput('{"ok":true}', process.cwd()); + expect(out.mode).toBe('json'); + }); + + it('classifies text output on invalid json', async () => { + const out = await classifyFallbackOutput('USAGE', process.cwd()); + expect(out.mode).toBe('text'); + }); + + it('resolves @file json output', async () => { + const dir = await mkdtemp(join(tmpdir(), 'classifier-')); + try { + const file = join(dir, 'payload.json'); + await writeFile(file, '{"from":"file"}', 'utf-8'); + const out = await classifyFallbackOutput('@file:payload.json', dir); + expect(out.mode).toBe('json'); + expect(out.output).toEqual({ from: 'file' }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('classifies empty output as text', async () => { + const out = await classifyFallbackOutput(' ', process.cwd()); + expect(out.mode).toBe('text'); + expect(out.output).toBe(' '); + }); +}); diff --git a/sdk/src/query/query-fallback-output-classifier.ts b/sdk/src/query/query-fallback-output-classifier.ts new file mode 100644 index 000000000..f3dbe332e --- /dev/null +++ b/sdk/src/query/query-fallback-output-classifier.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises'; + +export interface FallbackOutputClassification { + mode: 'json' | 'text'; + output: unknown; +} + +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); +} + +export async function classifyFallbackOutput(raw: string, projectDir: string): Promise { + if (raw.trim() === '') { + return { mode: 'text', output: raw }; + } + try { + const output = await parseCliQueryJsonOutput(raw, projectDir); + return { mode: 'json', output }; + } catch { + return { mode: 'text', output: raw }; + } +} diff --git a/sdk/src/query/query-fallback-policy.test.ts b/sdk/src/query/query-fallback-policy.test.ts new file mode 100644 index 000000000..6aae3c85b --- /dev/null +++ b/sdk/src/query/query-fallback-policy.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { canUseCjsFallback, describeFallbackDisabledPolicy } from './query-fallback-policy.js'; + +describe('query-fallback-policy', () => { + it('describes disabled fallback policy', () => { + expect(describeFallbackDisabledPolicy()).toContain('GSD_QUERY_FALLBACK=registered'); + }); + + it('reports fallback capability', () => { + expect(canUseCjsFallback({ cjsFallbackEnabled: true })).toBe(true); + expect(canUseCjsFallback({ cjsFallbackEnabled: false })).toBe(false); + }); +}); diff --git a/sdk/src/query/query-fallback-policy.ts b/sdk/src/query/query-fallback-policy.ts new file mode 100644 index 000000000..3f59a1249 --- /dev/null +++ b/sdk/src/query/query-fallback-policy.ts @@ -0,0 +1,11 @@ +export interface FallbackPolicyState { + cjsFallbackEnabled: boolean; +} + +export function describeFallbackDisabledPolicy(): string { + return 'CJS fallback is disabled (GSD_QUERY_FALLBACK=registered).'; +} + +export function canUseCjsFallback(policy: FallbackPolicyState): boolean { + return policy.cjsFallbackEnabled; +} diff --git a/sdk/src/query/query-policy-capability.test.ts b/sdk/src/query/query-policy-capability.test.ts new file mode 100644 index 000000000..de7219435 --- /dev/null +++ b/sdk/src/query/query-policy-capability.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { QUERY_POLICY_SNAPSHOT, supportsMutationCommand, supportsRawOutputCommand } from './query-policy-capability.js'; + +describe('query-policy-capability', () => { + it('exposes snapshot + predicates', () => { + expect(QUERY_POLICY_SNAPSHOT.mutation_commands.length).toBeGreaterThan(0); + expect(supportsMutationCommand('state.update')).toBe(true); + expect(supportsRawOutputCommand('state.load')).toBe(true); + }); +}); diff --git a/sdk/src/query/query-policy-capability.ts b/sdk/src/query/query-policy-capability.ts new file mode 100644 index 000000000..bf5707a8d --- /dev/null +++ b/sdk/src/query/query-policy-capability.ts @@ -0,0 +1,27 @@ +import { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-command-semantics.js'; + +export const QUERY_POLICY_SNAPSHOT = { + mutation_commands: QUERY_MUTATION_COMMAND_LIST, + raw_output_commands: TRANSPORT_RAW_COMMANDS, +} as const; + +const MUTATION_SET = new Set(QUERY_POLICY_SNAPSHOT.mutation_commands); +const RAW_OUTPUT_SET = new Set(QUERY_POLICY_SNAPSHOT.raw_output_commands); + +export function supportsMutationCommand(command: string): boolean { + return MUTATION_SET.has(command); +} + +export function supportsRawOutputCommand(command: string): boolean { + return RAW_OUTPUT_SET.has(command); +} + +export { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +}; diff --git a/sdk/src/query/query-policy-snapshot.test.ts b/sdk/src/query/query-policy-snapshot.test.ts new file mode 100644 index 000000000..515eea103 --- /dev/null +++ b/sdk/src/query/query-policy-snapshot.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { QUERY_POLICY_SNAPSHOT, QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './query-policy-snapshot.js'; + +describe('query-policy-snapshot', () => { + it('exposes policy constants through one snapshot interface', () => { + expect(QUERY_POLICY_SNAPSHOT.mutation_commands).toBe(QUERY_MUTATION_COMMAND_LIST); + expect(QUERY_POLICY_SNAPSHOT.raw_output_commands).toBe(TRANSPORT_RAW_COMMANDS); + }); +}); diff --git a/sdk/src/query/query-policy-snapshot.ts b/sdk/src/query/query-policy-snapshot.ts new file mode 100644 index 000000000..7d0df6987 --- /dev/null +++ b/sdk/src/query/query-policy-snapshot.ts @@ -0,0 +1,6 @@ +export { + QUERY_POLICY_SNAPSHOT, + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-policy-capability.js'; diff --git a/sdk/src/query/query-registry-capability.test.ts b/sdk/src/query/query-registry-capability.test.ts new file mode 100644 index 000000000..bcb339b82 --- /dev/null +++ b/sdk/src/query/query-registry-capability.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { supportsMutationCommand, supportsRawOutputCommand } from './query-registry-capability.js'; + +describe('query-registry-capability', () => { + it('reports mutation command capability', () => { + expect(supportsMutationCommand('state.update')).toBe(true); + expect(supportsMutationCommand('state.json')).toBe(false); + }); + + it('reports raw output capability', () => { + expect(supportsRawOutputCommand('state.load')).toBe(true); + expect(supportsRawOutputCommand('state.json')).toBe(false); + }); +}); diff --git a/sdk/src/query/query-registry-capability.ts b/sdk/src/query/query-registry-capability.ts new file mode 100644 index 000000000..7521221f6 --- /dev/null +++ b/sdk/src/query/query-registry-capability.ts @@ -0,0 +1,4 @@ +export { + supportsMutationCommand, + supportsRawOutputCommand, +} from './query-policy-capability.js'; diff --git a/sdk/src/query/query-unknown-command-hints.test.ts b/sdk/src/query/query-unknown-command-hints.test.ts new file mode 100644 index 000000000..528f9d945 --- /dev/null +++ b/sdk/src/query/query-unknown-command-hints.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { UNKNOWN_COMMAND_HINTS } from './query-unknown-command-hints.js'; + +describe('query-unknown-command-hints', () => { + it('exports stable hint catalog', () => { + expect(UNKNOWN_COMMAND_HINTS.length).toBeGreaterThan(1); + expect(UNKNOWN_COMMAND_HINTS[0]).toContain('registered `gsd-sdk query`'); + }); +}); diff --git a/sdk/src/query/query-unknown-command-hints.ts b/sdk/src/query/query-unknown-command-hints.ts new file mode 100644 index 000000000..09be616dd --- /dev/null +++ b/sdk/src/query/query-unknown-command-hints.ts @@ -0,0 +1,5 @@ +export const UNKNOWN_COMMAND_HINTS: readonly string[] = [ + 'Use a registered `gsd-sdk query` subcommand (see sdk/src/query/QUERY-HANDLERS.md).', + 'Invoke `node …/gsd-tools.cjs` for CJS-only operations.', + 'Unset GSD_QUERY_FALLBACK or set it to a non-restricted value to enable fallback.', +] as const; diff --git a/sdk/src/query/registry-assembly-invariants.ts b/sdk/src/query/registry-assembly-invariants.ts index d33a13604..2773179a6 100644 --- a/sdk/src/query/registry-assembly-invariants.ts +++ b/sdk/src/query/registry-assembly-invariants.ts @@ -20,11 +20,17 @@ export interface RegistryAssemblyInputs { rawOutputPolicyCommands: readonly string[]; } -function toSortedList(values: Iterable): string[] { - return Array.from(values).sort((a, b) => a.localeCompare(b)); +export interface RegistryAssemblyInvariantReport { + duplicateCommandKeys: string[]; + aliasCanonicalsMissingHandlers: string[]; + missingMutationCommands: string[]; + missingRawOutputPolicyCommands: string[]; } -export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInputs): void { +export function collectRegistryAssemblyInvariantReport( + inputs: RegistryAssemblyInputs, + registry?: QueryRegistry, +): RegistryAssemblyInvariantReport { const counts = new Map(); for (const group of inputs.staticGroups) { @@ -42,28 +48,51 @@ export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInpu } } - const duplicates = toSortedList( + const duplicateCommandKeys = 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(', ')}`); + const aliasCanonicalsMissingHandlers: string[] = []; + for (const group of inputs.aliasGroups) { + for (const entry of group.aliases) { + if (!group.handlers[entry.canonical]) { + aliasCanonicalsMissingHandlers.push(`${group.family}:${entry.canonical}`); + } + } + } + + const missingMutationCommands = registry + ? toSortedList(Array.from(inputs.mutationCommands).filter((command) => !registry.has(command))) + : []; + const missingRawOutputPolicyCommands = registry + ? toSortedList(inputs.rawOutputPolicyCommands.filter((command) => !registry.has(command))) + : []; + + return { + duplicateCommandKeys, + aliasCanonicalsMissingHandlers: toSortedList(aliasCanonicalsMissingHandlers), + missingMutationCommands, + missingRawOutputPolicyCommands, + }; +} + +function toSortedList(values: Iterable): string[] { + return Array.from(values).sort((a, b) => a.localeCompare(b)); +} + +export function assertNoDuplicateRegisteredCommands(inputs: RegistryAssemblyInputs): void { + const report = collectRegistryAssemblyInvariantReport(inputs); + if (report.duplicateCommandKeys.length > 0) { + throw new Error(`registry assembly invariant failed: duplicate command keys: ${report.duplicateCommandKeys.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(', ')}`); + const report = collectRegistryAssemblyInvariantReport(inputs); + if (report.aliasCanonicalsMissingHandlers.length > 0) { + throw new Error(`registry assembly invariant failed: alias canonical missing handler: ${report.aliasCanonicalsMissingHandlers.join(', ')}`); } } @@ -71,9 +100,14 @@ 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(', ')}`); + const report = collectRegistryAssemblyInvariantReport({ + staticGroups: [], + aliasGroups: [], + mutationCommands, + rawOutputPolicyCommands: [], + }, registry); + if (report.missingMutationCommands.length > 0) { + throw new Error(`registry assembly invariant failed: mutation command missing from registry: ${report.missingMutationCommands.join(', ')}`); } } @@ -81,8 +115,13 @@ 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(', ')}`); + const report = collectRegistryAssemblyInvariantReport({ + staticGroups: [], + aliasGroups: [], + mutationCommands: new Set(), + rawOutputPolicyCommands, + }, registry); + if (report.missingRawOutputPolicyCommands.length > 0) { + throw new Error(`registry assembly invariant failed: raw-output policy command missing from registry: ${report.missingRawOutputPolicyCommands.join(', ')}`); } } diff --git a/sdk/src/query/registry-assembly.test.ts b/sdk/src/query/registry-assembly.test.ts index 9fe40ffa4..a3a12d359 100644 --- a/sdk/src/query/registry-assembly.test.ts +++ b/sdk/src/query/registry-assembly.test.ts @@ -11,6 +11,7 @@ import { assertMutationCommandsRegistered, assertNoDuplicateRegisteredCommands, assertRawOutputPolicyCommandsRegistered, + collectRegistryAssemblyInvariantReport, type RegistryAssemblyAliasGroup, type RegistryAssemblyStaticGroup, } from './registry-assembly-invariants.js'; @@ -106,4 +107,26 @@ describe('registry assembly invariants', () => { expect(() => assertMutationCommandsRegistered(registry, new Set(['one']))).not.toThrow(); expect(() => assertRawOutputPolicyCommandsRegistered(registry, ['canon'])).not.toThrow(); }); + + it('collects invariant report for all failure classes', () => { + const registry = new QueryRegistry(); + const report = collectRegistryAssemblyInvariantReport({ + staticGroups: [ + { name: 'S1', entries: [['dup', noop]] }, + { name: 'S2', entries: [['dup', noop]] }, + ], + aliasGroups: [ + { family: 'f', aliases: [{ canonical: 'missing', aliases: ['dup'] }], handlers: {} }, + ], + mutationCommands: new Set(['missing.mutation']), + rawOutputPolicyCommands: ['missing.raw'], + }, registry); + + expect(report).toEqual({ + duplicateCommandKeys: ['dup'], + aliasCanonicalsMissingHandlers: ['f:missing'], + missingMutationCommands: ['missing.mutation'], + missingRawOutputPolicyCommands: ['missing.raw'], + }); + }); }); diff --git a/sdk/src/query/registry-assembly.ts b/sdk/src/query/registry-assembly.ts index aabf89ae2..d2d713a96 100644 --- a/sdk/src/query/registry-assembly.ts +++ b/sdk/src/query/registry-assembly.ts @@ -1,13 +1,6 @@ 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 type { AliasCatalogEntry } from './command-catalog.js'; +import type { CommandFamily } from './command-manifest.types.js'; import { GSDEventStream } from '../event-stream.js'; import type { QueryHandler } from './utils.js'; import { registerAliasCatalog, registerStaticCatalog } from './command-catalog.js'; @@ -20,6 +13,7 @@ import { } 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 { COMMAND_DEFINITIONS_BY_FAMILY, type CommandDefinition } from './command-definition.js'; import { decorateMutationsWithEvents } from './mutation-event-decorator.js'; import { FAMILY_HANDLERS } from './command-family-handlers.js'; import { @@ -45,16 +39,45 @@ const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [ { name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG }, ] as const; +function toAliasCatalogEntry(entry: CommandDefinition): AliasCatalogEntry { + return { + canonical: entry.canonical, + aliases: entry.aliases, + }; +} + +function buildAliasGroup(family: CommandFamily): RegistryAssemblyAliasGroup { + const definitions = COMMAND_DEFINITIONS_BY_FAMILY[family]; + const familyHandlers = FAMILY_HANDLERS[family] as Readonly>; + const handlers: Record = {}; + + for (const entry of definitions) { + const handler = familyHandlers[entry.handler_key]; + if (!handler) continue; + handlers[entry.canonical] = handler; + } + + return { + family, + aliases: definitions.map(toAliasCatalogEntry), + handlers, + }; +} + 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 }, + buildAliasGroup('state'), + buildAliasGroup('roadmap'), + buildAliasGroup('verify'), + buildAliasGroup('validate'), + buildAliasGroup('phase'), + buildAliasGroup('phases'), + buildAliasGroup('init'), ] as const; +const ALIAS_GROUP_BY_FAMILY = Object.fromEntries( + ALIAS_GROUPS.map((group) => [group.family, group]), +) as Readonly>; + export function buildRegistry(): QueryRegistry { assertAliasCanonicalsHaveHandlers({ staticGroups: STATIC_CATALOG_GROUPS, @@ -72,25 +95,25 @@ export function buildRegistry(): QueryRegistry { const registry = new QueryRegistry(); registerStaticCatalog(registry, FOUNDATION_STATIC_CATALOG); - registerAliasCatalog(registry, STATE_COMMAND_ALIASES, FAMILY_HANDLERS.state as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.state.aliases, ALIAS_GROUP_BY_FAMILY.state.handlers); registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG); - registerAliasCatalog(registry, ROADMAP_COMMAND_ALIASES, FAMILY_HANDLERS.roadmap as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.roadmap.aliases, ALIAS_GROUP_BY_FAMILY.roadmap.handlers); registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG); - registerAliasCatalog(registry, VERIFY_COMMAND_ALIASES, FAMILY_HANDLERS.verify as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.verify.aliases, ALIAS_GROUP_BY_FAMILY.verify.handlers); registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG); - registerAliasCatalog(registry, VALIDATE_COMMAND_ALIASES, FAMILY_HANDLERS.validate as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.validate.aliases, ALIAS_GROUP_BY_FAMILY.validate.handlers); registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG); - registerAliasCatalog(registry, PHASE_COMMAND_ALIASES, FAMILY_HANDLERS.phase as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phase.aliases, ALIAS_GROUP_BY_FAMILY.phase.handlers); - registerAliasCatalog(registry, PHASES_COMMAND_ALIASES, FAMILY_HANDLERS.phases as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phases.aliases, ALIAS_GROUP_BY_FAMILY.phases.handlers); - registerAliasCatalog(registry, INIT_COMMAND_ALIASES, FAMILY_HANDLERS.init as Record); + registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.init.aliases, ALIAS_GROUP_BY_FAMILY.init.handlers); registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG);