Merge pull request #3108 from gsd-build/feat/deepen-query-failure-classification
refactor: deepen query architecture seams with compatibility shims
This commit is contained in:
5
.changeset/plucky-pandas-sprint.md
Normal file
5
.changeset/plucky-pandas-sprint.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3108
|
||||
---
|
||||
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.
|
||||
21
sdk/src/gsd-tools-error.test.ts
Normal file
21
sdk/src/gsd-tools-error.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
describe('GSDToolsError constructors', () => {
|
||||
it('builds timeout-classified errors', () => {
|
||||
const err = GSDToolsError.timeout('timeout', 'state', ['load'], '', 1000);
|
||||
expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1000 });
|
||||
expect(err.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it('builds failure-classified errors', () => {
|
||||
const err = GSDToolsError.failure('boom', 'state', ['load'], 1);
|
||||
expect(err.classification).toEqual({ kind: 'failure' });
|
||||
expect(err.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults direct constructor to failure classification', () => {
|
||||
const err = new GSDToolsError('boom', 'state', ['load'], 1, 'stderr');
|
||||
expect(err.classification).toEqual({ kind: 'failure' });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,16 @@
|
||||
export interface GSDToolsErrorClassification {
|
||||
kind: 'timeout' | 'failure';
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function timeoutClassification(timeoutMs?: number): GSDToolsErrorClassification {
|
||||
return timeoutMs === undefined ? { kind: 'timeout' } : { kind: 'timeout', timeoutMs };
|
||||
}
|
||||
|
||||
function failureClassification(): GSDToolsErrorClassification {
|
||||
return { kind: 'failure' };
|
||||
}
|
||||
|
||||
export class GSDToolsError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -5,9 +18,48 @@ export class GSDToolsError extends Error {
|
||||
public readonly args: string[],
|
||||
public readonly exitCode: number | null,
|
||||
public readonly stderr: string,
|
||||
options?: { cause?: unknown },
|
||||
options?: { cause?: unknown; classification?: GSDToolsErrorClassification },
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'GSDToolsError';
|
||||
this.classification = options?.classification ?? failureClassification();
|
||||
}
|
||||
|
||||
static timeout(
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
stderr = '',
|
||||
timeoutMs?: number,
|
||||
options?: { cause?: unknown; exitCode?: number | null },
|
||||
): GSDToolsError {
|
||||
return new GSDToolsError(
|
||||
message,
|
||||
command,
|
||||
args,
|
||||
options?.exitCode ?? null,
|
||||
stderr,
|
||||
{ cause: options?.cause, classification: timeoutClassification(timeoutMs) },
|
||||
);
|
||||
}
|
||||
|
||||
static failure(
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
exitCode: number | null,
|
||||
stderr = '',
|
||||
options?: { cause?: unknown },
|
||||
): GSDToolsError {
|
||||
return new GSDToolsError(
|
||||
message,
|
||||
command,
|
||||
args,
|
||||
exitCode,
|
||||
stderr,
|
||||
{ cause: options?.cause, classification: failureClassification() },
|
||||
);
|
||||
}
|
||||
|
||||
public readonly classification: GSDToolsErrorClassification;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis } from './types.js';
|
||||
import type { GSDEventStream } from './event-stream.js';
|
||||
import { toGSDToolsError } from './query-tools-error-mapper.js';
|
||||
import { toToolsErrorFromUnknown } from './query-tools-error-factory.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { resolveQueryCommand, type QueryCommandResolution } from './query/query-command-resolution-strategy.js';
|
||||
import { QueryExecutionPolicy } from './query-execution-policy.js';
|
||||
@@ -107,10 +107,6 @@ export class GSDTools {
|
||||
return resolveQueryCommand(command, args, this.registry);
|
||||
}
|
||||
|
||||
private toToolsError(command: string, args: string[], err: unknown): GSDToolsError {
|
||||
return toGSDToolsError(command, args, err);
|
||||
}
|
||||
|
||||
private async dispatchNativeHotpath(
|
||||
legacyCommand: string,
|
||||
legacyArgs: string[],
|
||||
@@ -118,17 +114,22 @@ export class GSDTools {
|
||||
registryArgs: string[],
|
||||
mode: 'json' | 'raw',
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await this.nativeHotpathAdapter.dispatch(
|
||||
return this.executeWithToolsError(legacyCommand, legacyArgs, () =>
|
||||
this.nativeHotpathAdapter.dispatch(
|
||||
legacyCommand,
|
||||
legacyArgs,
|
||||
registryCommand,
|
||||
registryArgs,
|
||||
mode,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
private async executeWithToolsError<T>(command: string, args: string[], work: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await work();
|
||||
} catch (err) {
|
||||
if (err instanceof GSDToolsError) throw err;
|
||||
throw this.toToolsError(legacyCommand, legacyArgs, err);
|
||||
throw toToolsErrorFromUnknown(command, args, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +140,7 @@ export class GSDTools {
|
||||
* Handles the `@file:` prefix pattern for large results.
|
||||
*/
|
||||
async exec(command: string, args: string[] = []): Promise<unknown> {
|
||||
try {
|
||||
return await this.commandExecutor.exec(command, args, 'json');
|
||||
} catch (err) {
|
||||
if (err instanceof GSDToolsError) throw err;
|
||||
throw this.toToolsError(command, args, err);
|
||||
}
|
||||
return this.executeWithToolsError(command, args, () => this.commandExecutor.exec(command, args, 'json'));
|
||||
}
|
||||
|
||||
// ─── Raw exec (no JSON parsing) ───────────────────────────────────────
|
||||
@@ -154,12 +150,10 @@ export class GSDTools {
|
||||
* Use for commands like `config-set` that return plain text, not JSON.
|
||||
*/
|
||||
async execRaw(command: string, args: string[] = []): Promise<string> {
|
||||
try {
|
||||
return await this.commandExecutor.exec(command, args, 'raw') as string;
|
||||
} catch (err) {
|
||||
if (err instanceof GSDToolsError) throw err;
|
||||
throw this.toToolsError(command, args, err);
|
||||
}
|
||||
return this.executeWithToolsError(command, args, async () => {
|
||||
const out = await this.commandExecutor.exec(command, args, 'raw');
|
||||
return typeof out === 'string' ? out : String(out ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { QueryRegistry } from './query/registry.js';
|
||||
import { GSDTransport } from './gsd-transport.js';
|
||||
|
||||
@@ -119,6 +120,36 @@ describe('GSDTransport', () => {
|
||||
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fallback after typed timeout native error', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
registry.register('state.load', async () => ({ data: { ok: true } }));
|
||||
|
||||
const timeoutError = GSDToolsError.timeout('native timed out', 'state', ['load'], '', 500);
|
||||
const adapters = {
|
||||
dispatchNative: vi.fn(async () => {
|
||||
throw timeoutError;
|
||||
}),
|
||||
execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })),
|
||||
execSubprocessRaw: vi.fn(async () => 'fallback-raw'),
|
||||
};
|
||||
|
||||
const transport = new GSDTransport(registry, adapters);
|
||||
|
||||
await expect(transport.run({
|
||||
legacyCommand: 'state',
|
||||
legacyArgs: ['load'],
|
||||
registryCommand: 'state.load',
|
||||
registryArgs: [],
|
||||
mode: 'json',
|
||||
projectDir: '/tmp',
|
||||
}, {
|
||||
preferNative: true,
|
||||
allowFallbackToSubprocess: true,
|
||||
})).rejects.toBe(timeoutError);
|
||||
|
||||
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats native raw output via formatNativeRaw when provided', async () => {
|
||||
const registry = new QueryRegistry();
|
||||
registry.register('commit', async () => ({ data: { hash: 'abc123' } }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { QueryResult } from './query/utils.js';
|
||||
import type { QueryRegistry } from './query/registry.js';
|
||||
import type { TransportMode } from './gsd-transport-policy.js';
|
||||
import { toFailureSignal } from './query-failure-classification.js';
|
||||
|
||||
export interface TransportRequest {
|
||||
legacyCommand: string;
|
||||
@@ -24,12 +25,6 @@ export interface TransportPolicyLike {
|
||||
allowFallbackToSubprocess: boolean;
|
||||
}
|
||||
|
||||
function isTimeoutLikeError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === 'TimeoutError' || error.name === 'AbortError') return true;
|
||||
return error.message.includes('timed out after');
|
||||
}
|
||||
|
||||
export class GSDTransport {
|
||||
constructor(
|
||||
private readonly registry: QueryRegistry,
|
||||
@@ -37,33 +32,48 @@ export class GSDTransport {
|
||||
) {}
|
||||
|
||||
async run(request: TransportRequest, policy: TransportPolicyLike): Promise<unknown> {
|
||||
const forceSubprocess = Boolean(request.workstream);
|
||||
|
||||
if (!forceSubprocess && policy.preferNative && this.registry.has(request.registryCommand)) {
|
||||
if (this.shouldUseNative(request, policy)) {
|
||||
try {
|
||||
const native = await this.adapters.dispatchNative(request);
|
||||
if (request.mode === 'raw') {
|
||||
if (this.adapters.formatNativeRaw) {
|
||||
return this.adapters.formatNativeRaw(request.registryCommand, native.data).trim();
|
||||
}
|
||||
return this.toRaw(native.data);
|
||||
}
|
||||
return native.data;
|
||||
return this.projectNativeOutput(request, native.data);
|
||||
} catch (error) {
|
||||
if (!policy.allowFallbackToSubprocess) throw error;
|
||||
// Do not subprocess-fallback after a timed-out native dispatch:
|
||||
// the timeout does not cancel the native handler, so falling through
|
||||
// would run the same command twice (double-execution race).
|
||||
if (isTimeoutLikeError(error)) throw error;
|
||||
if (this.shouldRethrowNativeError(error, policy)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dispatchSubprocess(request);
|
||||
}
|
||||
|
||||
private shouldUseNative(request: TransportRequest, policy: TransportPolicyLike): boolean {
|
||||
const forceSubprocess = Boolean(request.workstream);
|
||||
return !forceSubprocess && policy.preferNative && this.registry.has(request.registryCommand);
|
||||
}
|
||||
|
||||
private shouldRethrowNativeError(error: unknown, policy: TransportPolicyLike): boolean {
|
||||
if (!policy.allowFallbackToSubprocess) return true;
|
||||
// Do not subprocess-fallback after a timed-out native dispatch:
|
||||
// the timeout does not cancel the native handler, so falling through
|
||||
// would run the same command twice (double-execution race).
|
||||
return toFailureSignal(error).kind === 'timeout';
|
||||
}
|
||||
|
||||
private dispatchSubprocess(request: TransportRequest): Promise<unknown> {
|
||||
if (request.mode === 'raw') {
|
||||
return this.adapters.execSubprocessRaw(request.legacyCommand, request.legacyArgs);
|
||||
}
|
||||
return this.adapters.execSubprocessJson(request.legacyCommand, request.legacyArgs);
|
||||
}
|
||||
|
||||
private projectNativeOutput(request: TransportRequest, data: unknown): unknown {
|
||||
if (request.mode === 'raw') {
|
||||
if (this.adapters.formatNativeRaw) {
|
||||
return this.adapters.formatNativeRaw(request.registryCommand, data).trim();
|
||||
}
|
||||
return this.toRaw(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private toRaw(data: unknown): string {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
||||
23
sdk/src/query-failure-classification.test.ts
Normal file
23
sdk/src/query-failure-classification.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
errorMessage,
|
||||
timeoutMessage,
|
||||
toFailureSignal,
|
||||
} from './query-failure-classification.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
describe('query failure classification', () => {
|
||||
it('extracts timeout metadata from message', () => {
|
||||
const msg = timeoutMessage('state', ['load'], 30000);
|
||||
expect(toFailureSignal(new Error(msg))).toEqual({ kind: 'timeout', message: msg, timeoutMs: 30000 });
|
||||
});
|
||||
it('normalizes unknown error values', () => {
|
||||
expect(errorMessage('boom')).toBe('boom');
|
||||
expect(errorMessage(new Error('x'))).toBe('x');
|
||||
});
|
||||
|
||||
it('prefers typed classification from GSDToolsError', () => {
|
||||
const err = GSDToolsError.timeout('x', 'state', ['load'], '', 2000);
|
||||
expect(toFailureSignal(err)).toEqual({ kind: 'timeout', message: 'x', timeoutMs: 2000 });
|
||||
});
|
||||
});
|
||||
42
sdk/src/query-failure-classification.ts
Normal file
42
sdk/src/query-failure-classification.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
export interface QueryFailureSignal {
|
||||
kind: 'timeout' | 'failure';
|
||||
message: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function parseTimeoutMs(message: string): number | undefined {
|
||||
const m = message.match(/timed out after\s+(\d+)ms/i);
|
||||
if (!m) return undefined;
|
||||
const n = Number.parseInt(m[1], 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function isTimeoutMessage(message: string): boolean {
|
||||
return /timed out after/i.test(message);
|
||||
}
|
||||
|
||||
export function timeoutMessage(command: string, args: string[], timeoutMs: number): string {
|
||||
return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`;
|
||||
}
|
||||
|
||||
export function toFailureSignal(error: unknown): QueryFailureSignal {
|
||||
if (error instanceof GSDToolsError && error.classification) {
|
||||
return {
|
||||
kind: error.classification.kind,
|
||||
message: error.message,
|
||||
timeoutMs: error.classification.timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
const message = errorMessage(error);
|
||||
if (isTimeoutMessage(message)) {
|
||||
return { kind: 'timeout', message, timeoutMs: parseTimeoutMs(message) };
|
||||
}
|
||||
return { kind: 'failure', message };
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { GSDEventStream } from './event-stream.js';
|
||||
import { createRegistry } from './query/index.js';
|
||||
import type { QueryResult } from './query/utils.js';
|
||||
import { GSDTransport } from './gsd-transport.js';
|
||||
import { QueryExecutionPolicy } from './query-execution-policy.js';
|
||||
import { QuerySubprocessAdapter } from './query-subprocess-adapter.js';
|
||||
import { QueryNativeDirectAdapter } from './query-native-direct-adapter.js';
|
||||
import { QueryNativeHotpathAdapter } from './query-native-hotpath-adapter.js';
|
||||
import { formatQueryRawOutput } from './query-raw-output-projection.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { createQueryNativeErrorFactory, createQueryToolsErrorFactory } from './query-tools-error-factory.js';
|
||||
|
||||
export interface GSDToolsRuntime {
|
||||
registry: ReturnType<typeof createRegistry>;
|
||||
@@ -28,30 +27,33 @@ export function createGSDToolsRuntime(opts: {
|
||||
}): GSDToolsRuntime {
|
||||
const registry = createRegistry(opts.eventStream, opts.sessionId);
|
||||
|
||||
const queryToolsErrorFactory = createQueryToolsErrorFactory();
|
||||
|
||||
const subprocessAdapter = new QuerySubprocessAdapter({
|
||||
projectDir: opts.projectDir,
|
||||
gsdToolsPath: opts.gsdToolsPath,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
workstream: opts.workstream,
|
||||
createToolsError: (message, command, args, exitCode, stderr) =>
|
||||
new GSDToolsError(message, command, args, exitCode, stderr),
|
||||
...queryToolsErrorFactory,
|
||||
});
|
||||
|
||||
const nativeErrorFactory = createQueryNativeErrorFactory(opts.timeoutMs);
|
||||
|
||||
const nativeDirectAdapter = new QueryNativeDirectAdapter({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir),
|
||||
createTimeoutError: (message, command, args) => new GSDToolsError(message, command, args, null, ''),
|
||||
...nativeErrorFactory,
|
||||
});
|
||||
|
||||
const transport = new GSDTransport(registry, {
|
||||
dispatchNative: async (request) => nativeDirectAdapter.dispatchResult(
|
||||
dispatchNative: (request) => nativeDirectAdapter.dispatchResult(
|
||||
request.legacyCommand,
|
||||
request.legacyArgs,
|
||||
request.registryCommand,
|
||||
request.registryArgs,
|
||||
) as Promise<QueryResult>,
|
||||
execSubprocessJson: async (legacyCommand, legacyArgs) => subprocessAdapter.execJson(legacyCommand, legacyArgs),
|
||||
execSubprocessRaw: async (legacyCommand, legacyArgs) => subprocessAdapter.execRaw(legacyCommand, legacyArgs),
|
||||
),
|
||||
execSubprocessJson: (legacyCommand, legacyArgs) => subprocessAdapter.execJson(legacyCommand, legacyArgs),
|
||||
execSubprocessRaw: (legacyCommand, legacyArgs) => subprocessAdapter.execRaw(legacyCommand, legacyArgs),
|
||||
formatNativeRaw: (registryCommand, data) => formatQueryRawOutput(registryCommand, data),
|
||||
});
|
||||
|
||||
|
||||
35
sdk/src/query-native-direct-adapter.test.ts
Normal file
35
sdk/src/query-native-direct-adapter.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { QueryNativeDirectAdapter } from './query-native-direct-adapter.js';
|
||||
|
||||
describe('QueryNativeDirectAdapter', () => {
|
||||
it('wraps native failures as typed failure errors', async () => {
|
||||
const adapter = new QueryNativeDirectAdapter({
|
||||
timeoutMs: 1000,
|
||||
dispatch: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
createNativeTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args),
|
||||
createNativeFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }),
|
||||
});
|
||||
|
||||
await expect(adapter.dispatchJson('state', ['load'], 'state.load', [])).rejects.toMatchObject({
|
||||
classification: GSDToolsError.failure('x', 'state', ['load'], 1).classification,
|
||||
command: 'state',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves timeout errors', async () => {
|
||||
const timeoutErr = GSDToolsError.timeout('timeout', 'state', ['load']);
|
||||
const adapter = new QueryNativeDirectAdapter({
|
||||
timeoutMs: 1000,
|
||||
dispatch: async () => {
|
||||
throw timeoutErr;
|
||||
},
|
||||
createNativeTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args),
|
||||
createNativeFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }),
|
||||
});
|
||||
|
||||
await expect(adapter.dispatchJson('state', ['load'], 'state.load', [])).rejects.toBe(timeoutErr);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { formatQueryRawOutput } from './query-raw-output-projection.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { errorMessage, timeoutMessage } from './query-failure-classification.js';
|
||||
import type { QueryNativeErrorFactory } from './query-tools-error-factory.js';
|
||||
import type { QueryResult } from './query/utils.js';
|
||||
|
||||
export interface QueryNativeDirectAdapterDeps {
|
||||
export interface QueryNativeDirectAdapterDeps extends QueryNativeErrorFactory {
|
||||
timeoutMs: number;
|
||||
dispatch: (registryCommand: string, registryArgs: string[]) => Promise<QueryResult>;
|
||||
createTimeoutError: (message: string, command: string, args: string[]) => Error;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,17 +16,35 @@ export class QueryNativeDirectAdapter {
|
||||
constructor(private readonly deps: QueryNativeDirectAdapterDeps) {}
|
||||
|
||||
async dispatchResult(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise<QueryResult> {
|
||||
return this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs));
|
||||
try {
|
||||
return await this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs));
|
||||
} catch (error) {
|
||||
throw this.toNativeDispatchError(legacyCommand, legacyArgs, error);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchJson(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise<unknown> {
|
||||
return this.dispatchData(legacyCommand, legacyArgs, registryCommand, registryArgs);
|
||||
}
|
||||
|
||||
async dispatchRaw(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise<string> {
|
||||
const data = await this.dispatchData(legacyCommand, legacyArgs, registryCommand, registryArgs);
|
||||
return formatQueryRawOutput(registryCommand, data).trim();
|
||||
}
|
||||
|
||||
private async dispatchData(
|
||||
legacyCommand: string,
|
||||
legacyArgs: string[],
|
||||
registryCommand: string,
|
||||
registryArgs: string[],
|
||||
): Promise<unknown> {
|
||||
const result = await this.dispatchResult(legacyCommand, legacyArgs, registryCommand, registryArgs);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async dispatchRaw(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise<string> {
|
||||
const result = await this.dispatchResult(legacyCommand, legacyArgs, registryCommand, registryArgs);
|
||||
return formatQueryRawOutput(registryCommand, result.data).trim();
|
||||
private toNativeDispatchError(legacyCommand: string, legacyArgs: string[], error: unknown): GSDToolsError {
|
||||
if (error instanceof GSDToolsError) return error;
|
||||
return this.deps.createNativeFailureError(errorMessage(error), legacyCommand, legacyArgs, error);
|
||||
}
|
||||
|
||||
private async withTimeout<T>(legacyCommand: string, legacyArgs: string[], work: Promise<T>): Promise<T> {
|
||||
@@ -32,8 +52,8 @@ export class QueryNativeDirectAdapter {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
this.deps.createTimeoutError(
|
||||
`gsd-tools timed out after ${this.deps.timeoutMs}ms: ${legacyCommand} ${legacyArgs.join(' ')}`,
|
||||
this.deps.createNativeTimeoutError(
|
||||
timeoutMessage(legacyCommand, legacyArgs, this.deps.timeoutMs),
|
||||
legacyCommand,
|
||||
legacyArgs,
|
||||
),
|
||||
|
||||
@@ -19,11 +19,25 @@ export class QueryNativeHotpathAdapter {
|
||||
mode: 'json' | 'raw',
|
||||
): Promise<unknown> {
|
||||
if (!this.shouldUseNativeQuery()) {
|
||||
return mode === 'raw'
|
||||
? this.execRawFallback(legacyCommand, legacyArgs)
|
||||
: this.execJsonFallback(legacyCommand, legacyArgs);
|
||||
return this.dispatchFallback(legacyCommand, legacyArgs, mode);
|
||||
}
|
||||
|
||||
return this.dispatchNative(legacyCommand, legacyArgs, registryCommand, registryArgs, mode);
|
||||
}
|
||||
|
||||
private dispatchFallback(legacyCommand: string, legacyArgs: string[], mode: 'json' | 'raw'): Promise<unknown> {
|
||||
return mode === 'raw'
|
||||
? this.execRawFallback(legacyCommand, legacyArgs)
|
||||
: this.execJsonFallback(legacyCommand, legacyArgs);
|
||||
}
|
||||
|
||||
private dispatchNative(
|
||||
legacyCommand: string,
|
||||
legacyArgs: string[],
|
||||
registryCommand: string,
|
||||
registryArgs: string[],
|
||||
mode: 'json' | 'raw',
|
||||
): Promise<unknown> {
|
||||
return mode === 'raw'
|
||||
? this.nativeDirect.dispatchRaw(legacyCommand, legacyArgs, registryCommand, registryArgs)
|
||||
: this.nativeDirect.dispatchJson(legacyCommand, legacyArgs, registryCommand, registryArgs);
|
||||
|
||||
@@ -31,4 +31,9 @@ describe('formatQueryRawOutput', () => {
|
||||
expect(formatQueryRawOutput('state begin-phase', { updated: ['x'] })).toBe('true');
|
||||
expect(formatQueryRawOutput('state begin-phase', { updated: [] })).toBe('false');
|
||||
});
|
||||
|
||||
it('never returns undefined for non-JSON top-level values', () => {
|
||||
expect(formatQueryRawOutput('commit', undefined)).toBe('undefined');
|
||||
expect(formatQueryRawOutput('commit', Symbol('x'))).toBe('Symbol(x)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { formatStateLoadRawStdout } from './query/state-project-load.js';
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
const out = JSON.stringify(value, null, 2);
|
||||
return out ?? String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw output projection for native query results.
|
||||
* Owns CLI-facing string contracts for raw mode commands.
|
||||
@@ -11,7 +16,7 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
|
||||
|
||||
if (registryCommand === 'commit') {
|
||||
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
const d = data as Record<string, unknown>;
|
||||
if (d.committed === true) {
|
||||
@@ -32,12 +37,12 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
|
||||
}
|
||||
return r || 'nothing';
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
|
||||
if (registryCommand === 'config-set') {
|
||||
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
const d = data as Record<string, unknown>;
|
||||
if ((d.updated === true || d.set === true) && d.key !== undefined) {
|
||||
@@ -50,12 +55,12 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
|
||||
}
|
||||
return `${d.key}=${String(v)}`;
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
|
||||
if (registryCommand === 'state.begin-phase' || registryCommand === 'state begin-phase') {
|
||||
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
const d = data as Record<string, unknown>;
|
||||
const u = d.updated as string[] | undefined;
|
||||
@@ -65,5 +70,5 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
return safeStringify(data);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ describe('QuerySubprocessAdapter', () => {
|
||||
projectDir: dir,
|
||||
gsdToolsPath,
|
||||
timeoutMs: 2_000,
|
||||
createToolsError: (message, command, args, exitCode, stderr) =>
|
||||
createTimeoutError: (message, command, args, stderr) =>
|
||||
new FakeToolsError(message, command, args, null, stderr) as never,
|
||||
createFailureError: (message, command, args, exitCode, stderr) =>
|
||||
new FakeToolsError(message, command, args, exitCode, stderr) as never,
|
||||
});
|
||||
}
|
||||
@@ -62,6 +64,17 @@ describe('QuerySubprocessAdapter', () => {
|
||||
await expect(adapter.execJson('state', ['load'])).resolves.toEqual({ from: 'file' });
|
||||
});
|
||||
|
||||
it('execJson resolves relative @file output against projectDir', async () => {
|
||||
const relDir = join(dir, '.planning');
|
||||
await mkdir(relDir, { recursive: true });
|
||||
const relFile = join(relDir, 'out.json');
|
||||
await writeFile(relFile, JSON.stringify({ from: 'relative-file' }));
|
||||
const script = await createScript('file-relative.cjs', `process.stdout.write('@file:.planning/out.json');`);
|
||||
const adapter = createAdapter(script);
|
||||
|
||||
await expect(adapter.execJson('state', ['load'])).resolves.toEqual({ from: 'relative-file' });
|
||||
});
|
||||
|
||||
it('execRaw returns trimmed stdout', async () => {
|
||||
const script = await createScript('raw.cjs', `process.stdout.write(' hello ');`);
|
||||
const adapter = createAdapter(script);
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { timeoutMessage } from './query-failure-classification.js';
|
||||
import type { QueryToolsErrorFactory } from './query-tools-error-factory.js';
|
||||
|
||||
export interface QuerySubprocessAdapterDeps {
|
||||
export interface QuerySubprocessAdapterDeps extends QueryToolsErrorFactory {
|
||||
projectDir: string;
|
||||
gsdToolsPath: string;
|
||||
timeoutMs: number;
|
||||
workstream?: string;
|
||||
createToolsError: (
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
) => GSDToolsError;
|
||||
}
|
||||
|
||||
export class QuerySubprocessAdapter {
|
||||
constructor(private readonly deps: QuerySubprocessAdapterDeps) {}
|
||||
|
||||
async execJson(command: string, args: string[]): Promise<unknown> {
|
||||
const wsArgs = this.deps.workstream ? ['--ws', this.deps.workstream] : [];
|
||||
const fullArgs = [this.deps.gsdToolsPath, command, ...args, ...wsArgs];
|
||||
const fullArgs = this.commandArgs(command, args);
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
@@ -37,28 +31,7 @@ export class QuerySubprocessAdapter {
|
||||
const stderrStr = stderr?.toString() ?? '';
|
||||
|
||||
if (error) {
|
||||
if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
reject(
|
||||
this.deps.createToolsError(
|
||||
`gsd-tools timed out after ${this.deps.timeoutMs}ms: ${command} ${args.join(' ')}`,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
this.deps.createToolsError(
|
||||
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
|
||||
command,
|
||||
args,
|
||||
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
reject(this.processExecutionError(command, args, error, stderrStr));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +41,7 @@ export class QuerySubprocessAdapter {
|
||||
resolve(parsed);
|
||||
} catch (parseErr) {
|
||||
reject(
|
||||
this.deps.createToolsError(
|
||||
this.deps.createFailureError(
|
||||
`Failed to parse gsd-tools output for "${command}": ${parseErr instanceof Error ? parseErr.message : String(parseErr)}\nRaw output: ${raw.slice(0, 500)}`,
|
||||
command,
|
||||
args,
|
||||
@@ -81,14 +54,13 @@ export class QuerySubprocessAdapter {
|
||||
);
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(this.deps.createToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, ''));
|
||||
reject(this.processSpawnError(command, args, err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async execRaw(command: string, args: string[]): Promise<string> {
|
||||
const wsArgs = this.deps.workstream ? ['--ws', this.deps.workstream] : [];
|
||||
const fullArgs = [this.deps.gsdToolsPath, command, ...args, ...wsArgs, '--raw'];
|
||||
const fullArgs = [...this.commandArgs(command, args), '--raw'];
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
@@ -103,27 +75,7 @@ export class QuerySubprocessAdapter {
|
||||
(error, stdout, stderr) => {
|
||||
const stderrStr = stderr?.toString() ?? '';
|
||||
if (error) {
|
||||
if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
reject(
|
||||
this.deps.createToolsError(
|
||||
`gsd-tools timed out after ${this.deps.timeoutMs}ms: ${command} ${args.join(' ')}`,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
this.deps.createToolsError(
|
||||
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
|
||||
command,
|
||||
args,
|
||||
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
reject(this.processExecutionError(command, args, error, stderrStr));
|
||||
return;
|
||||
}
|
||||
resolve((stdout?.toString() ?? '').trim());
|
||||
@@ -131,11 +83,45 @@ export class QuerySubprocessAdapter {
|
||||
);
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(this.deps.createToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, ''));
|
||||
reject(this.processSpawnError(command, args, err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private commandArgs(command: string, args: string[]): string[] {
|
||||
const wsArgs = this.deps.workstream ? ['--ws', this.deps.workstream] : [];
|
||||
return [this.deps.gsdToolsPath, command, ...args, ...wsArgs];
|
||||
}
|
||||
|
||||
private processExecutionError(
|
||||
command: string,
|
||||
args: string[],
|
||||
error: Error & { code?: unknown; status?: number; killed?: boolean },
|
||||
stderrStr: string,
|
||||
) {
|
||||
if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
return this.deps.createTimeoutError(
|
||||
timeoutMessage(command, args, this.deps.timeoutMs),
|
||||
command,
|
||||
args,
|
||||
stderrStr,
|
||||
this.deps.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
return this.deps.createFailureError(
|
||||
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
|
||||
command,
|
||||
args,
|
||||
typeof error.code === 'number' ? error.code : error.status ?? 1,
|
||||
stderrStr,
|
||||
);
|
||||
}
|
||||
|
||||
private processSpawnError(command: string, args: string[], err: Error) {
|
||||
return this.deps.createFailureError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '');
|
||||
}
|
||||
|
||||
private async parseOutput(raw: string): Promise<unknown> {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
@@ -146,11 +132,12 @@ export class QuerySubprocessAdapter {
|
||||
let jsonStr = trimmed;
|
||||
if (jsonStr.startsWith('@file:')) {
|
||||
const filePath = jsonStr.slice(6).trim();
|
||||
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(this.deps.projectDir, filePath);
|
||||
try {
|
||||
jsonStr = await readFile(filePath, 'utf-8');
|
||||
jsonStr = await readFile(resolvedPath, 'utf-8');
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`);
|
||||
throw new Error(`Failed to read gsd-tools @file: indirection at "${resolvedPath}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
sdk/src/query-tools-error-factory.test.ts
Normal file
35
sdk/src/query-tools-error-factory.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ErrorClassification, GSDError } from './errors.js';
|
||||
import {
|
||||
createQueryNativeErrorFactory,
|
||||
createQueryToolsErrorFactory,
|
||||
toToolsErrorFromUnknown,
|
||||
} from './query-tools-error-factory.js';
|
||||
|
||||
describe('query tools error factory', () => {
|
||||
it('builds timeout and failure tools errors via seam factories', () => {
|
||||
const toolsFactory = createQueryToolsErrorFactory();
|
||||
expect(toolsFactory.createTimeoutError('t', 'state', ['load'], '', 10).classification).toEqual({ kind: 'timeout', timeoutMs: 10 });
|
||||
expect(toolsFactory.createFailureError('f', 'state', ['load'], 1, '').classification).toEqual({ kind: 'failure' });
|
||||
});
|
||||
it('maps GSDError to failure with semantic exit code', () => {
|
||||
const err = toToolsErrorFromUnknown('state', ['load'], new GSDError('bad', ErrorClassification.Validation));
|
||||
expect(err.exitCode).toBe(10);
|
||||
expect(err.classification).toEqual({ kind: 'failure' });
|
||||
});
|
||||
|
||||
it('maps timeout-like unknown errors to timeout classification', () => {
|
||||
const err = toToolsErrorFromUnknown('state', ['load'], new Error('gsd-tools timed out after 50ms: state load'));
|
||||
expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 50 });
|
||||
});
|
||||
|
||||
it('builds subprocess/native error factories', () => {
|
||||
const toolsFactory = createQueryToolsErrorFactory();
|
||||
const nativeFactory = createQueryNativeErrorFactory(777);
|
||||
|
||||
expect(toolsFactory.createFailureError('x', 'state', ['load'], 1, '').classification).toEqual({ kind: 'failure' });
|
||||
expect(toolsFactory.createTimeoutError('x', 'state', ['load'], '', 123).classification).toEqual({ kind: 'timeout', timeoutMs: 123 });
|
||||
expect(nativeFactory.createNativeTimeoutError('x', 'state', ['load']).classification).toEqual({ kind: 'timeout', timeoutMs: 777 });
|
||||
expect(nativeFactory.createNativeFailureError('x', 'state', ['load'], new Error('boom')).classification).toEqual({ kind: 'failure' });
|
||||
});
|
||||
});
|
||||
76
sdk/src/query-tools-error-factory.ts
Normal file
76
sdk/src/query-tools-error-factory.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { GSDError, exitCodeFor } from './errors.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { errorMessage, toFailureSignal } from './query-failure-classification.js';
|
||||
|
||||
export interface QueryTimeoutErrorFactory {
|
||||
createTimeoutError: (
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
stderr: string,
|
||||
timeoutMs: number,
|
||||
) => GSDToolsError;
|
||||
}
|
||||
|
||||
export interface QueryFailureErrorFactory {
|
||||
createFailureError: (
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
) => GSDToolsError;
|
||||
}
|
||||
|
||||
export type QueryToolsErrorFactory = QueryTimeoutErrorFactory & QueryFailureErrorFactory;
|
||||
|
||||
export interface QueryNativeErrorFactory {
|
||||
createNativeTimeoutError: (message: string, command: string, args: string[]) => GSDToolsError;
|
||||
createNativeFailureError: (message: string, command: string, args: string[], cause: unknown) => GSDToolsError;
|
||||
}
|
||||
|
||||
function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError {
|
||||
return GSDToolsError.timeout(message, command, args, stderr, timeoutMs);
|
||||
}
|
||||
|
||||
function failureToolsError(
|
||||
message: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
exitCode: number | null,
|
||||
stderr = '',
|
||||
cause?: unknown,
|
||||
): GSDToolsError {
|
||||
return GSDToolsError.failure(message, command, args, exitCode, stderr, cause === undefined ? undefined : { cause });
|
||||
}
|
||||
|
||||
export function toToolsErrorFromUnknown(command: string, args: string[], err: unknown): GSDToolsError {
|
||||
if (err instanceof GSDError) {
|
||||
return failureToolsError(err.message, command, args, exitCodeFor(err.classification), '', err);
|
||||
}
|
||||
|
||||
const msg = errorMessage(err);
|
||||
const signal = toFailureSignal(err);
|
||||
if (signal.kind === 'timeout') {
|
||||
return timeoutToolsError(msg, command, args, '', signal.timeoutMs);
|
||||
}
|
||||
return failureToolsError(msg, command, args, 1, '', err instanceof Error ? err : undefined);
|
||||
}
|
||||
|
||||
export function createQueryToolsErrorFactory(): QueryToolsErrorFactory {
|
||||
return {
|
||||
createTimeoutError: (message, command, args, stderr, timeoutMs) =>
|
||||
timeoutToolsError(message, command, args, stderr, timeoutMs),
|
||||
createFailureError: (message, command, args, exitCode, stderr) =>
|
||||
failureToolsError(message, command, args, exitCode, stderr),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQueryNativeErrorFactory(defaultTimeoutMs: number): QueryNativeErrorFactory {
|
||||
return {
|
||||
createNativeTimeoutError: (message, command, args) =>
|
||||
timeoutToolsError(message, command, args, '', defaultTimeoutMs),
|
||||
createNativeFailureError: (message, command, args, cause) =>
|
||||
failureToolsError(message, command, args, 1, '', cause),
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { GSDError, exitCodeFor } from './errors.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
/**
|
||||
* Module owning projection of internal errors to GSDToolsError contract.
|
||||
*/
|
||||
export function toGSDToolsError(command: string, args: string[], err: unknown): GSDToolsError {
|
||||
if (err instanceof GSDError) {
|
||||
return new GSDToolsError(
|
||||
err.message,
|
||||
command,
|
||||
args,
|
||||
exitCodeFor(err.classification),
|
||||
'',
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return new GSDToolsError(
|
||||
msg,
|
||||
command,
|
||||
args,
|
||||
1,
|
||||
'',
|
||||
err instanceof Error ? { cause: err } : undefined,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
66
sdk/src/query/command-manifest.non-family.ts
Normal file
66
sdk/src/query/command-manifest.non-family.ts
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,28 +4,28 @@ import { buildQueryCliOutputFromError } from './query-cli-output.js';
|
||||
|
||||
describe('query-cli-output', () => {
|
||||
it('prefers raw gsd-tools stderr when present', () => {
|
||||
const err = new GSDToolsError('failed', 'list', ['json'], 2, 'line one\nline two\n');
|
||||
const err = GSDToolsError.failure('failed', 'list', ['json'], 2, 'line one\nline two\n');
|
||||
const out = buildQueryCliOutputFromError(err);
|
||||
expect(out.exitCode).toBe(2);
|
||||
expect(out.stderrLines).toEqual(['line one', 'line two']);
|
||||
});
|
||||
|
||||
it('falls back to Error: message when gsd-tools stderr is empty', () => {
|
||||
const err = new GSDToolsError('failed', 'list', ['json'], null, '');
|
||||
const err = GSDToolsError.failure('failed', 'list', ['json'], null, '');
|
||||
const out = buildQueryCliOutputFromError(err);
|
||||
expect(out.exitCode).toBe(1);
|
||||
expect(out.stderrLines).toEqual(['Error: failed']);
|
||||
});
|
||||
|
||||
it('falls back to Error: message when gsd-tools stderr is whitespace-only', () => {
|
||||
const err = new GSDToolsError('failed', 'build', ['json'], null, ' \n');
|
||||
const err = GSDToolsError.failure('failed', 'build', ['json'], null, ' \n');
|
||||
const out = buildQueryCliOutputFromError(err);
|
||||
expect(out.exitCode).toBe(1);
|
||||
expect(out.stderrLines).toEqual(['Error: failed']);
|
||||
});
|
||||
|
||||
it('uses exitCode 1 when gsd-tools exitCode is null and stderr is non-empty', () => {
|
||||
const err = new GSDToolsError('failed', 'build', ['json'], null, 'line');
|
||||
const err = GSDToolsError.failure('failed', 'build', ['json'], null, 'line');
|
||||
const out = buildQueryCliOutputFromError(err);
|
||||
expect(out.exitCode).toBe(1);
|
||||
expect(out.stderrLines).toEqual(['line']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
22
sdk/src/query/query-command-semantics.test.ts
Normal file
22
sdk/src/query/query-command-semantics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mapFallbackDispatchError,
|
||||
toDispatchFailure,
|
||||
} from './query-dispatch-error-mapper.js';
|
||||
import { GSDToolsError } from '../gsd-tools-error.js';
|
||||
|
||||
describe('query dispatch error mapper', () => {
|
||||
it('maps native timeout errors', () => {
|
||||
@@ -24,6 +25,25 @@ describe('query dispatch error mapper', () => {
|
||||
expect(err.details).toMatchObject({ command: 'state.json', args: [] });
|
||||
});
|
||||
|
||||
it('maps typed timeout classification from GSDToolsError', () => {
|
||||
const err = mapNativeDispatchError(
|
||||
GSDToolsError.timeout('timeout', 'state', ['load'], '', 1234),
|
||||
'state.load',
|
||||
[],
|
||||
);
|
||||
expect(err.kind).toBe('native_timeout');
|
||||
expect(err.details).toMatchObject({ timeout_ms: 1234 });
|
||||
});
|
||||
|
||||
it('maps typed failure classification from GSDToolsError', () => {
|
||||
const err = mapNativeDispatchError(
|
||||
GSDToolsError.failure('boom', 'state', ['load'], 1),
|
||||
'state.load',
|
||||
[],
|
||||
);
|
||||
expect(err.kind).toBe('native_failure');
|
||||
});
|
||||
|
||||
it('maps fallback errors', () => {
|
||||
const err = mapFallbackDispatchError(new Error('spawn ENOENT'), 'state', ['load']);
|
||||
expect(err.kind).toBe('fallback_failure');
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
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 dispatchFailure(error, stderr);
|
||||
}
|
||||
|
||||
export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
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 fallbackFailureError({ message, command, args, backend: 'cjs' });
|
||||
}
|
||||
|
||||
function parseTimeoutMs(message: string): number | undefined {
|
||||
const m = message.match(/timed out after\s+(\d+)ms/i);
|
||||
if (!m) return undefined;
|
||||
const n = Number.parseInt(m[1], 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
/**
|
||||
* @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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3,9 +3,9 @@ import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRegistry } from './index.js';
|
||||
import { GSDToolsError } from '../gsd-tools-error.js';
|
||||
import { runQueryDispatch } from './query-dispatch.js';
|
||||
import { createCommandTopology } from './command-topology.js';
|
||||
|
||||
describe('runQueryDispatch', () => {
|
||||
let tmpDir: string;
|
||||
let fixtureDir: string;
|
||||
@@ -152,6 +152,24 @@ describe('runQueryDispatch', () => {
|
||||
expect(out.error.details).toMatchObject({ command: 'state.load', args: [], timeout_ms: 30000 });
|
||||
});
|
||||
|
||||
it('maps typed native timeout to native_timeout kind with details', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
registry,
|
||||
projectDir: tmpDir,
|
||||
cjsFallbackEnabled: true,
|
||||
resolveGsdToolsPath: () => '',
|
||||
dispatchNative: async () => { throw GSDToolsError.timeout('timed out', 'state', ['load'], '', 30000); },
|
||||
topology: createCommandTopology(registry),
|
||||
}, ['state', 'load']);
|
||||
|
||||
expect(out.ok).toBe(false);
|
||||
if (out.ok) throw new Error('expected failure');
|
||||
expect(out.error.kind).toBe('native_timeout');
|
||||
expect(out.error.code).toBe(1);
|
||||
expect(out.error.details).toMatchObject({ command: 'state.load', args: [], timeout_ms: 30000 });
|
||||
});
|
||||
|
||||
it('maps native error to native_failure kind with details', async () => {
|
||||
const registry = createRegistry();
|
||||
const out = await runQueryDispatch({
|
||||
|
||||
@@ -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,145 @@ 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') {
|
||||
if (pickField) {
|
||||
throw new Error('--pick is not supported for text output');
|
||||
}
|
||||
return data.endsWith('\n') ? data : `${data}\n`;
|
||||
}
|
||||
const output = formatPick(data, pickField);
|
||||
return `${JSON.stringify(output === undefined ? null : 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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fallbackDispatchErrorFromSignal,
|
||||
fallbackFailureError,
|
||||
internalError,
|
||||
nativeDispatchErrorFromSignal,
|
||||
nativeFailureError,
|
||||
nativeTimeoutError,
|
||||
unknownCommandError,
|
||||
@@ -28,4 +30,10 @@ describe('query-error-taxonomy', () => {
|
||||
expect(validationError({ message: 'bad', details: { r: 'x' } }).kind).toBe('validation_error');
|
||||
expect(internalError({ message: 'bad' }).kind).toBe('internal_error');
|
||||
});
|
||||
|
||||
it('projects dispatch errors from failure signals', () => {
|
||||
expect(nativeDispatchErrorFromSignal({ kind: 'failure', message: 'boom' }, 'state.load', []).kind).toBe('native_failure');
|
||||
expect(nativeDispatchErrorFromSignal({ kind: 'timeout', message: 'timeout', timeoutMs: 1000 }, 'state.load', []).kind).toBe('native_timeout');
|
||||
expect(fallbackDispatchErrorFromSignal({ kind: 'failure', message: 'spawn' }, 'state', ['load']).kind).toBe('fallback_failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { QueryDispatchError } from './query-dispatch-contract.js';
|
||||
import type { QueryFailureSignal } from '../query-failure-classification.js';
|
||||
import { fallbackErrorDetails, nativeErrorDetails, unknownCommandDetails } from './query-error-details-schema.js';
|
||||
|
||||
export function unknownCommandError(input: {
|
||||
message: string;
|
||||
normalized: string;
|
||||
@@ -96,3 +96,22 @@ export function internalError(input: {
|
||||
details: input.details,
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeDispatchErrorFromSignal(
|
||||
signal: QueryFailureSignal,
|
||||
command: string,
|
||||
args: string[],
|
||||
): QueryDispatchError {
|
||||
if (signal.kind === 'timeout') {
|
||||
return nativeTimeoutError({ message: signal.message, command, args, timeoutMs: signal.timeoutMs });
|
||||
}
|
||||
return nativeFailureError({ message: signal.message, command, args });
|
||||
}
|
||||
|
||||
export function fallbackDispatchErrorFromSignal(
|
||||
signal: QueryFailureSignal,
|
||||
command: string,
|
||||
args: string[],
|
||||
): QueryDispatchError {
|
||||
return fallbackFailureError({ message: signal.message, command, args, backend: 'cjs' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
87
sdk/src/query/registry-assembly-descriptor.ts
Normal file
87
sdk/src/query/registry-assembly-descriptor.ts
Normal 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;
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,7 @@ const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'co
|
||||
const CONFIG_SCHEMA_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-schema.ts');
|
||||
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
|
||||
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly.ts');
|
||||
const QUERY_ASSEMBLY_DESCRIPTOR_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'registry-assembly-descriptor.ts');
|
||||
|
||||
describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
|
||||
@@ -170,10 +171,9 @@ describe('SDK wiring for #2492 gates', () => {
|
||||
|
||||
test('query registry assembly registers the new handlers', () => {
|
||||
const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8');
|
||||
// Handlers are now registered through catalog-based registration.
|
||||
assert.ok(c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decision-coverage-plan handler must be registered via the verify-decision catalog');
|
||||
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-plan handler must be registered');
|
||||
assert.ok(c.includes('registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG)'), 'check.decision-coverage-verify handler must be registered');
|
||||
assert.ok(c.includes('decisions.parse') || c.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decisions.parse handler must be registered');
|
||||
const d = fs.readFileSync(QUERY_ASSEMBLY_DESCRIPTOR_TS, 'utf-8');
|
||||
assert.ok(c.includes('REGISTRY_ASSEMBLY_PLAN'), 'registry assembly must be driven by declarative plan');
|
||||
assert.ok(d.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decision-coverage handlers must be sourced from verify-decision catalog');
|
||||
assert.ok(d.includes("{ kind: 'static', key: 'VERIFY_DECISION_STATIC_CATALOG' }"), 'verify-decision catalog must be present in assembly plan');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,28 +48,21 @@ function collectRegisteredNames() {
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) names.add(m[1]);
|
||||
|
||||
// Catalog-based registrations: extract handler names from registerStaticCatalog calls.
|
||||
const catalogRe = /registerStaticCatalog\(registry, (\w+)\)/g;
|
||||
let cm;
|
||||
while ((cm = catalogRe.exec(src)) !== null) {
|
||||
const catalogVarName = cm[1];
|
||||
// Map variable names to their source files.
|
||||
const catalogFileByVar = {
|
||||
FOUNDATION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
STATE_SUPPORT_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
MUTATION_SURFACES_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
VERIFY_DECISION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DECISION_ROUTING_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DOMAIN_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-domain.ts'),
|
||||
};
|
||||
const cf = catalogFileByVar[catalogVarName];
|
||||
if (!cf) continue;
|
||||
// Catalog-based registrations: parse known static catalogs directly.
|
||||
const catalogFileByVar = {
|
||||
FOUNDATION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
STATE_SUPPORT_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
MUTATION_SURFACES_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
VERIFY_DECISION_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DECISION_ROUTING_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-foundation.ts'),
|
||||
DOMAIN_STATIC_CATALOG: path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-static-catalog-domain.ts'),
|
||||
};
|
||||
|
||||
for (const [catalogVarName, cf] of Object.entries(catalogFileByVar)) {
|
||||
try {
|
||||
const catSrc = fs.readFileSync(cf, 'utf8');
|
||||
// Only extract names from the catalog matching this variable.
|
||||
const exportRe = new RegExp(`export const ${catalogVarName}:`, 'm');
|
||||
if (!exportRe.test(catSrc)) continue;
|
||||
// Match: [[name, handler], ...]
|
||||
const entryRe = /\[\s*['"]([^'"]+)['"]/g;
|
||||
let em;
|
||||
while ((em = entryRe.exec(catSrc)) !== null) {
|
||||
|
||||
Reference in New Issue
Block a user