refactor: deepen query architecture seams with compatibility shims

This commit is contained in:
Tom Boucher
2026-05-04 21:16:09 -04:00
parent c6a35d6398
commit 0500bdf619
18 changed files with 488 additions and 337 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<Record<CommandFamily, reado
roadmap: byFamily('roadmap'),
} as const;
export const FAMILY_MUTATION_COMMANDS: readonly string[] = COMMAND_DEFINITIONS
export const COMMAND_DEFINITION_BY_CANONICAL: Readonly<Record<string, CommandDefinition>> = Object.fromEntries(
COMMAND_DEFINITIONS.map((entry) => [entry.canonical, entry]),
);
export const COMMAND_MUTATION_SET: ReadonlySet<string> = new Set(
COMMAND_DEFINITIONS.filter((entry) => entry.mutation).flatMap((entry) => [entry.canonical, ...entry.aliases]),
);
export const COMMAND_RAW_OUTPUT_SET: ReadonlySet<string> = 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);

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
return toDispatchFailure(error, stderr);
}

View File

@@ -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);
}

View File

@@ -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<Record<string, QueryHandler>>;
const handlers: Record<string, QueryHandler> = {};
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<Record<string, RegistryAssemblyStaticGroup>>;
export const ALIAS_GROUP_BY_FAMILY = Object.fromEntries(
ALIAS_GROUPS.map((group) => [group.family, group]),
) as Readonly<Record<CommandFamily, RegistryAssemblyAliasGroup>>;
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;

View File

@@ -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', () => {

View File

@@ -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<string>(QUERY_MUTATION_COMMAND_LIST);
const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [
{ name: 'FOUNDATION_STATIC_CATALOG', entries: FOUNDATION_STATIC_CATALOG },
{ name: 'STATE_SUPPORT_STATIC_CATALOG', entries: STATE_SUPPORT_STATIC_CATALOG },
{ name: 'MUTATION_SURFACES_STATIC_CATALOG', entries: MUTATION_SURFACES_STATIC_CATALOG },
{ name: 'VERIFY_DECISION_STATIC_CATALOG', entries: VERIFY_DECISION_STATIC_CATALOG },
{ name: 'DECISION_ROUTING_STATIC_CATALOG', entries: DECISION_ROUTING_STATIC_CATALOG },
{ name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG },
] as const;
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<Record<string, QueryHandler>>;
const handlers: Record<string, QueryHandler> = {};
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<Record<CommandFamily, RegistryAssemblyAliasGroup>>;
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);