From 0500bdf6195b592817de8125a2c5b4b121a7ee99 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:16:09 -0400 Subject: [PATCH] refactor: deepen query architecture seams with compatibility shims --- .changeset/plucky-pandas-sprint.md | 5 + sdk/src/query/command-definition.test.ts | 26 ++- sdk/src/query/command-definition.ts | 38 ++++- sdk/src/query/command-manifest.non-family.ts | 66 ++++++++ sdk/src/query/command-topology.ts | 38 ++++- sdk/src/query/query-command-diagnosis.ts | 37 +---- sdk/src/query/query-command-semantics.test.ts | 22 +++ sdk/src/query/query-command-semantics.ts | 47 ++---- sdk/src/query/query-dispatch-error-mapper.ts | 24 +-- sdk/src/query/query-dispatch-formatting.ts | 21 +-- .../query/query-dispatch-input-validation.ts | 54 +------ sdk/src/query/query-dispatch-plan.ts | 52 +----- .../query/query-dispatch-result-builder.ts | 24 +-- sdk/src/query/query-dispatch.ts | 150 +++++++++++++++++- sdk/src/query/query-policy-capability.ts | 27 ++-- sdk/src/query/registry-assembly-descriptor.ts | 87 ++++++++++ sdk/src/query/registry-assembly.test.ts | 6 + sdk/src/query/registry-assembly.ts | 101 +++--------- 18 files changed, 488 insertions(+), 337 deletions(-) create mode 100644 .changeset/plucky-pandas-sprint.md create mode 100644 sdk/src/query/command-manifest.non-family.ts create mode 100644 sdk/src/query/query-command-semantics.test.ts create mode 100644 sdk/src/query/registry-assembly-descriptor.ts diff --git a/.changeset/plucky-pandas-sprint.md b/.changeset/plucky-pandas-sprint.md new file mode 100644 index 000000000..33188ca32 --- /dev/null +++ b/.changeset/plucky-pandas-sprint.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3107 +--- +Query module architecture deepened with compatibility-preserving seams — command policy now derives from command definitions, and dispatch/topology/registry seams are consolidated for better locality while preserving existing query behavior. diff --git a/sdk/src/query/command-definition.test.ts b/sdk/src/query/command-definition.test.ts index 814a4a508..c8e872927 100644 --- a/sdk/src/query/command-definition.test.ts +++ b/sdk/src/query/command-definition.test.ts @@ -1,10 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { COMMAND_DEFINITIONS, COMMAND_DEFINITIONS_BY_FAMILY, FAMILY_MUTATION_COMMANDS } from './command-definition.js'; +import { + COMMAND_DEFINITIONS, + COMMAND_DEFINITIONS_BY_FAMILY, + FAMILY_MUTATION_COMMANDS, + COMMAND_DEFINITION_BY_CANONICAL, + COMMAND_MUTATION_SET, + COMMAND_RAW_OUTPUT_SET, +} from './command-definition.js'; import { COMMAND_MANIFEST } from './command-manifest.js'; +import { NON_FAMILY_COMMAND_MANIFEST } from './command-manifest.non-family.js'; describe('command-definition module', () => { it('exposes canonical metadata with handler_key normalization contract', () => { - expect(COMMAND_DEFINITIONS).toHaveLength(COMMAND_MANIFEST.length); + expect(COMMAND_DEFINITIONS).toHaveLength(COMMAND_MANIFEST.length + NON_FAMILY_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); @@ -15,11 +23,11 @@ describe('command-definition module', () => { } }); - it('keeps family index canonicals in sync with flat list', () => { + it('keeps family index canonicals in sync with family definitions', () => { const indexed = Object.values(COMMAND_DEFINITIONS_BY_FAMILY).flat(); - expect(indexed).toHaveLength(COMMAND_DEFINITIONS.length); + expect(indexed).toHaveLength(COMMAND_MANIFEST.length); expect(indexed.map((entry) => entry.canonical).sort()).toEqual( - [...COMMAND_DEFINITIONS.map((entry) => entry.canonical)].sort(), + [...COMMAND_MANIFEST.map((entry) => entry.canonical)].sort(), ); }); @@ -28,4 +36,12 @@ describe('command-definition module', () => { expect(FAMILY_MUTATION_COMMANDS).toContain('phase complete'); expect(FAMILY_MUTATION_COMMANDS).toContain('roadmap.update-plan-progress'); }); + + it('exposes indexed views for policy consumers', () => { + expect(COMMAND_DEFINITION_BY_CANONICAL['state.load']?.canonical).toBe('state.load'); + expect(COMMAND_MUTATION_SET.has('state.update')).toBe(true); + expect(COMMAND_MUTATION_SET.has('state.json')).toBe(false); + expect(COMMAND_RAW_OUTPUT_SET.has('commit')).toBe(true); + expect(COMMAND_RAW_OUTPUT_SET.has('verify summary')).toBe(true); + }); }); diff --git a/sdk/src/query/command-definition.ts b/sdk/src/query/command-definition.ts index c64503675..993a70a79 100644 --- a/sdk/src/query/command-definition.ts +++ b/sdk/src/query/command-definition.ts @@ -1,16 +1,17 @@ import { COMMAND_MANIFEST } from './command-manifest.js'; +import { NON_FAMILY_COMMAND_MANIFEST } from './command-manifest.non-family.js'; import type { CommandFamily, OutputMode } from './command-manifest.types.js'; export interface CommandDefinition { - family: CommandFamily; + family?: CommandFamily; canonical: string; aliases: string[]; mutation: boolean; output_mode: OutputMode; - handler_key: string; + handler_key?: string; } -export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFEST.map((entry) => ({ +const FAMILY_COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFEST.map((entry) => ({ family: entry.family, canonical: entry.canonical, aliases: [...entry.aliases], @@ -19,6 +20,18 @@ export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFES handler_key: entry.handlerKey ?? entry.canonical, })) as readonly CommandDefinition[]; +const NON_FAMILY_COMMAND_DEFINITIONS: readonly CommandDefinition[] = NON_FAMILY_COMMAND_MANIFEST.map((entry) => ({ + canonical: entry.canonical, + aliases: [...entry.aliases], + mutation: entry.mutation, + output_mode: entry.outputMode, +})) as readonly CommandDefinition[]; + +export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = [ + ...FAMILY_COMMAND_DEFINITIONS, + ...NON_FAMILY_COMMAND_DEFINITIONS, +] as const; + function byFamily(family: CommandFamily): readonly CommandDefinition[] { return COMMAND_DEFINITIONS.filter((entry) => entry.family === family); } @@ -33,10 +46,25 @@ export const COMMAND_DEFINITIONS_BY_FAMILY: Readonly> = Object.fromEntries( + COMMAND_DEFINITIONS.map((entry) => [entry.canonical, entry]), +); + +export const COMMAND_MUTATION_SET: ReadonlySet = new Set( + COMMAND_DEFINITIONS.filter((entry) => entry.mutation).flatMap((entry) => [entry.canonical, ...entry.aliases]), +); + +export const COMMAND_RAW_OUTPUT_SET: ReadonlySet = new Set( + COMMAND_DEFINITIONS.filter((entry) => entry.output_mode === 'raw').flatMap((entry) => [entry.canonical, ...entry.aliases]), +); + +export const FAMILY_MUTATION_COMMANDS: readonly string[] = FAMILY_COMMAND_DEFINITIONS .filter((entry) => entry.mutation) .flatMap((entry) => [entry.canonical, ...entry.aliases]); -export const FAMILY_RAW_OUTPUT_COMMANDS: readonly string[] = COMMAND_DEFINITIONS +export const FAMILY_RAW_OUTPUT_COMMANDS: readonly string[] = FAMILY_COMMAND_DEFINITIONS .filter((entry) => entry.output_mode === 'raw') .flatMap((entry) => [entry.canonical, ...entry.aliases]); + +export const QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS: readonly string[] = Array.from(COMMAND_MUTATION_SET); +export const TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS: readonly string[] = Array.from(COMMAND_RAW_OUTPUT_SET); diff --git a/sdk/src/query/command-manifest.non-family.ts b/sdk/src/query/command-manifest.non-family.ts new file mode 100644 index 000000000..223d244cd --- /dev/null +++ b/sdk/src/query/command-manifest.non-family.ts @@ -0,0 +1,66 @@ +import type { OutputMode } from './command-manifest.types.js'; + +export interface NonFamilyCommandManifestEntry { + canonical: string; + aliases: string[]; + mutation: boolean; + outputMode: OutputMode; +} + +export const NON_FAMILY_COMMAND_MANIFEST: readonly NonFamilyCommandManifestEntry[] = [ + { canonical: 'frontmatter.set', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'frontmatter.merge', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'frontmatter.validate', aliases: ['frontmatter validate'], mutation: true, outputMode: 'json' }, + + { canonical: 'commit', aliases: [], mutation: true, outputMode: 'raw' }, + { canonical: 'config-set', aliases: [], mutation: true, outputMode: 'raw' }, + { canonical: 'config-set-model-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'config-new-project', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'config-ensure-section', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'check-commit', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'commit-to-subrepo', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'template.fill', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'template.select', aliases: ['template select'], mutation: true, outputMode: 'json' }, + + { canonical: 'requirements.mark-complete', aliases: ['requirements mark-complete'], mutation: true, outputMode: 'json' }, + { canonical: 'todo.complete', aliases: ['todo complete'], mutation: true, outputMode: 'json' }, + { canonical: 'milestone.complete', aliases: ['milestone complete'], mutation: true, outputMode: 'json' }, + + { + canonical: 'workstream.create', + aliases: ['workstream create'], + mutation: true, + outputMode: 'json', + }, + { canonical: 'workstream.set', aliases: ['workstream set'], mutation: true, outputMode: 'json' }, + { + canonical: 'workstream.complete', + aliases: ['workstream complete'], + mutation: true, + outputMode: 'json', + }, + { + canonical: 'workstream.progress', + aliases: ['workstream progress'], + mutation: true, + outputMode: 'json', + }, + + { canonical: 'docs-init', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'learnings.copy', aliases: ['learnings copy'], mutation: true, outputMode: 'json' }, + { canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true, outputMode: 'json' }, + { canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true, outputMode: 'json' }, + + { canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true, outputMode: 'json' }, + { canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true, outputMode: 'json' }, + + { canonical: 'write-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-claude-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-dev-preferences', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-claude-md', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'verify-summary', aliases: ['verify.summary', 'verify summary'], mutation: false, outputMode: 'raw' }, +] as const; diff --git a/sdk/src/query/command-topology.ts b/sdk/src/query/command-topology.ts index b0148889c..0be256fe0 100644 --- a/sdk/src/query/command-topology.ts +++ b/sdk/src/query/command-topology.ts @@ -1,8 +1,13 @@ import type { QueryRegistry } from './registry.js'; import type { QueryHandler } from './utils.js'; -import { resolveQueryCommand } from './query-command-resolution-strategy.js'; -import { diagnoseUnknownCommand } from './query-command-diagnosis.js'; +import { + resolveQueryCommand, + explainQueryCommandNoMatch, + type QueryCommandRegistryLike, +} from './query-command-resolution-strategy.js'; import { supportsMutationCommand, supportsRawOutputCommand } from './query-policy-capability.js'; +import { UNKNOWN_COMMAND_HINTS } from './query-unknown-command-hints.js'; +import { describeFallbackDisabledPolicy } from './query-fallback-policy.js'; export type CommandTopologyOutputMode = 'json' | 'text' | 'raw'; @@ -29,6 +34,35 @@ export interface CommandTopology { resolve(tokens: string[], fallbackRestricted?: boolean): CommandTopologyResult; } +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, + }; +} + export function createCommandTopology(registry: QueryRegistry): CommandTopology { return { resolve(tokens: string[], fallbackRestricted = false): CommandTopologyResult { diff --git a/sdk/src/query/query-command-diagnosis.ts b/sdk/src/query/query-command-diagnosis.ts index ef8ee8cd9..fc36b0ac9 100644 --- a/sdk/src/query/query-command-diagnosis.ts +++ b/sdk/src/query/query-command-diagnosis.ts @@ -1,32 +1,5 @@ -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, - }; -} +/** + * @deprecated Compatibility seam after Command Topology Module deepening. + * Remove-after: all imports migrate to `command-topology.ts`. + */ +export { diagnoseUnknownCommand, type UnknownCommandDiagnosis } from './command-topology.js'; diff --git a/sdk/src/query/query-command-semantics.test.ts b/sdk/src/query/query-command-semantics.test.ts new file mode 100644 index 000000000..9e7b0bfb2 --- /dev/null +++ b/sdk/src/query/query-command-semantics.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS, + TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS, +} from './command-definition.js'; +import { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-command-semantics.js'; + +describe('query-command-semantics compatibility seam', () => { + it('keeps legacy exports derived from command definitions', () => { + expect(QUERY_MUTATION_COMMAND_LIST).toBe(QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS); + expect(TRANSPORT_RAW_COMMANDS).toBe(TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS); + }); + + it('classifies mutation status through derived set', () => { + expect(isQueryMutationCommand('state.update')).toBe(true); + expect(isQueryMutationCommand('state.json')).toBe(false); + }); +}); diff --git a/sdk/src/query/query-command-semantics.ts b/sdk/src/query/query-command-semantics.ts index 9d4625ccf..eab534dc3 100644 --- a/sdk/src/query/query-command-semantics.ts +++ b/sdk/src/query/query-command-semantics.ts @@ -1,42 +1,13 @@ -import { FAMILY_MUTATION_COMMANDS, FAMILY_RAW_OUTPUT_COMMANDS } from './command-definition.js'; +/** + * @deprecated Legacy compatibility seam. + * Prefer importing policy and indexed views from `query-policy-capability` or `command-definition`. + */ -export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [ - ...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', - '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; - -const NON_FAMILY_RAW_OUTPUT_COMMANDS = [ - 'commit', - 'config-set', - 'verify-summary', - 'verify.summary', - '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 { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-policy-capability.js'; export { normalizeQueryCommand, diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index bd377be19..1b193c567 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,19 +1,5 @@ -import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; -import { toFailureSignal } from '../query-failure-classification.js'; -import { fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from './query-error-taxonomy.js'; -import { dispatchFailure } from './query-dispatch-result-builder.js'; - -export function toDispatchFailure( - error: QueryDispatchError, - stderr: string[] = [], -): QueryDispatchResult { - return dispatchFailure(error, stderr); -} - -export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); -} - -export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { toDispatchFailure, mapNativeDispatchError, mapFallbackDispatchError } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-formatting.ts b/sdk/src/query/query-dispatch-formatting.ts index 3b8cac188..67893476c 100644 --- a/sdk/src/query/query-dispatch-formatting.ts +++ b/sdk/src/query/query-dispatch-formatting.ts @@ -1,16 +1,5 @@ -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`; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { formatPick, formatSuccess, type DispatchSuccessFormat } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-input-validation.ts b/sdk/src/query/query-dispatch-input-validation.ts index e0ded9c22..74384ce07 100644 --- a/sdk/src/query/query-dispatch-input-validation.ts +++ b/sdk/src/query/query-dispatch-input-validation.ts @@ -1,49 +1,5 @@ -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 }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { validateQueryDispatchInput, type DispatchInputValidationResult } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-plan.ts b/sdk/src/query/query-dispatch-plan.ts index 3f627cdb4..ad067ec7c 100644 --- a/sdk/src/query/query-dispatch-plan.ts +++ b/sdk/src/query/query-dispatch-plan.ts @@ -1,47 +1,5 @@ -import { normalizeQueryCommand } from './query-command-resolution-strategy.js'; -import type { CommandTopology, CommandTopologyMatch } from './command-topology.js'; - -export type DispatchMode = 'native' | 'cjs' | 'error'; - -export interface DispatchPlan { - mode: DispatchMode; - normalized: { command: string; args: string[]; tokens: string[] }; - matched: CommandTopologyMatch | null; - noMatchMessage?: string; - noMatchNormalized?: string; - noMatchAttempted?: string[]; - noMatchHints?: string[]; -} - -export function planQueryDispatch( - queryArgv: string[], - topology: CommandTopology, - 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 resolved = topology.resolve(queryArgv, !cjsFallbackEnabled); - - if (resolved.kind === 'match') { - return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved }; - } - - 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, - noMatchMessage: resolved.message, - noMatchNormalized: resolved.normalized, - noMatchAttempted: resolved.attempted, - noMatchHints: resolved.hints, - }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { planQueryDispatch, type DispatchMode, type DispatchPlan } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-result-builder.ts b/sdk/src/query/query-dispatch-result-builder.ts index 07f8ad3b9..8fa5d55be 100644 --- a/sdk/src/query/query-dispatch-result-builder.ts +++ b/sdk/src/query/query-dispatch-result-builder.ts @@ -1,19 +1,5 @@ -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, - }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { dispatchFailure, dispatchSuccess } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch.ts b/sdk/src/query/query-dispatch.ts index d1286b114..4349ff087 100644 --- a/sdk/src/query/query-dispatch.ts +++ b/sdk/src/query/query-dispatch.ts @@ -1,16 +1,14 @@ import type { QueryRegistry } from './registry.js'; +import { extractField } from './registry.js'; +import { normalizeQueryCommand } from './query-command-resolution-strategy.js'; import { runCjsFallbackDispatch } from './query-fallback-executor.js'; -import type { QueryDispatchResult } from './query-dispatch-contract.js'; +import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; import type { QueryResult } from './utils.js'; import type { QueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js'; -import type { CommandTopology } from './command-topology.js'; -import { mapFallbackDispatchError, mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js'; -import { formatSuccess } from './query-dispatch-formatting.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 type { CommandTopology, CommandTopologyMatch } from './command-topology.js'; +import { unknownCommandError, validationError, fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from './query-error-taxonomy.js'; import { canUseCjsFallback } from './query-fallback-policy.js'; +import { toFailureSignal } from '../query-failure-classification.js'; export interface QueryDispatchDeps { registry: QueryRegistry; @@ -25,6 +23,142 @@ export interface QueryDispatchDeps { topology: CommandTopology; } +export type DispatchMode = 'native' | 'cjs' | 'error'; + +export interface DispatchPlan { + mode: DispatchMode; + normalized: { command: string; args: string[]; tokens: string[] }; + matched: CommandTopologyMatch | null; + noMatchMessage?: string; + noMatchNormalized?: string; + noMatchAttempted?: string[]; + noMatchHints?: string[]; +} + +export type DispatchSuccessFormat = 'json' | 'text' | undefined; + +export interface DispatchInputValidationResult { + queryArgs: string[]; + pickField?: string; + error?: QueryDispatchResult; +} + +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, + }; +} + +export function toDispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { + return dispatchFailure(error, stderr); +} + +export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { + return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); +} + +export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { + return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); +} + +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`; +} + +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 }; +} + +export function planQueryDispatch( + queryArgv: string[], + topology: CommandTopology, + 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 resolved = topology.resolve(queryArgv, !cjsFallbackEnabled); + + if (resolved.kind === 'match') { + return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved }; + } + + 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, + noMatchMessage: resolved.message, + noMatchNormalized: resolved.normalized, + noMatchAttempted: resolved.attempted, + noMatchHints: resolved.hints, + }; +} + function fail(error: ReturnType | ReturnType, stderr: string[] = []): QueryDispatchResult { return toDispatchFailure(error, stderr); } diff --git a/sdk/src/query/query-policy-capability.ts b/sdk/src/query/query-policy-capability.ts index bf5707a8d..afdef07a2 100644 --- a/sdk/src/query/query-policy-capability.ts +++ b/sdk/src/query/query-policy-capability.ts @@ -1,27 +1,26 @@ import { - QUERY_MUTATION_COMMAND_LIST, - TRANSPORT_RAW_COMMANDS, - isQueryMutationCommand, -} from './query-command-semantics.js'; + QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS, + TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS, + COMMAND_MUTATION_SET, + COMMAND_RAW_OUTPUT_SET, +} from './command-definition.js'; + +export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS; +export const TRANSPORT_RAW_COMMANDS: readonly string[] = TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS; 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); + return COMMAND_MUTATION_SET.has(command); } export function supportsRawOutputCommand(command: string): boolean { - return RAW_OUTPUT_SET.has(command); + return COMMAND_RAW_OUTPUT_SET.has(command); } -export { - QUERY_MUTATION_COMMAND_LIST, - TRANSPORT_RAW_COMMANDS, - isQueryMutationCommand, -}; +export function isQueryMutationCommand(command: string): boolean { + return COMMAND_MUTATION_SET.has(command); +} diff --git a/sdk/src/query/registry-assembly-descriptor.ts b/sdk/src/query/registry-assembly-descriptor.ts new file mode 100644 index 000000000..ad76c667a --- /dev/null +++ b/sdk/src/query/registry-assembly-descriptor.ts @@ -0,0 +1,87 @@ +import type { AliasCatalogEntry } from './command-catalog.js'; +import type { CommandFamily } from './command-manifest.types.js'; +import type { QueryHandler } from './utils.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 { COMMAND_DEFINITIONS_BY_FAMILY, type CommandDefinition } from './command-definition.js'; +import { FAMILY_HANDLERS } from './command-family-handlers.js'; +import type { RegistryAssemblyAliasGroup, RegistryAssemblyStaticGroup } from './registry-assembly-invariants.js'; + +export interface RegistryAssemblyStep { + kind: 'static' | 'alias'; + key: string; +} + +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, + }; +} + +export 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; + +export const ALIAS_GROUPS: readonly RegistryAssemblyAliasGroup[] = [ + buildAliasGroup('state'), + buildAliasGroup('roadmap'), + buildAliasGroup('verify'), + buildAliasGroup('validate'), + buildAliasGroup('phase'), + buildAliasGroup('phases'), + buildAliasGroup('init'), +] as const; + +export const STATIC_GROUP_BY_NAME = Object.fromEntries( + STATIC_CATALOG_GROUPS.map((group) => [group.name, group]), +) as Readonly>; + +export const ALIAS_GROUP_BY_FAMILY = Object.fromEntries( + ALIAS_GROUPS.map((group) => [group.family, group]), +) as Readonly>; + +export const REGISTRY_ASSEMBLY_PLAN: readonly RegistryAssemblyStep[] = [ + { kind: 'static', key: 'FOUNDATION_STATIC_CATALOG' }, + { kind: 'alias', key: 'state' }, + { kind: 'static', key: 'STATE_SUPPORT_STATIC_CATALOG' }, + { kind: 'alias', key: 'roadmap' }, + { kind: 'static', key: 'MUTATION_SURFACES_STATIC_CATALOG' }, + { kind: 'alias', key: 'verify' }, + { kind: 'static', key: 'VERIFY_DECISION_STATIC_CATALOG' }, + { kind: 'alias', key: 'validate' }, + { kind: 'static', key: 'DECISION_ROUTING_STATIC_CATALOG' }, + { kind: 'alias', key: 'phase' }, + { kind: 'alias', key: 'phases' }, + { kind: 'alias', key: 'init' }, + { kind: 'static', key: 'DOMAIN_STATIC_CATALOG' }, +] as const; diff --git a/sdk/src/query/registry-assembly.test.ts b/sdk/src/query/registry-assembly.test.ts index a3a12d359..fd29f5d72 100644 --- a/sdk/src/query/registry-assembly.test.ts +++ b/sdk/src/query/registry-assembly.test.ts @@ -15,6 +15,7 @@ import { type RegistryAssemblyAliasGroup, type RegistryAssemblyStaticGroup, } from './registry-assembly-invariants.js'; +import { REGISTRY_ASSEMBLY_PLAN } from './registry-assembly-descriptor.js'; const noop = async () => ({ data: null }); @@ -44,6 +45,11 @@ describe('registry assembly', () => { expect(registry.has(command), `missing mutation command: ${command}`).toBe(true); } }); + + it('uses declarative registry assembly plan', () => { + expect(REGISTRY_ASSEMBLY_PLAN.length).toBeGreaterThan(0); + expect(REGISTRY_ASSEMBLY_PLAN[0]).toEqual({ kind: 'static', key: 'FOUNDATION_STATIC_CATALOG' }); + }); }); describe('registry assembly invariants', () => { diff --git a/sdk/src/query/registry-assembly.ts b/sdk/src/query/registry-assembly.ts index e84ae22b2..5d677687d 100644 --- a/sdk/src/query/registry-assembly.ts +++ b/sdk/src/query/registry-assembly.ts @@ -1,28 +1,20 @@ import { QueryRegistry } from './registry.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'; -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 './query-policy-capability.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 { + STATIC_CATALOG_GROUPS, + ALIAS_GROUPS, + STATIC_GROUP_BY_NAME, + ALIAS_GROUP_BY_FAMILY, + REGISTRY_ASSEMBLY_PLAN, +} from './registry-assembly-descriptor.js'; import { assertAliasCanonicalsHaveHandlers, assertMutationCommandsRegistered, assertNoDuplicateRegisteredCommands, assertRawOutputPolicyCommandsRegistered, - type RegistryAssemblyAliasGroup, - type RegistryAssemblyStaticGroup, } from './registry-assembly-invariants.js'; /** @@ -30,53 +22,6 @@ import { */ 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; - -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[] = [ - 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({ @@ -94,28 +39,18 @@ export function buildRegistry(): QueryRegistry { const registry = new QueryRegistry(); - registerStaticCatalog(registry, FOUNDATION_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.state.aliases, ALIAS_GROUP_BY_FAMILY.state.handlers); + for (const step of REGISTRY_ASSEMBLY_PLAN) { + if (step.kind === 'static') { + const group = STATIC_GROUP_BY_NAME[step.key]; + if (!group) throw new Error(`registry assembly invariant failed: unknown static group: ${step.key}`); + registerStaticCatalog(registry, group.entries); + continue; + } - registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.roadmap.aliases, ALIAS_GROUP_BY_FAMILY.roadmap.handlers); - - registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.verify.aliases, ALIAS_GROUP_BY_FAMILY.verify.handlers); - - registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.validate.aliases, ALIAS_GROUP_BY_FAMILY.validate.handlers); - - registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phase.aliases, ALIAS_GROUP_BY_FAMILY.phase.handlers); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phases.aliases, ALIAS_GROUP_BY_FAMILY.phases.handlers); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.init.aliases, ALIAS_GROUP_BY_FAMILY.init.handlers); - - registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG); + const group = ALIAS_GROUP_BY_FAMILY[step.key as keyof typeof ALIAS_GROUP_BY_FAMILY]; + if (!group) throw new Error(`registry assembly invariant failed: unknown alias group: ${step.key}`); + registerAliasCatalog(registry, group.aliases, group.handlers); + } assertMutationCommandsRegistered(registry, QUERY_MUTATION_COMMANDS); assertRawOutputPolicyCommandsRegistered(registry, TRANSPORT_RAW_COMMANDS);