refactor(query): deepen command/dispatch seams and resolve coderabbit findings

* refactor(query): deepen command definition seam and fold fallback mapping cleanup

* refactor(query): add shared dispatch formatting module seam

* fix(query): restore QueryResult type import in dispatch deps

* test/query: align raw-output policy and definition normalization contracts

* refactor(query): deepen diagnosis, invariant report, and error taxonomy seams

* refactor(query): deepen dispatch plan, fallback bridge, policy snapshot, and hints seams

* refactor(query): deepen validation, fallback policy, capability, and result builder seams

* refactor(query): deepen resolution strategy, output classifier, observability, and policy-capability seams

* refactor(query): finalize deep strategy/classifier/observability/capability seams

* test/query: address coderabbit inline and out-of-diff dispatch nits

* fix(query): address remaining coderabbit input-validation and bridge stderr threads

* fix(query): address remaining coderabbit dispatch and strategy/output nits
This commit is contained in:
Tom Boucher
2026-05-03 15:29:34 -04:00
committed by GitHub
parent c3f896f311
commit b6c401dc90
49 changed files with 1159 additions and 484 deletions

View File

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

View File

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

View File

@@ -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<void> {
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<void> {
'export const VALIDATE_SUBCOMMANDS = new Set<string>(VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand));',
'export const ROADMAP_SUBCOMMANDS = new Set<string>(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');
}

View File

@@ -105,18 +105,3 @@ export const PHASES_SUBCOMMANDS = new Set<string>(PHASES_COMMAND_ALIASES.map((en
export const VALIDATE_SUBCOMMANDS = new Set<string>(VALIDATE_COMMAND_ALIASES.map((entry) => entry.subcommand));
export const ROADMAP_SUBCOMMANDS = new Set<string>(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]);

View File

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

View File

@@ -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<Record<CommandFamily, readonly CommandDefinition[]>> = {
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]);

View File

@@ -8,4 +8,6 @@ export interface CommandManifestEntry {
aliases: string[];
mutation: boolean;
outputMode: OutputMode;
/** Optional explicit handler key (defaults to canonical). */
handlerKey?: string;
}

View File

@@ -7,4 +7,4 @@ export {
type QueryResolutionSource,
explainQueryCommandNoMatch,
type QueryCommandNoMatch,
} from './query-command-semantics.js';
} from './query-command-resolution-strategy.js';

View File

@@ -1 +1 @@
export { normalizeQueryCommand } from './query-command-semantics.js';
export { normalizeQueryCommand } from './query-command-resolution-strategy.js';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string>([
'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 },
};
}

View File

@@ -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<string>([
'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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.',
];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<QueryResult>;
}
type DispatchMode = 'native' | 'cjs' | 'error';
interface DispatchPlan {
mode: DispatchMode;
normalized: { command: string; args: string[]; tokens: string[] };
matched: QueryCommandResolution | null;
function fail(error: ReturnType<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
return toDispatchFailure(error, stderr);
}
function fail(
kind: QueryDispatchErrorKind,
code: number,
message: string,
details?: Record<string, unknown>,
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<QueryDispatchResult> {
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));
}

View File

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

View File

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

View File

@@ -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<string, unknown>,
};
}
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<string, unknown>,
};
}
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<string, unknown>,
};
}
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<string, unknown>,
};
}
export function validationError(input: {
message: string;
code?: number;
details?: Record<string, unknown>;
}): 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<string, unknown>;
}): QueryDispatchError {
return {
kind: 'internal_error',
code: input.code ?? 1,
message: input.message,
details: input.details,
};
}

View File

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

View File

@@ -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<FallbackBridgeOutput> {
const { stdout, stderr } = await execBridge(input);
const classified = await classifyFallbackOutput(stdout, input.projectDir);
return { ...classified, stderr };
}

View File

@@ -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<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
async function runCjsFallbackQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<CjsFallbackQueryResult> {
const { stdout, stderr } = await execGsdToolsCjsQuery(projectDir, gsdToolsPath, normCmd, normArgs, ws);
try {
const output = await parseCliQueryJsonOutput(stdout, projectDir);
return { mode: 'json', output, stderr };
} catch {
return { mode: 'text', output: stdout, stderr };
}
}
function formatFallbackOutput(data: unknown, mode: 'json' | 'text', pickField?: string): string | undefined {
if (mode === 'text') {
const text = String(data ?? '');
if (!text.trim()) return undefined;
return text.endsWith('\n') ? text : `${text}\n`;
}
let output: unknown = data;
if (pickField) output = extractField(output, pickField);
return `${JSON.stringify(output, null, 2)}\n`;
return formatSuccess(data, mode, pickField);
}
export async function runCjsFallbackDispatch(input: RunCjsFallbackDispatchInput): Promise<QueryDispatchResult> {
const { projectDir, gsdToolsPath, normCmd, normArgs, ws, pickField } = input;
const stderr = [
`[gsd-sdk] '${normCmd}' not in native registry; falling back to gsd-tools.cjs.`,
'[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.',
];
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,
);
}

View File

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

View File

@@ -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<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
export async function classifyFallbackOutput(raw: string, projectDir: string): Promise<FallbackOutputClassification> {
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 };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export {
QUERY_POLICY_SNAPSHOT,
QUERY_MUTATION_COMMAND_LIST,
TRANSPORT_RAW_COMMANDS,
isQueryMutationCommand,
} from './query-policy-capability.js';

View File

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

View File

@@ -0,0 +1,4 @@
export {
supportsMutationCommand,
supportsRawOutputCommand,
} from './query-policy-capability.js';

View File

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

View File

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

View File

@@ -20,11 +20,17 @@ export interface RegistryAssemblyInputs {
rawOutputPolicyCommands: readonly string[];
}
function toSortedList(values: Iterable<string>): 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<string, number>();
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>): 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<string>,
): void {
const missing = toSortedList(Array.from(mutationCommands).filter((command) => !registry.has(command)));
if (missing.length > 0) {
throw new Error(`registry assembly invariant failed: mutation command missing from registry: ${missing.join(', ')}`);
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<string>(),
rawOutputPolicyCommands,
}, registry);
if (report.missingRawOutputPolicyCommands.length > 0) {
throw new Error(`registry assembly invariant failed: raw-output policy command missing from registry: ${report.missingRawOutputPolicyCommands.join(', ')}`);
}
}

View File

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

View File

@@ -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<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[] = [
{ family: 'state', aliases: STATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.state as Record<string, QueryHandler> },
{ family: 'roadmap', aliases: ROADMAP_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.roadmap as Record<string, QueryHandler> },
{ family: 'verify', aliases: VERIFY_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.verify as Record<string, QueryHandler> },
{ family: 'validate', aliases: VALIDATE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.validate as Record<string, QueryHandler> },
{ family: 'phase', aliases: PHASE_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phase as Record<string, QueryHandler> },
{ family: 'phases', aliases: PHASES_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.phases as Record<string, QueryHandler> },
{ family: 'init', aliases: INIT_COMMAND_ALIASES, handlers: FAMILY_HANDLERS.init as Record<string, QueryHandler> },
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({
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<string, QueryHandler>);
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<string, QueryHandler>);
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<string, QueryHandler>);
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<string, QueryHandler>);
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<string, QueryHandler>);
registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phase.aliases, ALIAS_GROUP_BY_FAMILY.phase.handlers);
registerAliasCatalog(registry, PHASES_COMMAND_ALIASES, FAMILY_HANDLERS.phases as Record<string, QueryHandler>);
registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phases.aliases, ALIAS_GROUP_BY_FAMILY.phases.handlers);
registerAliasCatalog(registry, INIT_COMMAND_ALIASES, FAMILY_HANDLERS.init as Record<string, QueryHandler>);
registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.init.aliases, ALIAS_GROUP_BY_FAMILY.init.handlers);
registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG);