From 9d096b99251f358559808175862ad131ec1d00f6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:31:06 -0400 Subject: [PATCH 01/44] refactor: deepen gsdtools query execution seams --- sdk/src/query-execution-policy.test.ts | 7 ++----- sdk/src/query-native-hotpath-adapter.test.ts | 8 ++++---- sdk/src/query-raw-output-projection.test.ts | 17 ----------------- sdk/src/query-raw-output-projection.ts | 9 --------- sdk/src/query-subprocess-adapter.ts | 14 +------------- 5 files changed, 7 insertions(+), 48 deletions(-) diff --git a/sdk/src/query-execution-policy.test.ts b/sdk/src/query-execution-policy.test.ts index 146801a5a..0b267a2e9 100644 --- a/sdk/src/query-execution-policy.test.ts +++ b/sdk/src/query-execution-policy.test.ts @@ -1,12 +1,8 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { QueryExecutionPolicy } from './query-execution-policy.js'; import { setTransportPolicy, clearTransportPolicy } from './gsd-transport-policy.js'; describe('QueryExecutionPolicy', () => { - afterEach(() => { - clearTransportPolicy(); - }); - it('applies transport policy to transport.run', async () => { const run = vi.fn().mockResolvedValue({ ok: true }); const policy = new QueryExecutionPolicy({ run } as never); @@ -27,5 +23,6 @@ describe('QueryExecutionPolicy', () => { const [, policyArg] = run.mock.calls[0]; expect(policyArg).toEqual({ preferNative: true, allowFallbackToSubprocess: false }); + clearTransportPolicy(); }); }); diff --git a/sdk/src/query-native-hotpath-adapter.test.ts b/sdk/src/query-native-hotpath-adapter.test.ts index 727494d4d..bdd6c672f 100644 --- a/sdk/src/query-native-hotpath-adapter.test.ts +++ b/sdk/src/query-native-hotpath-adapter.test.ts @@ -17,8 +17,8 @@ describe('QueryNativeHotpathAdapter', () => { await expect(adapter.dispatch('state', ['load'], 'state.load', [], 'json')).resolves.toEqual({ ok: true }); await expect(adapter.dispatch('commit', ['m'], 'commit', ['m'], 'raw')).resolves.toEqual('ok'); - expect((native as { dispatchJson: ReturnType }).dispatchJson).toHaveBeenCalledWith('state', ['load'], 'state.load', []); - expect((native as { dispatchRaw: ReturnType }).dispatchRaw).toHaveBeenCalledWith('commit', ['m'], 'commit', ['m']); + expect((native as { dispatchJson: ReturnType }).dispatchJson).toHaveBeenCalledTimes(1); + expect((native as { dispatchRaw: ReturnType }).dispatchRaw).toHaveBeenCalledTimes(1); }); it('uses fallback when native query disabled', async () => { @@ -37,7 +37,7 @@ describe('QueryNativeHotpathAdapter', () => { await expect(adapter.dispatch('state', ['load'], 'state.load', [], 'json')).resolves.toEqual({ from: 'fallback-json' }); await expect(adapter.dispatch('commit', ['m'], 'commit', ['m'], 'raw')).resolves.toEqual('fallback-raw'); - expect(execJsonFallback).toHaveBeenCalledWith('state', ['load']); - expect(execRawFallback).toHaveBeenCalledWith('commit', ['m']); + expect(execJsonFallback).toHaveBeenCalledTimes(1); + expect(execRawFallback).toHaveBeenCalledTimes(1); }); }); diff --git a/sdk/src/query-raw-output-projection.test.ts b/sdk/src/query-raw-output-projection.test.ts index 4b7db4e85..d2dcc96de 100644 --- a/sdk/src/query-raw-output-projection.test.ts +++ b/sdk/src/query-raw-output-projection.test.ts @@ -6,18 +6,6 @@ describe('formatQueryRawOutput', () => { expect(formatQueryRawOutput('commit', { committed: true, hash: 'abc123' })).toBe('abc123'); }); - it('returns committed when hash missing', () => { - expect(formatQueryRawOutput('commit', { committed: true })).toBe('committed'); - }); - - it('formats skipped commit reason', () => { - expect(formatQueryRawOutput('commit', { committed: false, reason: 'skipped' })).toBe('skipped'); - }); - - it('formats nothing-to-commit reason', () => { - expect(formatQueryRawOutput('commit', { committed: false, reason: 'nothing_to_commit' })).toBe('nothing'); - }); - it('formats config-set key=value', () => { expect(formatQueryRawOutput('config-set', { updated: true, key: 'mode', value: 'yolo' })).toBe('mode=yolo'); }); @@ -26,9 +14,4 @@ describe('formatQueryRawOutput', () => { expect(formatQueryRawOutput('state.begin-phase', { updated: ['x'] })).toBe('true'); expect(formatQueryRawOutput('state.begin-phase', { updated: [] })).toBe('false'); }); - - it('formats state begin-phase alias', () => { - expect(formatQueryRawOutput('state begin-phase', { updated: ['x'] })).toBe('true'); - expect(formatQueryRawOutput('state begin-phase', { updated: [] })).toBe('false'); - }); }); diff --git a/sdk/src/query-raw-output-projection.ts b/sdk/src/query-raw-output-projection.ts index 51f564435..720f36f7a 100644 --- a/sdk/src/query-raw-output-projection.ts +++ b/sdk/src/query-raw-output-projection.ts @@ -10,9 +10,6 @@ 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); - } const d = data as Record; if (d.committed === true) { return d.hash != null ? String(d.hash) : 'committed'; @@ -36,9 +33,6 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st } if (registryCommand === 'config-set') { - if (data == null || typeof data !== 'object' || Array.isArray(data)) { - return JSON.stringify(data, null, 2); - } const d = data as Record; if ((d.updated === true || d.set === true) && d.key !== undefined) { const v = d.value; @@ -54,9 +48,6 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st } if (registryCommand === 'state.begin-phase' || registryCommand === 'state begin-phase') { - if (data == null || typeof data !== 'object' || Array.isArray(data)) { - return JSON.stringify(data, null, 2); - } const d = data as Record; const u = d.updated as string[] | undefined; return Array.isArray(u) && u.length > 0 ? 'true' : 'false'; diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index f7b567d37..fbea8f7e6 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; -import type { GSDToolsError } from './gsd-tools-error.js'; +import type { GSDToolsError } from './gsd-tools.js'; export interface QuerySubprocessAdapterDeps { projectDir: string; @@ -103,18 +103,6 @@ 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}` : ''}`, From 12fc34689e19b88194750e60b14226037c43348c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:31:41 -0400 Subject: [PATCH 02/44] docs: add changeset for query seam deepening --- .changeset/tidy-tunas-zip.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-tunas-zip.md b/.changeset/tidy-tunas-zip.md index 775bda591..172683569 100644 --- a/.changeset/tidy-tunas-zip.md +++ b/.changeset/tidy-tunas-zip.md @@ -2,4 +2,4 @@ type: Changed pr: 3085 --- -**`GSDTools` query execution internals now use deep Module seams** — refactors runtime composition, native/subprocess adapters, and output projection behind stable public interfaces for better locality and testability. +** query execution internals now use deep Module seams** — refactors runtime composition, native/subprocess adapters, and output projection behind stable public interfaces for better locality and testability. From 3e22c70fac0f2560904fbdf7b92498fb561ebe42 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:31:59 -0400 Subject: [PATCH 03/44] docs: fix changeset summary text --- .changeset/tidy-tunas-zip.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-tunas-zip.md b/.changeset/tidy-tunas-zip.md index 172683569..775bda591 100644 --- a/.changeset/tidy-tunas-zip.md +++ b/.changeset/tidy-tunas-zip.md @@ -2,4 +2,4 @@ type: Changed pr: 3085 --- -** query execution internals now use deep Module seams** — refactors runtime composition, native/subprocess adapters, and output projection behind stable public interfaces for better locality and testability. +**`GSDTools` query execution internals now use deep Module seams** — refactors runtime composition, native/subprocess adapters, and output projection behind stable public interfaces for better locality and testability. From ac883f81509ed4bce54e5a0077f6b5be4f818ae3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:46:45 -0400 Subject: [PATCH 04/44] fix: address coderabbit query seam findings --- sdk/src/query-execution-policy.test.ts | 7 +++++-- sdk/src/query-raw-output-projection.ts | 9 +++++++++ sdk/src/query-subprocess-adapter.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/sdk/src/query-execution-policy.test.ts b/sdk/src/query-execution-policy.test.ts index 0b267a2e9..146801a5a 100644 --- a/sdk/src/query-execution-policy.test.ts +++ b/sdk/src/query-execution-policy.test.ts @@ -1,8 +1,12 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { QueryExecutionPolicy } from './query-execution-policy.js'; import { setTransportPolicy, clearTransportPolicy } from './gsd-transport-policy.js'; describe('QueryExecutionPolicy', () => { + afterEach(() => { + clearTransportPolicy(); + }); + it('applies transport policy to transport.run', async () => { const run = vi.fn().mockResolvedValue({ ok: true }); const policy = new QueryExecutionPolicy({ run } as never); @@ -23,6 +27,5 @@ describe('QueryExecutionPolicy', () => { const [, policyArg] = run.mock.calls[0]; expect(policyArg).toEqual({ preferNative: true, allowFallbackToSubprocess: false }); - clearTransportPolicy(); }); }); diff --git a/sdk/src/query-raw-output-projection.ts b/sdk/src/query-raw-output-projection.ts index 720f36f7a..51f564435 100644 --- a/sdk/src/query-raw-output-projection.ts +++ b/sdk/src/query-raw-output-projection.ts @@ -10,6 +10,9 @@ 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); + } const d = data as Record; if (d.committed === true) { return d.hash != null ? String(d.hash) : 'committed'; @@ -33,6 +36,9 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st } if (registryCommand === 'config-set') { + if (data == null || typeof data !== 'object' || Array.isArray(data)) { + return JSON.stringify(data, null, 2); + } const d = data as Record; if ((d.updated === true || d.set === true) && d.key !== undefined) { const v = d.value; @@ -48,6 +54,9 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st } if (registryCommand === 'state.begin-phase' || registryCommand === 'state begin-phase') { + if (data == null || typeof data !== 'object' || Array.isArray(data)) { + return JSON.stringify(data, null, 2); + } const d = data as Record; const u = d.updated as string[] | undefined; return Array.isArray(u) && u.length > 0 ? 'true' : 'false'; diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index fbea8f7e6..029dbf0f3 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -103,6 +103,18 @@ 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}` : ''}`, From 1037b82a9894e94109be522c7d228b00f1f51921 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:50:35 -0400 Subject: [PATCH 05/44] test: address remaining coderabbit findings and notes --- sdk/src/query-native-hotpath-adapter.test.ts | 8 ++++---- sdk/src/query-raw-output-projection.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/sdk/src/query-native-hotpath-adapter.test.ts b/sdk/src/query-native-hotpath-adapter.test.ts index bdd6c672f..727494d4d 100644 --- a/sdk/src/query-native-hotpath-adapter.test.ts +++ b/sdk/src/query-native-hotpath-adapter.test.ts @@ -17,8 +17,8 @@ describe('QueryNativeHotpathAdapter', () => { await expect(adapter.dispatch('state', ['load'], 'state.load', [], 'json')).resolves.toEqual({ ok: true }); await expect(adapter.dispatch('commit', ['m'], 'commit', ['m'], 'raw')).resolves.toEqual('ok'); - expect((native as { dispatchJson: ReturnType }).dispatchJson).toHaveBeenCalledTimes(1); - expect((native as { dispatchRaw: ReturnType }).dispatchRaw).toHaveBeenCalledTimes(1); + expect((native as { dispatchJson: ReturnType }).dispatchJson).toHaveBeenCalledWith('state', ['load'], 'state.load', []); + expect((native as { dispatchRaw: ReturnType }).dispatchRaw).toHaveBeenCalledWith('commit', ['m'], 'commit', ['m']); }); it('uses fallback when native query disabled', async () => { @@ -37,7 +37,7 @@ describe('QueryNativeHotpathAdapter', () => { await expect(adapter.dispatch('state', ['load'], 'state.load', [], 'json')).resolves.toEqual({ from: 'fallback-json' }); await expect(adapter.dispatch('commit', ['m'], 'commit', ['m'], 'raw')).resolves.toEqual('fallback-raw'); - expect(execJsonFallback).toHaveBeenCalledTimes(1); - expect(execRawFallback).toHaveBeenCalledTimes(1); + expect(execJsonFallback).toHaveBeenCalledWith('state', ['load']); + expect(execRawFallback).toHaveBeenCalledWith('commit', ['m']); }); }); diff --git a/sdk/src/query-raw-output-projection.test.ts b/sdk/src/query-raw-output-projection.test.ts index d2dcc96de..4b7db4e85 100644 --- a/sdk/src/query-raw-output-projection.test.ts +++ b/sdk/src/query-raw-output-projection.test.ts @@ -6,6 +6,18 @@ describe('formatQueryRawOutput', () => { expect(formatQueryRawOutput('commit', { committed: true, hash: 'abc123' })).toBe('abc123'); }); + it('returns committed when hash missing', () => { + expect(formatQueryRawOutput('commit', { committed: true })).toBe('committed'); + }); + + it('formats skipped commit reason', () => { + expect(formatQueryRawOutput('commit', { committed: false, reason: 'skipped' })).toBe('skipped'); + }); + + it('formats nothing-to-commit reason', () => { + expect(formatQueryRawOutput('commit', { committed: false, reason: 'nothing_to_commit' })).toBe('nothing'); + }); + it('formats config-set key=value', () => { expect(formatQueryRawOutput('config-set', { updated: true, key: 'mode', value: 'yolo' })).toBe('mode=yolo'); }); @@ -14,4 +26,9 @@ describe('formatQueryRawOutput', () => { expect(formatQueryRawOutput('state.begin-phase', { updated: ['x'] })).toBe('true'); expect(formatQueryRawOutput('state.begin-phase', { updated: [] })).toBe('false'); }); + + it('formats state begin-phase alias', () => { + expect(formatQueryRawOutput('state begin-phase', { updated: ['x'] })).toBe('true'); + expect(formatQueryRawOutput('state begin-phase', { updated: [] })).toBe('false'); + }); }); From 9f5b011b357df884c42d401ae84b621e698b40a4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 3 May 2026 18:54:23 -0400 Subject: [PATCH 06/44] refactor: use internal gsdtools error type import --- sdk/src/query-subprocess-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index 029dbf0f3..f7b567d37 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; -import type { GSDToolsError } from './gsd-tools.js'; +import type { GSDToolsError } from './gsd-tools-error.js'; export interface QuerySubprocessAdapterDeps { projectDir: string; From ba6100c5484b82179a41713995ae8b6d39b4b40d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:54:29 -0400 Subject: [PATCH 07/44] refactor: deepen query failure classification module --- sdk/src/gsd-transport.ts | 7 +---- sdk/src/query-failure-classification.test.ts | 28 ++++++++++++++++++++ sdk/src/query-failure-classification.ts | 24 +++++++++++++++++ sdk/src/query-native-direct-adapter.ts | 3 ++- sdk/src/query-subprocess-adapter.ts | 5 ++-- sdk/src/query-tools-error-mapper.ts | 3 ++- sdk/src/query/query-dispatch-error-mapper.ts | 14 +++------- 7 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 sdk/src/query-failure-classification.test.ts create mode 100644 sdk/src/query-failure-classification.ts diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 4b61f4093..67164310d 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -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 { isTimeoutLikeError } 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, diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts new file mode 100644 index 000000000..7675f9012 --- /dev/null +++ b/sdk/src/query-failure-classification.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + errorMessage, + isTimeoutLikeError, + isTimeoutMessage, + parseTimeoutMs, + timeoutMessage, +} from './query-failure-classification.js'; + +describe('query failure classification', () => { + it('extracts timeout metadata from message', () => { + const msg = timeoutMessage('state', ['load'], 30000); + expect(isTimeoutMessage(msg)).toBe(true); + expect(parseTimeoutMs(msg)).toBe(30000); + }); + + it('classifies timeout-like errors', () => { + expect(isTimeoutLikeError(new Error('gsd-tools timed out after 1000ms: x'))).toBe(true); + const abort = new Error('aborted'); + abort.name = 'AbortError'; + expect(isTimeoutLikeError(abort)).toBe(true); + }); + + it('normalizes unknown error values', () => { + expect(errorMessage('boom')).toBe('boom'); + expect(errorMessage(new Error('x'))).toBe('x'); + }); +}); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts new file mode 100644 index 000000000..63ddbd3f1 --- /dev/null +++ b/sdk/src/query-failure-classification.ts @@ -0,0 +1,24 @@ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export 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; +} + +export function isTimeoutMessage(message: string): boolean { + return /timed out after/i.test(message); +} + +export function isTimeoutLikeError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; + return isTimeoutMessage(error.message); +} + +export function timeoutMessage(command: string, args: string[], timeoutMs: number): string { + return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`; +} diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index f87792d10..96a4982e5 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -1,4 +1,5 @@ import { formatQueryRawOutput } from './query-raw-output-projection.js'; +import { timeoutMessage } from './query-failure-classification.js'; import type { QueryResult } from './query/utils.js'; export interface QueryNativeDirectAdapterDeps { @@ -33,7 +34,7 @@ export class QueryNativeDirectAdapter { timeoutId = setTimeout(() => { reject( this.deps.createTimeoutError( - `gsd-tools timed out after ${this.deps.timeoutMs}ms: ${legacyCommand} ${legacyArgs.join(' ')}`, + timeoutMessage(legacyCommand, legacyArgs, this.deps.timeoutMs), legacyCommand, legacyArgs, ), diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index f7b567d37..e63df45f7 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { timeoutMessage } from './query-failure-classification.js'; import type { GSDToolsError } from './gsd-tools-error.js'; export interface QuerySubprocessAdapterDeps { @@ -40,7 +41,7 @@ export class QuerySubprocessAdapter { 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(' ')}`, + timeoutMessage(command, args, this.deps.timeoutMs), command, args, null, @@ -106,7 +107,7 @@ export class QuerySubprocessAdapter { 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(' ')}`, + timeoutMessage(command, args, this.deps.timeoutMs), command, args, null, diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 162e23720..913b91d6f 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,5 +1,6 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; +import { errorMessage } from './query-failure-classification.js'; /** * Module owning projection of internal errors to GSDToolsError contract. @@ -16,7 +17,7 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): ); } - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return new GSDToolsError( msg, command, diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index e12f89458..4d5a8e48a 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,4 +1,5 @@ import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; +import { errorMessage, isTimeoutMessage, parseTimeoutMs } from '../query-failure-classification.js'; import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; import { dispatchFailure } from './query-dispatch-result-builder.js'; @@ -10,21 +11,14 @@ export function toDispatchFailure( } 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)) { + const message = errorMessage(error); + if (isTimeoutMessage(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); + const message = errorMessage(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; -} From 5cfd874058f3e123af171c41dc78be769b0bef88 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:57:17 -0400 Subject: [PATCH 08/44] refactor: add typed query failure signals --- sdk/src/gsd-tools-error.ts | 10 +++++++- sdk/src/query-failure-classification.test.ts | 9 +++++++ sdk/src/query-failure-classification.ts | 24 +++++++++++++++++++ sdk/src/query-gsd-tools-runtime.ts | 7 +++--- sdk/src/query-subprocess-adapter.ts | 5 +++- sdk/src/query-tools-error-mapper.ts | 13 ++++++++-- .../query/query-dispatch-error-mapper.test.ts | 13 ++++++++++ sdk/src/query/query-dispatch-error-mapper.ts | 14 +++++------ 8 files changed, 81 insertions(+), 14 deletions(-) diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index 7898885e3..68a5a1bc6 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -1,3 +1,8 @@ +export interface GSDToolsErrorClassification { + kind: 'timeout' | 'failure'; + timeoutMs?: number; +} + export class GSDToolsError extends Error { constructor( message: string, @@ -5,9 +10,12 @@ 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; } + + public readonly classification?: GSDToolsErrorClassification; } diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts index 7675f9012..4232304c7 100644 --- a/sdk/src/query-failure-classification.test.ts +++ b/sdk/src/query-failure-classification.test.ts @@ -5,7 +5,9 @@ import { isTimeoutMessage, parseTimeoutMs, timeoutMessage, + toFailureSignal, } from './query-failure-classification.js'; +import { GSDToolsError } from './gsd-tools-error.js'; describe('query failure classification', () => { it('extracts timeout metadata from message', () => { @@ -25,4 +27,11 @@ describe('query failure classification', () => { expect(errorMessage('boom')).toBe('boom'); expect(errorMessage(new Error('x'))).toBe('x'); }); + + it('prefers typed classification from GSDToolsError', () => { + const err = new GSDToolsError('x', 'state', ['load'], null, '', { + classification: { kind: 'timeout', timeoutMs: 2000 }, + }); + expect(toFailureSignal(err)).toEqual({ kind: 'timeout', message: 'x', timeoutMs: 2000 }); + }); }); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts index 63ddbd3f1..5fdda719e 100644 --- a/sdk/src/query-failure-classification.ts +++ b/sdk/src/query-failure-classification.ts @@ -1,3 +1,11 @@ +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); } @@ -22,3 +30,19 @@ export function isTimeoutLikeError(error: unknown): boolean { 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 }; +} diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index e6c871e21..1ca5dfd75 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -33,14 +33,15 @@ export function createGSDToolsRuntime(opts: { gsdToolsPath: opts.gsdToolsPath, timeoutMs: opts.timeoutMs, workstream: opts.workstream, - createToolsError: (message, command, args, exitCode, stderr) => - new GSDToolsError(message, command, args, exitCode, stderr), + createToolsError: (message, command, args, exitCode, stderr, classification) => + new GSDToolsError(message, command, args, exitCode, stderr, classification ? { classification } : undefined), }); 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, ''), + createTimeoutError: (message, command, args) => + new GSDToolsError(message, command, args, null, '', { classification: { kind: 'timeout', timeoutMs: opts.timeoutMs } }), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index e63df45f7..474892dd7 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; -import type { GSDToolsError } from './gsd-tools-error.js'; +import type { GSDToolsError, GSDToolsErrorClassification } from './gsd-tools-error.js'; export interface QuerySubprocessAdapterDeps { projectDir: string; @@ -14,6 +14,7 @@ export interface QuerySubprocessAdapterDeps { args: string[], exitCode: number | null, stderr: string, + classification?: GSDToolsErrorClassification, ) => GSDToolsError; } @@ -46,6 +47,7 @@ export class QuerySubprocessAdapter { args, null, stderrStr, + { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, ), ); return; @@ -112,6 +114,7 @@ export class QuerySubprocessAdapter { args, null, stderrStr, + { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, ), ); return; diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 913b91d6f..44898ed03 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,6 +1,6 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; -import { errorMessage } from './query-failure-classification.js'; +import { isTimeoutMessage, errorMessage, parseTimeoutMs } from './query-failure-classification.js'; /** * Module owning projection of internal errors to GSDToolsError contract. @@ -24,6 +24,15 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): args, 1, '', - err instanceof Error ? { cause: err } : undefined, + err instanceof Error + ? { + cause: err, + ...(isTimeoutMessage(msg) + ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } + : undefined), + } + : (isTimeoutMessage(msg) + ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } + : undefined), ); } diff --git a/sdk/src/query/query-dispatch-error-mapper.test.ts b/sdk/src/query/query-dispatch-error-mapper.test.ts index 01c4c2e45..0fa140f89 100644 --- a/sdk/src/query/query-dispatch-error-mapper.test.ts +++ b/sdk/src/query/query-dispatch-error-mapper.test.ts @@ -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,18 @@ describe('query dispatch error mapper', () => { expect(err.details).toMatchObject({ command: 'state.json', args: [] }); }); + it('maps typed timeout classification from GSDToolsError', () => { + const err = mapNativeDispatchError( + new GSDToolsError('timeout', 'state', ['load'], null, '', { + classification: { kind: 'timeout', timeoutMs: 1234 }, + }), + 'state.load', + [], + ); + expect(err.kind).toBe('native_timeout'); + expect(err.details).toMatchObject({ timeout_ms: 1234 }); + }); + it('maps fallback errors', () => { const err = mapFallbackDispatchError(new Error('spawn ENOENT'), 'state', ['load']); expect(err.kind).toBe('fallback_failure'); diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index 4d5a8e48a..5d4a725af 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,5 +1,5 @@ import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; -import { errorMessage, isTimeoutMessage, parseTimeoutMs } from '../query-failure-classification.js'; +import { toFailureSignal } from '../query-failure-classification.js'; import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; import { dispatchFailure } from './query-dispatch-result-builder.js'; @@ -11,14 +11,14 @@ export function toDispatchFailure( } export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = errorMessage(error); - if (isTimeoutMessage(message)) { - return nativeTimeoutError({ message, command, args, timeoutMs: parseTimeoutMs(message) }); + const signal = toFailureSignal(error); + if (signal.kind === 'timeout') { + return nativeTimeoutError({ message: signal.message, command, args, timeoutMs: signal.timeoutMs }); } - return nativeFailureError({ message, command, args }); + return nativeFailureError({ message: signal.message, command, args }); } export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = errorMessage(error); - return fallbackFailureError({ message, command, args, backend: 'cjs' }); + const signal = toFailureSignal(error); + return fallbackFailureError({ message: signal.message, command, args, backend: 'cjs' }); } From 7298a76b20067abb3789ead90e4854d40ba190cf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:58:20 -0400 Subject: [PATCH 09/44] refactor: centralize dispatch error projection from failure signals --- sdk/src/query/query-dispatch-error-mapper.ts | 11 +++------- sdk/src/query/query-error-taxonomy.test.ts | 8 ++++++++ sdk/src/query/query-error-taxonomy.ts | 21 +++++++++++++++++++- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index 5d4a725af..bd377be19 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,6 +1,6 @@ import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; import { toFailureSignal } from '../query-failure-classification.js'; -import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; +import { fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from './query-error-taxonomy.js'; import { dispatchFailure } from './query-dispatch-result-builder.js'; export function toDispatchFailure( @@ -11,14 +11,9 @@ export function toDispatchFailure( } export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const signal = toFailureSignal(error); - if (signal.kind === 'timeout') { - return nativeTimeoutError({ message: signal.message, command, args, timeoutMs: signal.timeoutMs }); - } - return nativeFailureError({ message: signal.message, command, args }); + return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); } export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const signal = toFailureSignal(error); - return fallbackFailureError({ message: signal.message, command, args, backend: 'cjs' }); + return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); } diff --git a/sdk/src/query/query-error-taxonomy.test.ts b/sdk/src/query/query-error-taxonomy.test.ts index 3f900d736..823e3e7f0 100644 --- a/sdk/src/query/query-error-taxonomy.test.ts +++ b/sdk/src/query/query-error-taxonomy.test.ts @@ -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'); + }); }); diff --git a/sdk/src/query/query-error-taxonomy.ts b/sdk/src/query/query-error-taxonomy.ts index c43e440e1..67543728e 100644 --- a/sdk/src/query/query-error-taxonomy.ts +++ b/sdk/src/query/query-error-taxonomy.ts @@ -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' }); +} From 1ca7f588312305f00842f4e2e01032d1d15f4064 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:58:45 -0400 Subject: [PATCH 10/44] test: cover tools error mapping and unify timeout fallback check --- sdk/src/gsd-transport.ts | 4 ++-- sdk/src/query-tools-error-mapper.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 sdk/src/query-tools-error-mapper.test.ts diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 67164310d..69be7b680 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -1,7 +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 { isTimeoutLikeError } from './query-failure-classification.js'; +import { toFailureSignal } from './query-failure-classification.js'; export interface TransportRequest { legacyCommand: string; @@ -49,7 +49,7 @@ export class GSDTransport { // 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 (toFailureSignal(error).kind === 'timeout') throw error; } } diff --git a/sdk/src/query-tools-error-mapper.test.ts b/sdk/src/query-tools-error-mapper.test.ts new file mode 100644 index 000000000..c4c0dd0e6 --- /dev/null +++ b/sdk/src/query-tools-error-mapper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorClassification, GSDError } from './errors.js'; +import { toGSDToolsError } from './query-tools-error-mapper.js'; + +describe('query tools error mapper', () => { + it('maps GSDError to GSDToolsError exit code', () => { + const err = toGSDToolsError('state', ['load'], new GSDError('bad input', ErrorClassification.Validation)); + expect(err.exitCode).toBe(10); + expect(err.message).toBe('bad input'); + }); + + it('attaches timeout classification when message indicates timeout', () => { + const err = toGSDToolsError('state', ['load'], new Error('gsd-tools timed out after 1234ms: state load')); + expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1234 }); + }); + + it('does not attach timeout classification for non-timeout failures', () => { + const err = toGSDToolsError('state', ['load'], new Error('boom')); + expect(err.classification).toBeUndefined(); + }); +}); From ccda572aded06f75b91ecefec105d25ff73db3d8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:01:08 -0400 Subject: [PATCH 11/44] refactor: default typed failure classification across query errors --- sdk/src/query-gsd-tools-runtime.ts | 4 +++- sdk/src/query-tools-error-mapper.test.ts | 4 ++-- sdk/src/query-tools-error-mapper.ts | 18 ++++++++---------- .../query/query-dispatch-error-mapper.test.ts | 11 +++++++++++ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 1ca5dfd75..6ff59c513 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -34,7 +34,9 @@ export function createGSDToolsRuntime(opts: { timeoutMs: opts.timeoutMs, workstream: opts.workstream, createToolsError: (message, command, args, exitCode, stderr, classification) => - new GSDToolsError(message, command, args, exitCode, stderr, classification ? { classification } : undefined), + new GSDToolsError(message, command, args, exitCode, stderr, { + classification: classification ?? { kind: 'failure' }, + }), }); const nativeDirectAdapter = new QueryNativeDirectAdapter({ diff --git a/sdk/src/query-tools-error-mapper.test.ts b/sdk/src/query-tools-error-mapper.test.ts index c4c0dd0e6..8915e5975 100644 --- a/sdk/src/query-tools-error-mapper.test.ts +++ b/sdk/src/query-tools-error-mapper.test.ts @@ -14,8 +14,8 @@ describe('query tools error mapper', () => { expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1234 }); }); - it('does not attach timeout classification for non-timeout failures', () => { + it('attaches failure classification for non-timeout failures', () => { const err = toGSDToolsError('state', ['load'], new Error('boom')); - expect(err.classification).toBeUndefined(); + expect(err.classification).toEqual({ kind: 'failure' }); }); }); diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 44898ed03..357af2fe4 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,6 +1,6 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; -import { isTimeoutMessage, errorMessage, parseTimeoutMs } from './query-failure-classification.js'; +import { errorMessage, toFailureSignal } from './query-failure-classification.js'; /** * Module owning projection of internal errors to GSDToolsError contract. @@ -18,6 +18,11 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): } const msg = errorMessage(err); + const signal = toFailureSignal(err); + const classification = signal.kind === 'timeout' + ? { kind: 'timeout' as const, timeoutMs: signal.timeoutMs } + : { kind: 'failure' as const }; + return new GSDToolsError( msg, command, @@ -25,14 +30,7 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): 1, '', err instanceof Error - ? { - cause: err, - ...(isTimeoutMessage(msg) - ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } - : undefined), - } - : (isTimeoutMessage(msg) - ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } - : undefined), + ? { cause: err, classification } + : { classification }, ); } diff --git a/sdk/src/query/query-dispatch-error-mapper.test.ts b/sdk/src/query/query-dispatch-error-mapper.test.ts index 0fa140f89..24a47c0cf 100644 --- a/sdk/src/query/query-dispatch-error-mapper.test.ts +++ b/sdk/src/query/query-dispatch-error-mapper.test.ts @@ -37,6 +37,17 @@ describe('query dispatch error mapper', () => { expect(err.details).toMatchObject({ timeout_ms: 1234 }); }); + it('maps typed failure classification from GSDToolsError', () => { + const err = mapNativeDispatchError( + new GSDToolsError('boom', 'state', ['load'], 1, '', { + classification: { kind: 'failure' }, + }), + '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'); From 7dcafbc211f0861e8f089fb7460861e1d615ae01 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:02:38 -0400 Subject: [PATCH 12/44] refactor: consolidate failure classification constructors --- sdk/src/gsd-tools-error.ts | 8 ++++++++ sdk/src/query-failure-classification.test.ts | 8 -------- sdk/src/query-failure-classification.ts | 6 ------ sdk/src/query-gsd-tools-runtime.ts | 6 +++--- sdk/src/query-tools-error-mapper.ts | 6 +++--- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index 68a5a1bc6..bc5d79880 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -3,6 +3,14 @@ export interface GSDToolsErrorClassification { timeoutMs?: number; } +export function timeoutClassification(timeoutMs?: number): GSDToolsErrorClassification { + return timeoutMs === undefined ? { kind: 'timeout' } : { kind: 'timeout', timeoutMs }; +} + +export function failureClassification(): GSDToolsErrorClassification { + return { kind: 'failure' }; +} + export class GSDToolsError extends Error { constructor( message: string, diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts index 4232304c7..5f3774c74 100644 --- a/sdk/src/query-failure-classification.test.ts +++ b/sdk/src/query-failure-classification.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { errorMessage, - isTimeoutLikeError, isTimeoutMessage, parseTimeoutMs, timeoutMessage, @@ -16,13 +15,6 @@ describe('query failure classification', () => { expect(parseTimeoutMs(msg)).toBe(30000); }); - it('classifies timeout-like errors', () => { - expect(isTimeoutLikeError(new Error('gsd-tools timed out after 1000ms: x'))).toBe(true); - const abort = new Error('aborted'); - abort.name = 'AbortError'; - expect(isTimeoutLikeError(abort)).toBe(true); - }); - it('normalizes unknown error values', () => { expect(errorMessage('boom')).toBe('boom'); expect(errorMessage(new Error('x'))).toBe('x'); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts index 5fdda719e..e5a4a8aad 100644 --- a/sdk/src/query-failure-classification.ts +++ b/sdk/src/query-failure-classification.ts @@ -21,12 +21,6 @@ export function isTimeoutMessage(message: string): boolean { return /timed out after/i.test(message); } -export function isTimeoutLikeError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; - return isTimeoutMessage(error.message); -} - export function timeoutMessage(command: string, args: string[], timeoutMs: number): string { return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`; } diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 6ff59c513..3a9f72d92 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -7,7 +7,7 @@ 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 { failureClassification, GSDToolsError, timeoutClassification } from './gsd-tools-error.js'; export interface GSDToolsRuntime { registry: ReturnType; @@ -35,7 +35,7 @@ export function createGSDToolsRuntime(opts: { workstream: opts.workstream, createToolsError: (message, command, args, exitCode, stderr, classification) => new GSDToolsError(message, command, args, exitCode, stderr, { - classification: classification ?? { kind: 'failure' }, + classification: classification ?? failureClassification(), }), }); @@ -43,7 +43,7 @@ export function createGSDToolsRuntime(opts: { timeoutMs: opts.timeoutMs, dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir), createTimeoutError: (message, command, args) => - new GSDToolsError(message, command, args, null, '', { classification: { kind: 'timeout', timeoutMs: opts.timeoutMs } }), + new GSDToolsError(message, command, args, null, '', { classification: timeoutClassification(opts.timeoutMs) }), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 357af2fe4..ee58ec0a8 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,5 +1,5 @@ import { GSDError, exitCodeFor } from './errors.js'; -import { GSDToolsError } from './gsd-tools-error.js'; +import { failureClassification, GSDToolsError, timeoutClassification } from './gsd-tools-error.js'; import { errorMessage, toFailureSignal } from './query-failure-classification.js'; /** @@ -20,8 +20,8 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): const msg = errorMessage(err); const signal = toFailureSignal(err); const classification = signal.kind === 'timeout' - ? { kind: 'timeout' as const, timeoutMs: signal.timeoutMs } - : { kind: 'failure' as const }; + ? timeoutClassification(signal.timeoutMs) + : failureClassification(); return new GSDToolsError( msg, From 41683b2f53682b8f23a9d93238599ebd65a18142 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:03:56 -0400 Subject: [PATCH 13/44] refactor: centralize typed GSDToolsError construction --- sdk/src/gsd-tools-error.test.ts | 16 +++++++++++++ sdk/src/gsd-tools-error.ts | 36 +++++++++++++++++++++++++++++ sdk/src/query-gsd-tools-runtime.ts | 10 ++++---- sdk/src/query-tools-error-mapper.ts | 21 +++++------------ 4 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 sdk/src/gsd-tools-error.test.ts diff --git a/sdk/src/gsd-tools-error.test.ts b/sdk/src/gsd-tools-error.test.ts new file mode 100644 index 000000000..704de685a --- /dev/null +++ b/sdk/src/gsd-tools-error.test.ts @@ -0,0 +1,16 @@ +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); + }); +}); diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index bc5d79880..1d54e26d3 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -25,5 +25,41 @@ export class GSDToolsError extends Error { this.classification = options?.classification; } + 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; } diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 3a9f72d92..07a9e9063 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -7,7 +7,7 @@ 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 { failureClassification, GSDToolsError, timeoutClassification } from './gsd-tools-error.js'; +import { GSDToolsError } from './gsd-tools-error.js'; export interface GSDToolsRuntime { registry: ReturnType; @@ -34,16 +34,16 @@ export function createGSDToolsRuntime(opts: { timeoutMs: opts.timeoutMs, workstream: opts.workstream, createToolsError: (message, command, args, exitCode, stderr, classification) => - new GSDToolsError(message, command, args, exitCode, stderr, { - classification: classification ?? failureClassification(), - }), + classification?.kind === 'timeout' + ? GSDToolsError.timeout(message, command, args, stderr, classification.timeoutMs, { exitCode }) + : GSDToolsError.failure(message, command, args, exitCode, stderr), }); 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, '', { classification: timeoutClassification(opts.timeoutMs) }), + GSDToolsError.timeout(message, command, args, '', opts.timeoutMs), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index ee58ec0a8..06f54b6eb 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,5 +1,5 @@ import { GSDError, exitCodeFor } from './errors.js'; -import { failureClassification, GSDToolsError, timeoutClassification } from './gsd-tools-error.js'; +import { GSDToolsError } from './gsd-tools-error.js'; import { errorMessage, toFailureSignal } from './query-failure-classification.js'; /** @@ -7,7 +7,7 @@ import { errorMessage, toFailureSignal } from './query-failure-classification.js */ export function toGSDToolsError(command: string, args: string[], err: unknown): GSDToolsError { if (err instanceof GSDError) { - return new GSDToolsError( + return GSDToolsError.failure( err.message, command, args, @@ -19,18 +19,9 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): const msg = errorMessage(err); const signal = toFailureSignal(err); - const classification = signal.kind === 'timeout' - ? timeoutClassification(signal.timeoutMs) - : failureClassification(); + if (signal.kind === 'timeout') { + return GSDToolsError.timeout(msg, command, args, '', signal.timeoutMs, err instanceof Error ? { cause: err } : undefined); + } - return new GSDToolsError( - msg, - command, - args, - 1, - '', - err instanceof Error - ? { cause: err, classification } - : { classification }, - ); + return GSDToolsError.failure(msg, command, args, 1, '', err instanceof Error ? { cause: err } : undefined); } From 6fe4af2546b428ce65db72c061ef66eb858defc4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:05:09 -0400 Subject: [PATCH 14/44] refactor: split subprocess timeout and failure error seams --- sdk/src/query-gsd-tools-runtime.ts | 8 +++--- sdk/src/query-subprocess-adapter.test.ts | 4 ++- sdk/src/query-subprocess-adapter.ts | 32 +++++++++++++----------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 07a9e9063..3ea879768 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -33,10 +33,10 @@ export function createGSDToolsRuntime(opts: { gsdToolsPath: opts.gsdToolsPath, timeoutMs: opts.timeoutMs, workstream: opts.workstream, - createToolsError: (message, command, args, exitCode, stderr, classification) => - classification?.kind === 'timeout' - ? GSDToolsError.timeout(message, command, args, stderr, classification.timeoutMs, { exitCode }) - : GSDToolsError.failure(message, command, args, exitCode, stderr), + createTimeoutError: (message, command, args, stderr, timeoutMs) => + GSDToolsError.timeout(message, command, args, stderr, timeoutMs), + createFailureError: (message, command, args, exitCode, stderr) => + GSDToolsError.failure(message, command, args, exitCode, stderr), }); const nativeDirectAdapter = new QueryNativeDirectAdapter({ diff --git a/sdk/src/query-subprocess-adapter.test.ts b/sdk/src/query-subprocess-adapter.test.ts index c2ab850c3..fae7c4a5b 100644 --- a/sdk/src/query-subprocess-adapter.test.ts +++ b/sdk/src/query-subprocess-adapter.test.ts @@ -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, }); } diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index 474892dd7..ce940212d 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,20 +1,26 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; -import type { GSDToolsError, GSDToolsErrorClassification } from './gsd-tools-error.js'; +import type { GSDToolsError } from './gsd-tools-error.js'; export interface QuerySubprocessAdapterDeps { projectDir: string; gsdToolsPath: string; timeoutMs: number; workstream?: string; - createToolsError: ( + createTimeoutError: ( + message: string, + command: string, + args: string[], + stderr: string, + timeoutMs: number, + ) => GSDToolsError; + createFailureError: ( message: string, command: string, args: string[], exitCode: number | null, stderr: string, - classification?: GSDToolsErrorClassification, ) => GSDToolsError; } @@ -41,20 +47,19 @@ export class QuerySubprocessAdapter { if (error) { if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { reject( - this.deps.createToolsError( + this.deps.createTimeoutError( timeoutMessage(command, args, this.deps.timeoutMs), command, args, - null, stderrStr, - { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, + this.deps.timeoutMs, ), ); return; } reject( - this.deps.createToolsError( + this.deps.createFailureError( `gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`, command, args, @@ -71,7 +76,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, @@ -84,7 +89,7 @@ export class QuerySubprocessAdapter { ); child.on('error', (err) => { - reject(this.deps.createToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '')); + reject(this.deps.createFailureError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '')); }); }); } @@ -108,19 +113,18 @@ export class QuerySubprocessAdapter { if (error) { if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { reject( - this.deps.createToolsError( + this.deps.createTimeoutError( timeoutMessage(command, args, this.deps.timeoutMs), command, args, - null, stderrStr, - { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, + this.deps.timeoutMs, ), ); return; } reject( - this.deps.createToolsError( + this.deps.createFailureError( `gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`, command, args, @@ -135,7 +139,7 @@ export class QuerySubprocessAdapter { ); child.on('error', (err) => { - reject(this.deps.createToolsError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '')); + reject(this.deps.createFailureError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '')); }); }); } From 009cfb15629e7956498a74d581be972fe6f2ee9c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:06:30 -0400 Subject: [PATCH 15/44] refactor: split native adapter timeout and failure seams --- sdk/src/query-gsd-tools-runtime.ts | 2 ++ sdk/src/query-native-direct-adapter.test.ts | 35 +++++++++++++++++++++ sdk/src/query-native-direct-adapter.ts | 11 +++++-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 sdk/src/query-native-direct-adapter.test.ts diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 3ea879768..1db59bc82 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -44,6 +44,8 @@ export function createGSDToolsRuntime(opts: { dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir), createTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args, '', opts.timeoutMs), + createFailureError: (message, command, args, cause) => + GSDToolsError.failure(message, command, args, 1, '', { cause }), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-native-direct-adapter.test.ts b/sdk/src/query-native-direct-adapter.test.ts new file mode 100644 index 000000000..aa06edf08 --- /dev/null +++ b/sdk/src/query-native-direct-adapter.test.ts @@ -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'); + }, + createTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args), + createFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }), + }); + + await expect(adapter.dispatchJson('state', ['load'], 'state.load', [])).rejects.toMatchObject({ + classification: { kind: 'failure' }, + command: 'state', + }); + }); + + it('preserves timeout errors', async () => { + const timeoutErr = GSDToolsError.timeout('timeout', 'state', ['load']); + const adapter = new QueryNativeDirectAdapter({ + timeoutMs: 1000, + dispatch: async () => { + throw timeoutErr; + }, + createTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args), + createFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }), + }); + + await expect(adapter.dispatchJson('state', ['load'], 'state.load', [])).rejects.toBe(timeoutErr); + }); +}); diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index 96a4982e5..673af1cc2 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -1,11 +1,13 @@ import { formatQueryRawOutput } from './query-raw-output-projection.js'; -import { timeoutMessage } from './query-failure-classification.js'; +import { GSDToolsError } from './gsd-tools-error.js'; +import { errorMessage, timeoutMessage } from './query-failure-classification.js'; import type { QueryResult } from './query/utils.js'; export interface QueryNativeDirectAdapterDeps { timeoutMs: number; dispatch: (registryCommand: string, registryArgs: string[]) => Promise; createTimeoutError: (message: string, command: string, args: string[]) => Error; + createFailureError: (message: string, command: string, args: string[], cause: unknown) => Error; } /** @@ -15,7 +17,12 @@ export class QueryNativeDirectAdapter { constructor(private readonly deps: QueryNativeDirectAdapterDeps) {} async dispatchResult(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise { - return this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs)); + try { + return await this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs)); + } catch (error) { + if (error instanceof GSDToolsError) throw error; + throw this.deps.createFailureError(errorMessage(error), legacyCommand, legacyArgs, error); + } } async dispatchJson(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise { From 16bf5520379acea1bc4be8b92a0b5b001b17ac24 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:07:08 -0400 Subject: [PATCH 16/44] test: lock typed timeout no-fallback transport behavior --- sdk/src/gsd-transport.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts index d2eefcfb2..af5a71669 100644 --- a/sdk/src/gsd-transport.test.ts +++ b/sdk/src/gsd-transport.test.ts @@ -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' } })); From abf7779088d6c47f823dae1e76ea25862e81dccb Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:08:07 -0400 Subject: [PATCH 17/44] test: cover typed timeout mapping in query dispatch --- sdk/src/query/query-dispatch.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/query-dispatch.test.ts b/sdk/src/query/query-dispatch.test.ts index 359744f99..01ccbf9a1 100644 --- a/sdk/src/query/query-dispatch.test.ts +++ b/sdk/src/query/query-dispatch.test.ts @@ -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({ From 9a469fa05ca8cc15cee594ec646277bb400e0967 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:09:20 -0400 Subject: [PATCH 18/44] refactor: centralize query tools error construction in factory --- sdk/src/query-gsd-tools-runtime.ts | 10 ++++---- sdk/src/query-tools-error-factory.test.ts | 25 ++++++++++++++++++ sdk/src/query-tools-error-factory.ts | 31 +++++++++++++++++++++++ sdk/src/query-tools-error-mapper.ts | 24 +++--------------- 4 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 sdk/src/query-tools-error-factory.test.ts create mode 100644 sdk/src/query-tools-error-factory.ts diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 1db59bc82..d58b38e3f 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -7,7 +7,7 @@ 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 { failureToolsError, timeoutToolsError } from './query-tools-error-factory.js'; export interface GSDToolsRuntime { registry: ReturnType; @@ -34,18 +34,18 @@ export function createGSDToolsRuntime(opts: { timeoutMs: opts.timeoutMs, workstream: opts.workstream, createTimeoutError: (message, command, args, stderr, timeoutMs) => - GSDToolsError.timeout(message, command, args, stderr, timeoutMs), + timeoutToolsError(message, command, args, stderr, timeoutMs), createFailureError: (message, command, args, exitCode, stderr) => - GSDToolsError.failure(message, command, args, exitCode, stderr), + failureToolsError(message, command, args, exitCode, stderr), }); const nativeDirectAdapter = new QueryNativeDirectAdapter({ timeoutMs: opts.timeoutMs, dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir), createTimeoutError: (message, command, args) => - GSDToolsError.timeout(message, command, args, '', opts.timeoutMs), + timeoutToolsError(message, command, args, '', opts.timeoutMs), createFailureError: (message, command, args, cause) => - GSDToolsError.failure(message, command, args, 1, '', { cause }), + failureToolsError(message, command, args, 1, '', cause), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-tools-error-factory.test.ts b/sdk/src/query-tools-error-factory.test.ts new file mode 100644 index 000000000..dad1075e6 --- /dev/null +++ b/sdk/src/query-tools-error-factory.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorClassification, GSDError } from './errors.js'; +import { + failureToolsError, + timeoutToolsError, + toToolsErrorFromUnknown, +} from './query-tools-error-factory.js'; + +describe('query tools error factory', () => { + it('builds timeout and failure tools errors', () => { + expect(timeoutToolsError('t', 'state', ['load'], '', 10).classification).toEqual({ kind: 'timeout', timeoutMs: 10 }); + expect(failureToolsError('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 }); + }); +}); diff --git a/sdk/src/query-tools-error-factory.ts b/sdk/src/query-tools-error-factory.ts new file mode 100644 index 000000000..838f51381 --- /dev/null +++ b/sdk/src/query-tools-error-factory.ts @@ -0,0 +1,31 @@ +import { GSDError, exitCodeFor } from './errors.js'; +import { GSDToolsError } from './gsd-tools-error.js'; +import { errorMessage, toFailureSignal } from './query-failure-classification.js'; + +export function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError { + return GSDToolsError.timeout(message, command, args, stderr, timeoutMs); +} + +export 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); +} diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 06f54b6eb..835545b6e 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,27 +1,9 @@ -import { GSDError, exitCodeFor } from './errors.js'; -import { GSDToolsError } from './gsd-tools-error.js'; -import { errorMessage, toFailureSignal } from './query-failure-classification.js'; +import { toToolsErrorFromUnknown } from './query-tools-error-factory.js'; +import type { 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 GSDToolsError.failure( - err.message, - command, - args, - exitCodeFor(err.classification), - '', - { cause: err }, - ); - } - - const msg = errorMessage(err); - const signal = toFailureSignal(err); - if (signal.kind === 'timeout') { - return GSDToolsError.timeout(msg, command, args, '', signal.timeoutMs, err instanceof Error ? { cause: err } : undefined); - } - - return GSDToolsError.failure(msg, command, args, 1, '', err instanceof Error ? { cause: err } : undefined); + return toToolsErrorFromUnknown(command, args, err); } From 9bee4dce4afdc15d2f3e3b61e7fe8408267203b7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:09:48 -0400 Subject: [PATCH 19/44] test: adopt typed GSDToolsError constructors across failure tests --- sdk/src/query-failure-classification.test.ts | 4 +--- sdk/src/query-native-direct-adapter.test.ts | 2 +- sdk/src/query/query-dispatch-error-mapper.test.ts | 8 ++------ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts index 5f3774c74..8c1a52459 100644 --- a/sdk/src/query-failure-classification.test.ts +++ b/sdk/src/query-failure-classification.test.ts @@ -21,9 +21,7 @@ describe('query failure classification', () => { }); it('prefers typed classification from GSDToolsError', () => { - const err = new GSDToolsError('x', 'state', ['load'], null, '', { - classification: { kind: 'timeout', timeoutMs: 2000 }, - }); + const err = GSDToolsError.timeout('x', 'state', ['load'], '', 2000); expect(toFailureSignal(err)).toEqual({ kind: 'timeout', message: 'x', timeoutMs: 2000 }); }); }); diff --git a/sdk/src/query-native-direct-adapter.test.ts b/sdk/src/query-native-direct-adapter.test.ts index aa06edf08..a8dd2c5b9 100644 --- a/sdk/src/query-native-direct-adapter.test.ts +++ b/sdk/src/query-native-direct-adapter.test.ts @@ -14,7 +14,7 @@ describe('QueryNativeDirectAdapter', () => { }); await expect(adapter.dispatchJson('state', ['load'], 'state.load', [])).rejects.toMatchObject({ - classification: { kind: 'failure' }, + classification: GSDToolsError.failure('x', 'state', ['load'], 1).classification, command: 'state', }); }); diff --git a/sdk/src/query/query-dispatch-error-mapper.test.ts b/sdk/src/query/query-dispatch-error-mapper.test.ts index 24a47c0cf..b4fe8beb5 100644 --- a/sdk/src/query/query-dispatch-error-mapper.test.ts +++ b/sdk/src/query/query-dispatch-error-mapper.test.ts @@ -27,9 +27,7 @@ describe('query dispatch error mapper', () => { it('maps typed timeout classification from GSDToolsError', () => { const err = mapNativeDispatchError( - new GSDToolsError('timeout', 'state', ['load'], null, '', { - classification: { kind: 'timeout', timeoutMs: 1234 }, - }), + GSDToolsError.timeout('timeout', 'state', ['load'], '', 1234), 'state.load', [], ); @@ -39,9 +37,7 @@ describe('query dispatch error mapper', () => { it('maps typed failure classification from GSDToolsError', () => { const err = mapNativeDispatchError( - new GSDToolsError('boom', 'state', ['load'], 1, '', { - classification: { kind: 'failure' }, - }), + GSDToolsError.failure('boom', 'state', ['load'], 1), 'state.load', [], ); From bc289fad4a3ccac2daa39eaa5cb23227239d48cc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:10:24 -0400 Subject: [PATCH 20/44] refactor: type native adapter error seam to GSDToolsError --- sdk/src/query-native-direct-adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index 673af1cc2..ba6e2060d 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -6,8 +6,8 @@ import type { QueryResult } from './query/utils.js'; export interface QueryNativeDirectAdapterDeps { timeoutMs: number; dispatch: (registryCommand: string, registryArgs: string[]) => Promise; - createTimeoutError: (message: string, command: string, args: string[]) => Error; - createFailureError: (message: string, command: string, args: string[], cause: unknown) => Error; + createTimeoutError: (message: string, command: string, args: string[]) => GSDToolsError; + createFailureError: (message: string, command: string, args: string[], cause: unknown) => GSDToolsError; } /** From c7d3f83b8ba00bfb23422c8e6310399f6f1d7ce6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:10:52 -0400 Subject: [PATCH 21/44] refactor: reduce failure-classification API surface --- sdk/src/query-failure-classification.test.ts | 6 +----- sdk/src/query-failure-classification.ts | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts index 8c1a52459..f6dbe0115 100644 --- a/sdk/src/query-failure-classification.test.ts +++ b/sdk/src/query-failure-classification.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { errorMessage, - isTimeoutMessage, - parseTimeoutMs, timeoutMessage, toFailureSignal, } from './query-failure-classification.js'; @@ -11,10 +9,8 @@ import { GSDToolsError } from './gsd-tools-error.js'; describe('query failure classification', () => { it('extracts timeout metadata from message', () => { const msg = timeoutMessage('state', ['load'], 30000); - expect(isTimeoutMessage(msg)).toBe(true); - expect(parseTimeoutMs(msg)).toBe(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'); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts index e5a4a8aad..7d90f3648 100644 --- a/sdk/src/query-failure-classification.ts +++ b/sdk/src/query-failure-classification.ts @@ -10,14 +10,14 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function parseTimeoutMs(message: string): number | undefined { +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; } -export function isTimeoutMessage(message: string): boolean { +function isTimeoutMessage(message: string): boolean { return /timed out after/i.test(message); } From b9e3979fc152a7883f67b9bbaecc6da23f78cdd2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:12:11 -0400 Subject: [PATCH 22/44] refactor: introduce explicit query error seam contracts --- sdk/src/query-gsd-tools-runtime.ts | 25 ++++++++++++------ sdk/src/query-native-direct-adapter.test.ts | 8 +++--- sdk/src/query-native-direct-adapter.ts | 9 +++---- sdk/src/query-subprocess-adapter.ts | 18 ++----------- sdk/src/query-tools-error-seam.ts | 28 +++++++++++++++++++++ 5 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 sdk/src/query-tools-error-seam.ts diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index d58b38e3f..22a00f4fb 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -8,6 +8,7 @@ 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 { failureToolsError, timeoutToolsError } from './query-tools-error-factory.js'; +import type { QueryNativeErrorFactory, QueryToolsErrorFactory } from './query-tools-error-seam.js'; export interface GSDToolsRuntime { registry: ReturnType; @@ -28,24 +29,32 @@ export function createGSDToolsRuntime(opts: { }): GSDToolsRuntime { const registry = createRegistry(opts.eventStream, opts.sessionId); + const queryToolsErrorFactory: QueryToolsErrorFactory = { + createTimeoutError: (message, command, args, stderr, timeoutMs) => + timeoutToolsError(message, command, args, stderr, timeoutMs), + createFailureError: (message, command, args, exitCode, stderr) => + failureToolsError(message, command, args, exitCode, stderr), + }; + const subprocessAdapter = new QuerySubprocessAdapter({ projectDir: opts.projectDir, gsdToolsPath: opts.gsdToolsPath, timeoutMs: opts.timeoutMs, workstream: opts.workstream, - createTimeoutError: (message, command, args, stderr, timeoutMs) => - timeoutToolsError(message, command, args, stderr, timeoutMs), - createFailureError: (message, command, args, exitCode, stderr) => - failureToolsError(message, command, args, exitCode, stderr), + ...queryToolsErrorFactory, }); + const nativeErrorFactory: QueryNativeErrorFactory = { + createNativeTimeoutError: (message, command, args) => + timeoutToolsError(message, command, args, '', opts.timeoutMs), + createNativeFailureError: (message, command, args, cause) => + failureToolsError(message, command, args, 1, '', cause), + }; + const nativeDirectAdapter = new QueryNativeDirectAdapter({ timeoutMs: opts.timeoutMs, dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir), - createTimeoutError: (message, command, args) => - timeoutToolsError(message, command, args, '', opts.timeoutMs), - createFailureError: (message, command, args, cause) => - failureToolsError(message, command, args, 1, '', cause), + ...nativeErrorFactory, }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-native-direct-adapter.test.ts b/sdk/src/query-native-direct-adapter.test.ts index a8dd2c5b9..d966fc737 100644 --- a/sdk/src/query-native-direct-adapter.test.ts +++ b/sdk/src/query-native-direct-adapter.test.ts @@ -9,8 +9,8 @@ describe('QueryNativeDirectAdapter', () => { dispatch: async () => { throw new Error('boom'); }, - createTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args), - createFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }), + 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({ @@ -26,8 +26,8 @@ describe('QueryNativeDirectAdapter', () => { dispatch: async () => { throw timeoutErr; }, - createTimeoutError: (message, command, args) => GSDToolsError.timeout(message, command, args), - createFailureError: (message, command, args, cause) => GSDToolsError.failure(message, command, args, 1, '', { cause }), + 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); diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index ba6e2060d..0aa4a6ac8 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -1,13 +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-seam.js'; import type { QueryResult } from './query/utils.js'; -export interface QueryNativeDirectAdapterDeps { +export interface QueryNativeDirectAdapterDeps extends QueryNativeErrorFactory { timeoutMs: number; dispatch: (registryCommand: string, registryArgs: string[]) => Promise; - createTimeoutError: (message: string, command: string, args: string[]) => GSDToolsError; - createFailureError: (message: string, command: string, args: string[], cause: unknown) => GSDToolsError; } /** @@ -21,7 +20,7 @@ export class QueryNativeDirectAdapter { return await this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs)); } catch (error) { if (error instanceof GSDToolsError) throw error; - throw this.deps.createFailureError(errorMessage(error), legacyCommand, legacyArgs, error); + throw this.deps.createNativeFailureError(errorMessage(error), legacyCommand, legacyArgs, error); } } @@ -40,7 +39,7 @@ export class QueryNativeDirectAdapter { const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject( - this.deps.createTimeoutError( + this.deps.createNativeTimeoutError( timeoutMessage(legacyCommand, legacyArgs, this.deps.timeoutMs), legacyCommand, legacyArgs, diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index ce940212d..8fac76050 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,27 +1,13 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; -import type { GSDToolsError } from './gsd-tools-error.js'; +import type { QueryToolsErrorFactory } from './query-tools-error-seam.js'; -export interface QuerySubprocessAdapterDeps { +export interface QuerySubprocessAdapterDeps extends QueryToolsErrorFactory { projectDir: string; gsdToolsPath: string; timeoutMs: number; workstream?: string; - createTimeoutError: ( - message: string, - command: string, - args: string[], - stderr: string, - timeoutMs: number, - ) => GSDToolsError; - createFailureError: ( - message: string, - command: string, - args: string[], - exitCode: number | null, - stderr: string, - ) => GSDToolsError; } export class QuerySubprocessAdapter { diff --git a/sdk/src/query-tools-error-seam.ts b/sdk/src/query-tools-error-seam.ts new file mode 100644 index 000000000..def5697a3 --- /dev/null +++ b/sdk/src/query-tools-error-seam.ts @@ -0,0 +1,28 @@ +import type { GSDToolsError } from './gsd-tools-error.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; +} From 70faa0ff0f6a2ec90d9160dc0958f3314f753054 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:12:58 -0400 Subject: [PATCH 23/44] refactor: remove query tools error mapper wrapper --- sdk/src/gsd-tools.ts | 4 ++-- sdk/src/query-tools-error-mapper.test.ts | 8 ++++---- sdk/src/query-tools-error-mapper.ts | 9 --------- 3 files changed, 6 insertions(+), 15 deletions(-) delete mode 100644 sdk/src/query-tools-error-mapper.ts diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 7d72e3b28..28d49d9e9 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -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'; @@ -108,7 +108,7 @@ export class GSDTools { } private toToolsError(command: string, args: string[], err: unknown): GSDToolsError { - return toGSDToolsError(command, args, err); + return toToolsErrorFromUnknown(command, args, err); } private async dispatchNativeHotpath( diff --git a/sdk/src/query-tools-error-mapper.test.ts b/sdk/src/query-tools-error-mapper.test.ts index 8915e5975..a04be72ad 100644 --- a/sdk/src/query-tools-error-mapper.test.ts +++ b/sdk/src/query-tools-error-mapper.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest'; import { ErrorClassification, GSDError } from './errors.js'; -import { toGSDToolsError } from './query-tools-error-mapper.js'; +import { toToolsErrorFromUnknown } from './query-tools-error-factory.js'; describe('query tools error mapper', () => { it('maps GSDError to GSDToolsError exit code', () => { - const err = toGSDToolsError('state', ['load'], new GSDError('bad input', ErrorClassification.Validation)); + const err = toToolsErrorFromUnknown('state', ['load'], new GSDError('bad input', ErrorClassification.Validation)); expect(err.exitCode).toBe(10); expect(err.message).toBe('bad input'); }); it('attaches timeout classification when message indicates timeout', () => { - const err = toGSDToolsError('state', ['load'], new Error('gsd-tools timed out after 1234ms: state load')); + const err = toToolsErrorFromUnknown('state', ['load'], new Error('gsd-tools timed out after 1234ms: state load')); expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1234 }); }); it('attaches failure classification for non-timeout failures', () => { - const err = toGSDToolsError('state', ['load'], new Error('boom')); + const err = toToolsErrorFromUnknown('state', ['load'], new Error('boom')); expect(err.classification).toEqual({ kind: 'failure' }); }); }); diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts deleted file mode 100644 index 835545b6e..000000000 --- a/sdk/src/query-tools-error-mapper.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { toToolsErrorFromUnknown } from './query-tools-error-factory.js'; -import type { 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 { - return toToolsErrorFromUnknown(command, args, err); -} From a24de43f8bf4d42affc8ca2e788086aa8a2f9e0c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:13:13 -0400 Subject: [PATCH 24/44] test: consolidate tools error mapping coverage in factory tests --- sdk/src/query-tools-error-mapper.test.ts | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 sdk/src/query-tools-error-mapper.test.ts diff --git a/sdk/src/query-tools-error-mapper.test.ts b/sdk/src/query-tools-error-mapper.test.ts deleted file mode 100644 index a04be72ad..000000000 --- a/sdk/src/query-tools-error-mapper.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ErrorClassification, GSDError } from './errors.js'; -import { toToolsErrorFromUnknown } from './query-tools-error-factory.js'; - -describe('query tools error mapper', () => { - it('maps GSDError to GSDToolsError exit code', () => { - const err = toToolsErrorFromUnknown('state', ['load'], new GSDError('bad input', ErrorClassification.Validation)); - expect(err.exitCode).toBe(10); - expect(err.message).toBe('bad input'); - }); - - it('attaches timeout classification when message indicates timeout', () => { - const err = toToolsErrorFromUnknown('state', ['load'], new Error('gsd-tools timed out after 1234ms: state load')); - expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1234 }); - }); - - it('attaches failure classification for non-timeout failures', () => { - const err = toToolsErrorFromUnknown('state', ['load'], new Error('boom')); - expect(err.classification).toEqual({ kind: 'failure' }); - }); -}); From c66ff96de8be7fdcd4bcc7f162d00bfce721d41b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:13:32 -0400 Subject: [PATCH 25/44] test: use typed GSDToolsError constructors in cli output tests --- sdk/src/query/query-cli-output.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/src/query/query-cli-output.test.ts b/sdk/src/query/query-cli-output.test.ts index 894c2ac28..eedbffeee 100644 --- a/sdk/src/query/query-cli-output.test.ts +++ b/sdk/src/query/query-cli-output.test.ts @@ -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']); From 7311e0a9abae658efde66898851d9596f937d702 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:17:08 -0400 Subject: [PATCH 26/44] refactor: extract query error seam factory builders --- sdk/src/query-gsd-tools-runtime.ts | 17 +++-------------- sdk/src/query-tools-error-factory.test.ts | 12 ++++++++++++ sdk/src/query-tools-error-factory.ts | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 22a00f4fb..4099c4de1 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -7,8 +7,7 @@ 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 { failureToolsError, timeoutToolsError } from './query-tools-error-factory.js'; -import type { QueryNativeErrorFactory, QueryToolsErrorFactory } from './query-tools-error-seam.js'; +import { createQueryNativeErrorFactory, createQueryToolsErrorFactory } from './query-tools-error-factory.js'; export interface GSDToolsRuntime { registry: ReturnType; @@ -29,12 +28,7 @@ export function createGSDToolsRuntime(opts: { }): GSDToolsRuntime { const registry = createRegistry(opts.eventStream, opts.sessionId); - const queryToolsErrorFactory: QueryToolsErrorFactory = { - createTimeoutError: (message, command, args, stderr, timeoutMs) => - timeoutToolsError(message, command, args, stderr, timeoutMs), - createFailureError: (message, command, args, exitCode, stderr) => - failureToolsError(message, command, args, exitCode, stderr), - }; + const queryToolsErrorFactory = createQueryToolsErrorFactory(); const subprocessAdapter = new QuerySubprocessAdapter({ projectDir: opts.projectDir, @@ -44,12 +38,7 @@ export function createGSDToolsRuntime(opts: { ...queryToolsErrorFactory, }); - const nativeErrorFactory: QueryNativeErrorFactory = { - createNativeTimeoutError: (message, command, args) => - timeoutToolsError(message, command, args, '', opts.timeoutMs), - createNativeFailureError: (message, command, args, cause) => - failureToolsError(message, command, args, 1, '', cause), - }; + const nativeErrorFactory = createQueryNativeErrorFactory(opts.timeoutMs); const nativeDirectAdapter = new QueryNativeDirectAdapter({ timeoutMs: opts.timeoutMs, diff --git a/sdk/src/query-tools-error-factory.test.ts b/sdk/src/query-tools-error-factory.test.ts index dad1075e6..84f63a058 100644 --- a/sdk/src/query-tools-error-factory.test.ts +++ b/sdk/src/query-tools-error-factory.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { ErrorClassification, GSDError } from './errors.js'; import { + createQueryNativeErrorFactory, + createQueryToolsErrorFactory, failureToolsError, timeoutToolsError, toToolsErrorFromUnknown, @@ -22,4 +24,14 @@ describe('query tools error factory', () => { 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' }); + }); }); diff --git a/sdk/src/query-tools-error-factory.ts b/sdk/src/query-tools-error-factory.ts index 838f51381..620a9fe85 100644 --- a/sdk/src/query-tools-error-factory.ts +++ b/sdk/src/query-tools-error-factory.ts @@ -1,6 +1,7 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; import { errorMessage, toFailureSignal } from './query-failure-classification.js'; +import type { QueryNativeErrorFactory, QueryToolsErrorFactory } from './query-tools-error-seam.js'; export function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError { return GSDToolsError.timeout(message, command, args, stderr, timeoutMs); @@ -29,3 +30,21 @@ export function toToolsErrorFromUnknown(command: string, args: string[], err: un } 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), + }; +} From 97019d274e7a6273d61b42d2a5ac37089aae373e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:17:46 -0400 Subject: [PATCH 27/44] refactor: keep classification constructors internal to GSDToolsError --- sdk/src/gsd-tools-error.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index 1d54e26d3..829811830 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -3,11 +3,11 @@ export interface GSDToolsErrorClassification { timeoutMs?: number; } -export function timeoutClassification(timeoutMs?: number): GSDToolsErrorClassification { +function timeoutClassification(timeoutMs?: number): GSDToolsErrorClassification { return timeoutMs === undefined ? { kind: 'timeout' } : { kind: 'timeout', timeoutMs }; } -export function failureClassification(): GSDToolsErrorClassification { +function failureClassification(): GSDToolsErrorClassification { return { kind: 'failure' }; } From ed9d67c91b380df4e352eac3f0ccd6f695a63327 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:18:52 -0400 Subject: [PATCH 28/44] refactor: deepen subprocess adapter with shared execution error path --- sdk/src/query-subprocess-adapter.ts | 89 +++++++++++++---------------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index 8fac76050..a3b32f029 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -14,8 +14,7 @@ export class QuerySubprocessAdapter { constructor(private readonly deps: QuerySubprocessAdapterDeps) {} async execJson(command: string, args: string[]): Promise { - 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((resolve, reject) => { const child = execFile( @@ -31,28 +30,7 @@ export class QuerySubprocessAdapter { const stderrStr = stderr?.toString() ?? ''; if (error) { - if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { - reject( - this.deps.createTimeoutError( - timeoutMessage(command, args, this.deps.timeoutMs), - command, - args, - stderrStr, - this.deps.timeoutMs, - ), - ); - return; - } - - reject( - 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 as { status?: number }).status ?? 1, - stderrStr, - ), - ); + reject(this.processExecutionError(command, args, error, stderrStr)); return; } @@ -75,14 +53,13 @@ export class QuerySubprocessAdapter { ); child.on('error', (err) => { - reject(this.deps.createFailureError(`Failed to execute gsd-tools: ${err.message}`, command, args, null, '')); + reject(this.processSpawnError(command, args, err)); }); }); } async execRaw(command: string, args: string[]): Promise { - 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((resolve, reject) => { const child = execFile( @@ -97,27 +74,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.createTimeoutError( - timeoutMessage(command, args, this.deps.timeoutMs), - command, - args, - stderrStr, - this.deps.timeoutMs, - ), - ); - return; - } - reject( - 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 as { status?: number }).status ?? 1, - stderrStr, - ), - ); + reject(this.processExecutionError(command, args, error, stderrStr)); return; } resolve((stdout?.toString() ?? '').trim()); @@ -125,11 +82,45 @@ export class QuerySubprocessAdapter { ); child.on('error', (err) => { - reject(this.deps.createFailureError(`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 { const trimmed = raw.trim(); From b0e616288b74ef80cfe51bc095194a9425aafc8e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:19:13 -0400 Subject: [PATCH 29/44] refactor: isolate native dispatch error projection --- sdk/src/query-native-direct-adapter.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index 0aa4a6ac8..2c748f697 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -19,8 +19,7 @@ export class QueryNativeDirectAdapter { try { return await this.withTimeout(legacyCommand, legacyArgs, this.deps.dispatch(registryCommand, registryArgs)); } catch (error) { - if (error instanceof GSDToolsError) throw error; - throw this.deps.createNativeFailureError(errorMessage(error), legacyCommand, legacyArgs, error); + throw this.toNativeDispatchError(legacyCommand, legacyArgs, error); } } @@ -34,6 +33,11 @@ export class QueryNativeDirectAdapter { 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(legacyCommand: string, legacyArgs: string[], work: Promise): Promise { let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { From 6059a574f2f276d0035f312a1a75064e47851f1a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:20:16 -0400 Subject: [PATCH 30/44] refactor: remove redundant native dispatch cast in runtime --- sdk/src/query-gsd-tools-runtime.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 4099c4de1..9dab6fbb3 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -1,6 +1,5 @@ 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'; @@ -47,12 +46,12 @@ export function createGSDToolsRuntime(opts: { }); const transport = new GSDTransport(registry, { - dispatchNative: async (request) => nativeDirectAdapter.dispatchResult( + dispatchNative: (request) => nativeDirectAdapter.dispatchResult( request.legacyCommand, request.legacyArgs, request.registryCommand, request.registryArgs, - ) as Promise, + ), execSubprocessJson: async (legacyCommand, legacyArgs) => subprocessAdapter.execJson(legacyCommand, legacyArgs), execSubprocessRaw: async (legacyCommand, legacyArgs) => subprocessAdapter.execRaw(legacyCommand, legacyArgs), formatNativeRaw: (registryCommand, data) => formatQueryRawOutput(registryCommand, data), From 0fffc7c055bbafc54da6f31a05342e033bc822c3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:20:39 -0400 Subject: [PATCH 31/44] refactor: centralize gsd-tools error wrapping path --- sdk/src/gsd-tools.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 28d49d9e9..3d67696dd 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -118,17 +118,22 @@ export class GSDTools { registryArgs: string[], mode: 'json' | 'raw', ): Promise { - try { - return await this.nativeHotpathAdapter.dispatch( + return this.executeWithToolsError(legacyCommand, legacyArgs, () => + this.nativeHotpathAdapter.dispatch( legacyCommand, legacyArgs, registryCommand, registryArgs, mode, - ); + )); + } + + private async executeWithToolsError(command: string, args: string[], work: () => Promise): Promise { + try { + return await work(); } catch (err) { if (err instanceof GSDToolsError) throw err; - throw this.toToolsError(legacyCommand, legacyArgs, err); + throw this.toToolsError(command, args, err); } } @@ -139,12 +144,7 @@ export class GSDTools { * Handles the `@file:` prefix pattern for large results. */ async exec(command: string, args: string[] = []): Promise { - 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 +154,7 @@ export class GSDTools { * Use for commands like `config-set` that return plain text, not JSON. */ async execRaw(command: string, args: string[] = []): Promise { - 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 () => this.commandExecutor.exec(command, args, 'raw') as string); } From ace241d0c24ccf3310987d47527fdded891d2690 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:21:51 -0400 Subject: [PATCH 32/44] refactor: fold query error seam types into factory module --- sdk/src/query-native-direct-adapter.ts | 2 +- sdk/src/query-subprocess-adapter.ts | 2 +- sdk/src/query-tools-error-factory.ts | 28 +++++++++++++++++++++++++- sdk/src/query-tools-error-seam.ts | 28 -------------------------- 4 files changed, 29 insertions(+), 31 deletions(-) delete mode 100644 sdk/src/query-tools-error-seam.ts diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index 2c748f697..6546b319a 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -1,7 +1,7 @@ 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-seam.js'; +import type { QueryNativeErrorFactory } from './query-tools-error-factory.js'; import type { QueryResult } from './query/utils.js'; export interface QueryNativeDirectAdapterDeps extends QueryNativeErrorFactory { diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index a3b32f029..af83aac3d 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; -import type { QueryToolsErrorFactory } from './query-tools-error-seam.js'; +import type { QueryToolsErrorFactory } from './query-tools-error-factory.js'; export interface QuerySubprocessAdapterDeps extends QueryToolsErrorFactory { projectDir: string; diff --git a/sdk/src/query-tools-error-factory.ts b/sdk/src/query-tools-error-factory.ts index 620a9fe85..f427cf170 100644 --- a/sdk/src/query-tools-error-factory.ts +++ b/sdk/src/query-tools-error-factory.ts @@ -1,7 +1,33 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; import { errorMessage, toFailureSignal } from './query-failure-classification.js'; -import type { QueryNativeErrorFactory, QueryToolsErrorFactory } from './query-tools-error-seam.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; +} export function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError { return GSDToolsError.timeout(message, command, args, stderr, timeoutMs); diff --git a/sdk/src/query-tools-error-seam.ts b/sdk/src/query-tools-error-seam.ts deleted file mode 100644 index def5697a3..000000000 --- a/sdk/src/query-tools-error-seam.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { GSDToolsError } from './gsd-tools-error.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; -} From 5aaf0dbea50d3e2752f37f116d347fc4fa8a3677 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:22:58 -0400 Subject: [PATCH 33/44] refactor: reduce query error factory public surface --- sdk/src/query-tools-error-factory.test.ts | 10 ++++------ sdk/src/query-tools-error-factory.ts | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/src/query-tools-error-factory.test.ts b/sdk/src/query-tools-error-factory.test.ts index 84f63a058..616d592c2 100644 --- a/sdk/src/query-tools-error-factory.test.ts +++ b/sdk/src/query-tools-error-factory.test.ts @@ -3,17 +3,15 @@ import { ErrorClassification, GSDError } from './errors.js'; import { createQueryNativeErrorFactory, createQueryToolsErrorFactory, - failureToolsError, - timeoutToolsError, toToolsErrorFromUnknown, } from './query-tools-error-factory.js'; describe('query tools error factory', () => { - it('builds timeout and failure tools errors', () => { - expect(timeoutToolsError('t', 'state', ['load'], '', 10).classification).toEqual({ kind: 'timeout', timeoutMs: 10 }); - expect(failureToolsError('f', 'state', ['load'], 1).classification).toEqual({ kind: 'failure' }); + 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); diff --git a/sdk/src/query-tools-error-factory.ts b/sdk/src/query-tools-error-factory.ts index f427cf170..cb008ffa5 100644 --- a/sdk/src/query-tools-error-factory.ts +++ b/sdk/src/query-tools-error-factory.ts @@ -29,11 +29,11 @@ export interface QueryNativeErrorFactory { createNativeFailureError: (message: string, command: string, args: string[], cause: unknown) => GSDToolsError; } -export function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError { +function timeoutToolsError(message: string, command: string, args: string[], stderr = '', timeoutMs?: number): GSDToolsError { return GSDToolsError.timeout(message, command, args, stderr, timeoutMs); } -export function failureToolsError( +function failureToolsError( message: string, command: string, args: string[], From deb4477375d3db73e29f7865c792dd6e4923be73 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:23:34 -0400 Subject: [PATCH 34/44] refactor: remove thin runtime and tools error wrappers --- sdk/src/gsd-tools.ts | 6 +----- sdk/src/query-gsd-tools-runtime.ts | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 3d67696dd..169cf948c 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -107,10 +107,6 @@ export class GSDTools { return resolveQueryCommand(command, args, this.registry); } - private toToolsError(command: string, args: string[], err: unknown): GSDToolsError { - return toToolsErrorFromUnknown(command, args, err); - } - private async dispatchNativeHotpath( legacyCommand: string, legacyArgs: string[], @@ -133,7 +129,7 @@ export class GSDTools { return await work(); } catch (err) { if (err instanceof GSDToolsError) throw err; - throw this.toToolsError(command, args, err); + throw toToolsErrorFromUnknown(command, args, err); } } diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index 9dab6fbb3..c86efc6cd 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -52,8 +52,8 @@ export function createGSDToolsRuntime(opts: { request.registryCommand, request.registryArgs, ), - 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), }); From e0c791a5d062536e327529b188357abe2a53e8d4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:24:08 -0400 Subject: [PATCH 35/44] refactor: centralize native dispatch data projection --- sdk/src/query-native-direct-adapter.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index 6546b319a..c3d903808 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -24,13 +24,22 @@ export class QueryNativeDirectAdapter { } async dispatchJson(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise { - const result = await this.dispatchResult(legacyCommand, legacyArgs, registryCommand, registryArgs); - return result.data; + return this.dispatchData(legacyCommand, legacyArgs, registryCommand, registryArgs); } async dispatchRaw(legacyCommand: string, legacyArgs: string[], registryCommand: string, registryArgs: string[]): Promise { + 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 { const result = await this.dispatchResult(legacyCommand, legacyArgs, registryCommand, registryArgs); - return formatQueryRawOutput(registryCommand, result.data).trim(); + return result.data; } private toNativeDispatchError(legacyCommand: string, legacyArgs: string[], error: unknown): GSDToolsError { From 969cfcf998e5a2f853dbed79ad920c55258657e4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:24:36 -0400 Subject: [PATCH 36/44] refactor: split native hotpath fallback and dispatch branches --- sdk/src/query-native-hotpath-adapter.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/sdk/src/query-native-hotpath-adapter.ts b/sdk/src/query-native-hotpath-adapter.ts index 6a201d74b..91c93e738 100644 --- a/sdk/src/query-native-hotpath-adapter.ts +++ b/sdk/src/query-native-hotpath-adapter.ts @@ -19,11 +19,25 @@ export class QueryNativeHotpathAdapter { mode: 'json' | 'raw', ): Promise { 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 { + 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 { return mode === 'raw' ? this.nativeDirect.dispatchRaw(legacyCommand, legacyArgs, registryCommand, registryArgs) : this.nativeDirect.dispatchJson(legacyCommand, legacyArgs, registryCommand, registryArgs); From c6a35d6398f06bf7760202706847cccca9cd5efe Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 20:25:21 -0400 Subject: [PATCH 37/44] refactor: deepen transport policy and output projection paths --- sdk/src/gsd-transport.ts | 45 ++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 69be7b680..7b9cefc8b 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -32,33 +32,48 @@ export class GSDTransport { ) {} async run(request: TransportRequest, policy: TransportPolicyLike): Promise { - 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 (toFailureSignal(error).kind === 'timeout') 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 { 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); From 0500bdf6195b592817de8125a2c5b4b121a7ee99 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:16:09 -0400 Subject: [PATCH 38/44] refactor: deepen query architecture seams with compatibility shims --- .changeset/plucky-pandas-sprint.md | 5 + sdk/src/query/command-definition.test.ts | 26 ++- sdk/src/query/command-definition.ts | 38 ++++- sdk/src/query/command-manifest.non-family.ts | 66 ++++++++ sdk/src/query/command-topology.ts | 38 ++++- sdk/src/query/query-command-diagnosis.ts | 37 +---- sdk/src/query/query-command-semantics.test.ts | 22 +++ sdk/src/query/query-command-semantics.ts | 47 ++---- sdk/src/query/query-dispatch-error-mapper.ts | 24 +-- sdk/src/query/query-dispatch-formatting.ts | 21 +-- .../query/query-dispatch-input-validation.ts | 54 +------ sdk/src/query/query-dispatch-plan.ts | 52 +----- .../query/query-dispatch-result-builder.ts | 24 +-- sdk/src/query/query-dispatch.ts | 150 +++++++++++++++++- sdk/src/query/query-policy-capability.ts | 27 ++-- sdk/src/query/registry-assembly-descriptor.ts | 87 ++++++++++ sdk/src/query/registry-assembly.test.ts | 6 + sdk/src/query/registry-assembly.ts | 101 +++--------- 18 files changed, 488 insertions(+), 337 deletions(-) create mode 100644 .changeset/plucky-pandas-sprint.md create mode 100644 sdk/src/query/command-manifest.non-family.ts create mode 100644 sdk/src/query/query-command-semantics.test.ts create mode 100644 sdk/src/query/registry-assembly-descriptor.ts diff --git a/.changeset/plucky-pandas-sprint.md b/.changeset/plucky-pandas-sprint.md new file mode 100644 index 000000000..33188ca32 --- /dev/null +++ b/.changeset/plucky-pandas-sprint.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3107 +--- +Query module architecture deepened with compatibility-preserving seams — command policy now derives from command definitions, and dispatch/topology/registry seams are consolidated for better locality while preserving existing query behavior. diff --git a/sdk/src/query/command-definition.test.ts b/sdk/src/query/command-definition.test.ts index 814a4a508..c8e872927 100644 --- a/sdk/src/query/command-definition.test.ts +++ b/sdk/src/query/command-definition.test.ts @@ -1,10 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { COMMAND_DEFINITIONS, COMMAND_DEFINITIONS_BY_FAMILY, FAMILY_MUTATION_COMMANDS } from './command-definition.js'; +import { + COMMAND_DEFINITIONS, + COMMAND_DEFINITIONS_BY_FAMILY, + FAMILY_MUTATION_COMMANDS, + COMMAND_DEFINITION_BY_CANONICAL, + COMMAND_MUTATION_SET, + COMMAND_RAW_OUTPUT_SET, +} from './command-definition.js'; import { COMMAND_MANIFEST } from './command-manifest.js'; +import { NON_FAMILY_COMMAND_MANIFEST } from './command-manifest.non-family.js'; describe('command-definition module', () => { it('exposes canonical metadata with handler_key normalization contract', () => { - expect(COMMAND_DEFINITIONS).toHaveLength(COMMAND_MANIFEST.length); + expect(COMMAND_DEFINITIONS).toHaveLength(COMMAND_MANIFEST.length + NON_FAMILY_COMMAND_MANIFEST.length); for (const [index, manifestEntry] of COMMAND_MANIFEST.entries()) { const definition = COMMAND_DEFINITIONS[index]; expect(definition.handler_key).toBe(manifestEntry.handlerKey ?? manifestEntry.canonical); @@ -15,11 +23,11 @@ describe('command-definition module', () => { } }); - it('keeps family index canonicals in sync with flat list', () => { + it('keeps family index canonicals in sync with family definitions', () => { const indexed = Object.values(COMMAND_DEFINITIONS_BY_FAMILY).flat(); - expect(indexed).toHaveLength(COMMAND_DEFINITIONS.length); + expect(indexed).toHaveLength(COMMAND_MANIFEST.length); expect(indexed.map((entry) => entry.canonical).sort()).toEqual( - [...COMMAND_DEFINITIONS.map((entry) => entry.canonical)].sort(), + [...COMMAND_MANIFEST.map((entry) => entry.canonical)].sort(), ); }); @@ -28,4 +36,12 @@ describe('command-definition module', () => { expect(FAMILY_MUTATION_COMMANDS).toContain('phase complete'); expect(FAMILY_MUTATION_COMMANDS).toContain('roadmap.update-plan-progress'); }); + + it('exposes indexed views for policy consumers', () => { + expect(COMMAND_DEFINITION_BY_CANONICAL['state.load']?.canonical).toBe('state.load'); + expect(COMMAND_MUTATION_SET.has('state.update')).toBe(true); + expect(COMMAND_MUTATION_SET.has('state.json')).toBe(false); + expect(COMMAND_RAW_OUTPUT_SET.has('commit')).toBe(true); + expect(COMMAND_RAW_OUTPUT_SET.has('verify summary')).toBe(true); + }); }); diff --git a/sdk/src/query/command-definition.ts b/sdk/src/query/command-definition.ts index c64503675..993a70a79 100644 --- a/sdk/src/query/command-definition.ts +++ b/sdk/src/query/command-definition.ts @@ -1,16 +1,17 @@ import { COMMAND_MANIFEST } from './command-manifest.js'; +import { NON_FAMILY_COMMAND_MANIFEST } from './command-manifest.non-family.js'; import type { CommandFamily, OutputMode } from './command-manifest.types.js'; export interface CommandDefinition { - family: CommandFamily; + family?: CommandFamily; canonical: string; aliases: string[]; mutation: boolean; output_mode: OutputMode; - handler_key: string; + handler_key?: string; } -export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFEST.map((entry) => ({ +const FAMILY_COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFEST.map((entry) => ({ family: entry.family, canonical: entry.canonical, aliases: [...entry.aliases], @@ -19,6 +20,18 @@ export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = COMMAND_MANIFES handler_key: entry.handlerKey ?? entry.canonical, })) as readonly CommandDefinition[]; +const NON_FAMILY_COMMAND_DEFINITIONS: readonly CommandDefinition[] = NON_FAMILY_COMMAND_MANIFEST.map((entry) => ({ + canonical: entry.canonical, + aliases: [...entry.aliases], + mutation: entry.mutation, + output_mode: entry.outputMode, +})) as readonly CommandDefinition[]; + +export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = [ + ...FAMILY_COMMAND_DEFINITIONS, + ...NON_FAMILY_COMMAND_DEFINITIONS, +] as const; + function byFamily(family: CommandFamily): readonly CommandDefinition[] { return COMMAND_DEFINITIONS.filter((entry) => entry.family === family); } @@ -33,10 +46,25 @@ export const COMMAND_DEFINITIONS_BY_FAMILY: Readonly> = Object.fromEntries( + COMMAND_DEFINITIONS.map((entry) => [entry.canonical, entry]), +); + +export const COMMAND_MUTATION_SET: ReadonlySet = new Set( + COMMAND_DEFINITIONS.filter((entry) => entry.mutation).flatMap((entry) => [entry.canonical, ...entry.aliases]), +); + +export const COMMAND_RAW_OUTPUT_SET: ReadonlySet = new Set( + COMMAND_DEFINITIONS.filter((entry) => entry.output_mode === 'raw').flatMap((entry) => [entry.canonical, ...entry.aliases]), +); + +export const FAMILY_MUTATION_COMMANDS: readonly string[] = FAMILY_COMMAND_DEFINITIONS .filter((entry) => entry.mutation) .flatMap((entry) => [entry.canonical, ...entry.aliases]); -export const FAMILY_RAW_OUTPUT_COMMANDS: readonly string[] = COMMAND_DEFINITIONS +export const FAMILY_RAW_OUTPUT_COMMANDS: readonly string[] = FAMILY_COMMAND_DEFINITIONS .filter((entry) => entry.output_mode === 'raw') .flatMap((entry) => [entry.canonical, ...entry.aliases]); + +export const QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS: readonly string[] = Array.from(COMMAND_MUTATION_SET); +export const TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS: readonly string[] = Array.from(COMMAND_RAW_OUTPUT_SET); diff --git a/sdk/src/query/command-manifest.non-family.ts b/sdk/src/query/command-manifest.non-family.ts new file mode 100644 index 000000000..223d244cd --- /dev/null +++ b/sdk/src/query/command-manifest.non-family.ts @@ -0,0 +1,66 @@ +import type { OutputMode } from './command-manifest.types.js'; + +export interface NonFamilyCommandManifestEntry { + canonical: string; + aliases: string[]; + mutation: boolean; + outputMode: OutputMode; +} + +export const NON_FAMILY_COMMAND_MANIFEST: readonly NonFamilyCommandManifestEntry[] = [ + { canonical: 'frontmatter.set', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'frontmatter.merge', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'frontmatter.validate', aliases: ['frontmatter validate'], mutation: true, outputMode: 'json' }, + + { canonical: 'commit', aliases: [], mutation: true, outputMode: 'raw' }, + { canonical: 'config-set', aliases: [], mutation: true, outputMode: 'raw' }, + { canonical: 'config-set-model-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'config-new-project', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'config-ensure-section', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'check-commit', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'commit-to-subrepo', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'template.fill', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'template.select', aliases: ['template select'], mutation: true, outputMode: 'json' }, + + { canonical: 'requirements.mark-complete', aliases: ['requirements mark-complete'], mutation: true, outputMode: 'json' }, + { canonical: 'todo.complete', aliases: ['todo complete'], mutation: true, outputMode: 'json' }, + { canonical: 'milestone.complete', aliases: ['milestone complete'], mutation: true, outputMode: 'json' }, + + { + canonical: 'workstream.create', + aliases: ['workstream create'], + mutation: true, + outputMode: 'json', + }, + { canonical: 'workstream.set', aliases: ['workstream set'], mutation: true, outputMode: 'json' }, + { + canonical: 'workstream.complete', + aliases: ['workstream complete'], + mutation: true, + outputMode: 'json', + }, + { + canonical: 'workstream.progress', + aliases: ['workstream progress'], + mutation: true, + outputMode: 'json', + }, + + { canonical: 'docs-init', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'learnings.copy', aliases: ['learnings copy'], mutation: true, outputMode: 'json' }, + { canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true, outputMode: 'json' }, + { canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true, outputMode: 'json' }, + + { canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true, outputMode: 'json' }, + { canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true, outputMode: 'json' }, + + { canonical: 'write-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-claude-profile', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-dev-preferences', aliases: [], mutation: true, outputMode: 'json' }, + { canonical: 'generate-claude-md', aliases: [], mutation: true, outputMode: 'json' }, + + { canonical: 'verify-summary', aliases: ['verify.summary', 'verify summary'], mutation: false, outputMode: 'raw' }, +] as const; diff --git a/sdk/src/query/command-topology.ts b/sdk/src/query/command-topology.ts index b0148889c..0be256fe0 100644 --- a/sdk/src/query/command-topology.ts +++ b/sdk/src/query/command-topology.ts @@ -1,8 +1,13 @@ import type { QueryRegistry } from './registry.js'; import type { QueryHandler } from './utils.js'; -import { resolveQueryCommand } from './query-command-resolution-strategy.js'; -import { diagnoseUnknownCommand } from './query-command-diagnosis.js'; +import { + resolveQueryCommand, + explainQueryCommandNoMatch, + type QueryCommandRegistryLike, +} from './query-command-resolution-strategy.js'; import { supportsMutationCommand, supportsRawOutputCommand } from './query-policy-capability.js'; +import { UNKNOWN_COMMAND_HINTS } from './query-unknown-command-hints.js'; +import { describeFallbackDisabledPolicy } from './query-fallback-policy.js'; export type CommandTopologyOutputMode = 'json' | 'text' | 'raw'; @@ -29,6 +34,35 @@ export interface CommandTopology { resolve(tokens: string[], fallbackRestricted?: boolean): CommandTopologyResult; } +export interface UnknownCommandDiagnosis { + normalized: string; + attempted: string[]; + hints: string[]; + message: string; +} + +export function diagnoseUnknownCommand( + command: string, + args: string[], + registry: QueryCommandRegistryLike, + fallbackRestricted: boolean, +): UnknownCommandDiagnosis { + const noMatch = explainQueryCommandNoMatch(command, args, registry); + const normalized = [noMatch.normalized.command, ...noMatch.normalized.args].join(' '); + const attempted = noMatch.attempted.dotted.slice(0, 2); + const hints = [...UNKNOWN_COMMAND_HINTS]; + const attemptedSuffix = attempted.length > 0 ? ` Attempted dotted: ${attempted.join(' | ')}.` : ''; + const fallbackClause = fallbackRestricted ? `${describeFallbackDisabledPolicy()} ` : ''; + const message = `Error: Unknown command: "${normalized}". ${hints[0]} ${hints[1]} ${fallbackClause}${hints[2]}${attemptedSuffix}`; + + return { + normalized, + attempted, + hints, + message, + }; +} + export function createCommandTopology(registry: QueryRegistry): CommandTopology { return { resolve(tokens: string[], fallbackRestricted = false): CommandTopologyResult { diff --git a/sdk/src/query/query-command-diagnosis.ts b/sdk/src/query/query-command-diagnosis.ts index ef8ee8cd9..fc36b0ac9 100644 --- a/sdk/src/query/query-command-diagnosis.ts +++ b/sdk/src/query/query-command-diagnosis.ts @@ -1,32 +1,5 @@ -import { explainQueryCommandNoMatch, type QueryCommandRegistryLike } from './query-command-semantics.js'; -import { UNKNOWN_COMMAND_HINTS } from './query-unknown-command-hints.js'; -import { describeFallbackDisabledPolicy } from './query-fallback-policy.js'; - -export interface UnknownCommandDiagnosis { - normalized: string; - attempted: string[]; - hints: string[]; - message: string; -} - -export function diagnoseUnknownCommand( - command: string, - args: string[], - registry: QueryCommandRegistryLike, - fallbackRestricted: boolean, -): UnknownCommandDiagnosis { - const noMatch = explainQueryCommandNoMatch(command, args, registry); - const normalized = [noMatch.normalized.command, ...noMatch.normalized.args].join(' '); - const attempted = noMatch.attempted.dotted.slice(0, 2); - const hints = [...UNKNOWN_COMMAND_HINTS]; - const attemptedSuffix = attempted.length > 0 ? ` Attempted dotted: ${attempted.join(' | ')}.` : ''; - const fallbackClause = fallbackRestricted ? `${describeFallbackDisabledPolicy()} ` : ''; - const message = `Error: Unknown command: "${normalized}". ${hints[0]} ${hints[1]} ${fallbackClause}${hints[2]}${attemptedSuffix}`; - - return { - normalized, - attempted, - hints, - message, - }; -} +/** + * @deprecated Compatibility seam after Command Topology Module deepening. + * Remove-after: all imports migrate to `command-topology.ts`. + */ +export { diagnoseUnknownCommand, type UnknownCommandDiagnosis } from './command-topology.js'; diff --git a/sdk/src/query/query-command-semantics.test.ts b/sdk/src/query/query-command-semantics.test.ts new file mode 100644 index 000000000..9e7b0bfb2 --- /dev/null +++ b/sdk/src/query/query-command-semantics.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS, + TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS, +} from './command-definition.js'; +import { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-command-semantics.js'; + +describe('query-command-semantics compatibility seam', () => { + it('keeps legacy exports derived from command definitions', () => { + expect(QUERY_MUTATION_COMMAND_LIST).toBe(QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS); + expect(TRANSPORT_RAW_COMMANDS).toBe(TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS); + }); + + it('classifies mutation status through derived set', () => { + expect(isQueryMutationCommand('state.update')).toBe(true); + expect(isQueryMutationCommand('state.json')).toBe(false); + }); +}); diff --git a/sdk/src/query/query-command-semantics.ts b/sdk/src/query/query-command-semantics.ts index 9d4625ccf..eab534dc3 100644 --- a/sdk/src/query/query-command-semantics.ts +++ b/sdk/src/query/query-command-semantics.ts @@ -1,42 +1,13 @@ -import { FAMILY_MUTATION_COMMANDS, FAMILY_RAW_OUTPUT_COMMANDS } from './command-definition.js'; +/** + * @deprecated Legacy compatibility seam. + * Prefer importing policy and indexed views from `query-policy-capability` or `command-definition`. + */ -export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = [ - ...FAMILY_MUTATION_COMMANDS, - 'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate', - 'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section', - 'commit', 'check-commit', 'commit-to-subrepo', - 'template.fill', 'template.select', 'template select', - 'requirements.mark-complete', 'requirements mark-complete', - 'todo.complete', 'todo complete', - 'milestone.complete', 'milestone complete', - 'workstream.create', 'workstream.set', 'workstream.complete', 'workstream.progress', - 'workstream create', 'workstream set', 'workstream complete', 'workstream progress', - 'docs-init', - 'learnings.copy', 'learnings copy', - 'learnings.prune', 'learnings prune', - 'learnings.delete', 'learnings delete', - 'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta', - 'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md', -] as const; - -const NON_FAMILY_RAW_OUTPUT_COMMANDS = [ - 'commit', - 'config-set', - 'verify-summary', - 'verify.summary', - 'verify summary', -] as const; - -export const TRANSPORT_RAW_COMMANDS: readonly string[] = [ - ...FAMILY_RAW_OUTPUT_COMMANDS, - ...NON_FAMILY_RAW_OUTPUT_COMMANDS, -] as const; - -const QUERY_MUTATION_COMMAND_SET = new Set(QUERY_MUTATION_COMMAND_LIST); - -export function isQueryMutationCommand(command: string): boolean { - return QUERY_MUTATION_COMMAND_SET.has(command); -} +export { + QUERY_MUTATION_COMMAND_LIST, + TRANSPORT_RAW_COMMANDS, + isQueryMutationCommand, +} from './query-policy-capability.js'; export { normalizeQueryCommand, diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index bd377be19..1b193c567 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,19 +1,5 @@ -import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; -import { toFailureSignal } from '../query-failure-classification.js'; -import { fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from './query-error-taxonomy.js'; -import { dispatchFailure } from './query-dispatch-result-builder.js'; - -export function toDispatchFailure( - error: QueryDispatchError, - stderr: string[] = [], -): QueryDispatchResult { - return dispatchFailure(error, stderr); -} - -export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); -} - -export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { toDispatchFailure, mapNativeDispatchError, mapFallbackDispatchError } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-formatting.ts b/sdk/src/query/query-dispatch-formatting.ts index 3b8cac188..67893476c 100644 --- a/sdk/src/query/query-dispatch-formatting.ts +++ b/sdk/src/query/query-dispatch-formatting.ts @@ -1,16 +1,5 @@ -import { extractField } from './registry.js'; - -export type DispatchSuccessFormat = 'json' | 'text' | undefined; - -export function formatPick(data: unknown, pickField?: string): unknown { - if (!pickField) return data; - return extractField(data, pickField); -} - -export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string { - if (format === 'text' && typeof data === 'string') { - return data.endsWith('\n') ? data : `${data}\n`; - } - const output = formatPick(data, pickField); - return `${JSON.stringify(output, null, 2)}\n`; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { formatPick, formatSuccess, type DispatchSuccessFormat } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-input-validation.ts b/sdk/src/query/query-dispatch-input-validation.ts index e0ded9c22..74384ce07 100644 --- a/sdk/src/query/query-dispatch-input-validation.ts +++ b/sdk/src/query/query-dispatch-input-validation.ts @@ -1,49 +1,5 @@ -import type { QueryDispatchResult } from './query-dispatch-contract.js'; -import { validationError } from './query-error-taxonomy.js'; -import { dispatchFailure } from './query-dispatch-result-builder.js'; - -export interface DispatchInputValidationResult { - queryArgs: string[]; - pickField?: string; - error?: QueryDispatchResult; -} - -export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult { - const queryArgs = [...queryArgv]; - const pickIdx = queryArgs.indexOf('--pick'); - if (pickIdx !== -1) { - if (pickIdx + 1 >= queryArgs.length) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: --pick requires a field name', - details: { field: '--pick', reason: 'missing_value' }, - })), - }; - } - const pickField = queryArgs[pickIdx + 1]; - queryArgs.splice(pickIdx, 2); - if (queryArgs.length === 0 || !queryArgs[0]) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: "gsd-sdk query" requires a command', - details: { reason: 'missing_command' }, - })), - }; - } - return { queryArgs, pickField }; - } - - if (queryArgs.length === 0 || !queryArgs[0]) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: "gsd-sdk query" requires a command', - details: { reason: 'missing_command' }, - })), - }; - } - - return { queryArgs }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { validateQueryDispatchInput, type DispatchInputValidationResult } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-plan.ts b/sdk/src/query/query-dispatch-plan.ts index 3f627cdb4..ad067ec7c 100644 --- a/sdk/src/query/query-dispatch-plan.ts +++ b/sdk/src/query/query-dispatch-plan.ts @@ -1,47 +1,5 @@ -import { normalizeQueryCommand } from './query-command-resolution-strategy.js'; -import type { CommandTopology, CommandTopologyMatch } from './command-topology.js'; - -export type DispatchMode = 'native' | 'cjs' | 'error'; - -export interface DispatchPlan { - mode: DispatchMode; - normalized: { command: string; args: string[]; tokens: string[] }; - matched: CommandTopologyMatch | null; - noMatchMessage?: string; - noMatchNormalized?: string; - noMatchAttempted?: string[]; - noMatchHints?: string[]; -} - -export function planQueryDispatch( - queryArgv: string[], - topology: CommandTopology, - cjsFallbackEnabled: boolean, -): DispatchPlan { - const queryCommand = queryArgv[0]; - if (!queryCommand) { - return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null }; - } - - const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1)); - const normalizedTokens = [normCmd, ...normArgs]; - const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled); - - if (resolved.kind === 'match') { - return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved }; - } - - if (cjsFallbackEnabled) { - return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null }; - } - - return { - mode: 'error', - normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, - matched: null, - noMatchMessage: resolved.message, - noMatchNormalized: resolved.normalized, - noMatchAttempted: resolved.attempted, - noMatchHints: resolved.hints, - }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { planQueryDispatch, type DispatchMode, type DispatchPlan } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch-result-builder.ts b/sdk/src/query/query-dispatch-result-builder.ts index 07f8ad3b9..8fa5d55be 100644 --- a/sdk/src/query/query-dispatch-result-builder.ts +++ b/sdk/src/query/query-dispatch-result-builder.ts @@ -1,19 +1,5 @@ -import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; - -export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { - return { - ok: false, - error, - stderr, - exit_code: error.code, - }; -} - -export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult { - return { - ok: true, - stdout, - stderr, - exit_code: 0, - }; -} +/** + * @deprecated Compatibility seam after Query Dispatch Module deepening. + * Remove-after: all imports migrate to `query-dispatch.ts`. + */ +export { dispatchFailure, dispatchSuccess } from './query-dispatch.js'; diff --git a/sdk/src/query/query-dispatch.ts b/sdk/src/query/query-dispatch.ts index d1286b114..4349ff087 100644 --- a/sdk/src/query/query-dispatch.ts +++ b/sdk/src/query/query-dispatch.ts @@ -1,16 +1,14 @@ import type { QueryRegistry } from './registry.js'; +import { extractField } from './registry.js'; +import { normalizeQueryCommand } from './query-command-resolution-strategy.js'; import { runCjsFallbackDispatch } from './query-fallback-executor.js'; -import type { QueryDispatchResult } from './query-dispatch-contract.js'; +import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; import type { QueryResult } from './utils.js'; import type { QueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js'; -import type { CommandTopology } from './command-topology.js'; -import { mapFallbackDispatchError, mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js'; -import { formatSuccess } from './query-dispatch-formatting.js'; -import { unknownCommandError, validationError } from './query-error-taxonomy.js'; -import { planQueryDispatch } from './query-dispatch-plan.js'; -import { validateQueryDispatchInput } from './query-dispatch-input-validation.js'; -import { dispatchSuccess } from './query-dispatch-result-builder.js'; +import type { CommandTopology, CommandTopologyMatch } from './command-topology.js'; +import { unknownCommandError, validationError, fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from './query-error-taxonomy.js'; import { canUseCjsFallback } from './query-fallback-policy.js'; +import { toFailureSignal } from '../query-failure-classification.js'; export interface QueryDispatchDeps { registry: QueryRegistry; @@ -25,6 +23,142 @@ export interface QueryDispatchDeps { topology: CommandTopology; } +export type DispatchMode = 'native' | 'cjs' | 'error'; + +export interface DispatchPlan { + mode: DispatchMode; + normalized: { command: string; args: string[]; tokens: string[] }; + matched: CommandTopologyMatch | null; + noMatchMessage?: string; + noMatchNormalized?: string; + noMatchAttempted?: string[]; + noMatchHints?: string[]; +} + +export type DispatchSuccessFormat = 'json' | 'text' | undefined; + +export interface DispatchInputValidationResult { + queryArgs: string[]; + pickField?: string; + error?: QueryDispatchResult; +} + +export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { + return { + ok: false, + error, + stderr, + exit_code: error.code, + }; +} + +export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult { + return { + ok: true, + stdout, + stderr, + exit_code: 0, + }; +} + +export function toDispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { + return dispatchFailure(error, stderr); +} + +export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { + return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); +} + +export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { + return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); +} + +export function formatPick(data: unknown, pickField?: string): unknown { + if (!pickField) return data; + return extractField(data, pickField); +} + +export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string { + if (format === 'text' && typeof data === 'string') { + return data.endsWith('\n') ? data : `${data}\n`; + } + const output = formatPick(data, pickField); + return `${JSON.stringify(output, null, 2)}\n`; +} + +export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult { + const queryArgs = [...queryArgv]; + const pickIdx = queryArgs.indexOf('--pick'); + if (pickIdx !== -1) { + if (pickIdx + 1 >= queryArgs.length) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: --pick requires a field name', + details: { field: '--pick', reason: 'missing_value' }, + })), + }; + } + const pickField = queryArgs[pickIdx + 1]; + queryArgs.splice(pickIdx, 2); + if (queryArgs.length === 0 || !queryArgs[0]) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: "gsd-sdk query" requires a command', + details: { reason: 'missing_command' }, + })), + }; + } + return { queryArgs, pickField }; + } + + if (queryArgs.length === 0 || !queryArgs[0]) { + return { + queryArgs, + error: dispatchFailure(validationError({ + message: 'Error: "gsd-sdk query" requires a command', + details: { reason: 'missing_command' }, + })), + }; + } + + return { queryArgs }; +} + +export function planQueryDispatch( + queryArgv: string[], + topology: CommandTopology, + cjsFallbackEnabled: boolean, +): DispatchPlan { + const queryCommand = queryArgv[0]; + if (!queryCommand) { + return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null }; + } + + const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1)); + const normalizedTokens = [normCmd, ...normArgs]; + const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled); + + if (resolved.kind === 'match') { + return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved }; + } + + if (cjsFallbackEnabled) { + return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null }; + } + + return { + mode: 'error', + normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, + matched: null, + noMatchMessage: resolved.message, + noMatchNormalized: resolved.normalized, + noMatchAttempted: resolved.attempted, + noMatchHints: resolved.hints, + }; +} + function fail(error: ReturnType | ReturnType, stderr: string[] = []): QueryDispatchResult { return toDispatchFailure(error, stderr); } diff --git a/sdk/src/query/query-policy-capability.ts b/sdk/src/query/query-policy-capability.ts index bf5707a8d..afdef07a2 100644 --- a/sdk/src/query/query-policy-capability.ts +++ b/sdk/src/query/query-policy-capability.ts @@ -1,27 +1,26 @@ import { - QUERY_MUTATION_COMMAND_LIST, - TRANSPORT_RAW_COMMANDS, - isQueryMutationCommand, -} from './query-command-semantics.js'; + QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS, + TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS, + COMMAND_MUTATION_SET, + COMMAND_RAW_OUTPUT_SET, +} from './command-definition.js'; + +export const QUERY_MUTATION_COMMAND_LIST: readonly string[] = QUERY_MUTATION_COMMANDS_FROM_DEFINITIONS; +export const TRANSPORT_RAW_COMMANDS: readonly string[] = TRANSPORT_RAW_COMMANDS_FROM_DEFINITIONS; export const QUERY_POLICY_SNAPSHOT = { mutation_commands: QUERY_MUTATION_COMMAND_LIST, raw_output_commands: TRANSPORT_RAW_COMMANDS, } as const; -const MUTATION_SET = new Set(QUERY_POLICY_SNAPSHOT.mutation_commands); -const RAW_OUTPUT_SET = new Set(QUERY_POLICY_SNAPSHOT.raw_output_commands); - export function supportsMutationCommand(command: string): boolean { - return MUTATION_SET.has(command); + return COMMAND_MUTATION_SET.has(command); } export function supportsRawOutputCommand(command: string): boolean { - return RAW_OUTPUT_SET.has(command); + return COMMAND_RAW_OUTPUT_SET.has(command); } -export { - QUERY_MUTATION_COMMAND_LIST, - TRANSPORT_RAW_COMMANDS, - isQueryMutationCommand, -}; +export function isQueryMutationCommand(command: string): boolean { + return COMMAND_MUTATION_SET.has(command); +} diff --git a/sdk/src/query/registry-assembly-descriptor.ts b/sdk/src/query/registry-assembly-descriptor.ts new file mode 100644 index 000000000..ad76c667a --- /dev/null +++ b/sdk/src/query/registry-assembly-descriptor.ts @@ -0,0 +1,87 @@ +import type { AliasCatalogEntry } from './command-catalog.js'; +import type { CommandFamily } from './command-manifest.types.js'; +import type { QueryHandler } from './utils.js'; +import { + FOUNDATION_STATIC_CATALOG, + STATE_SUPPORT_STATIC_CATALOG, + MUTATION_SURFACES_STATIC_CATALOG, + VERIFY_DECISION_STATIC_CATALOG, + DECISION_ROUTING_STATIC_CATALOG, +} from './command-static-catalog-foundation.js'; +import { DOMAIN_STATIC_CATALOG } from './command-static-catalog-domain.js'; +import { COMMAND_DEFINITIONS_BY_FAMILY, type CommandDefinition } from './command-definition.js'; +import { FAMILY_HANDLERS } from './command-family-handlers.js'; +import type { RegistryAssemblyAliasGroup, RegistryAssemblyStaticGroup } from './registry-assembly-invariants.js'; + +export interface RegistryAssemblyStep { + kind: 'static' | 'alias'; + key: string; +} + +function toAliasCatalogEntry(entry: CommandDefinition): AliasCatalogEntry { + return { + canonical: entry.canonical, + aliases: entry.aliases, + }; +} + +function buildAliasGroup(family: CommandFamily): RegistryAssemblyAliasGroup { + const definitions = COMMAND_DEFINITIONS_BY_FAMILY[family]; + const familyHandlers = FAMILY_HANDLERS[family] as Readonly>; + const handlers: Record = {}; + + for (const entry of definitions) { + const handler = familyHandlers[entry.handler_key!]; + if (!handler) continue; + handlers[entry.canonical] = handler; + } + + return { + family, + aliases: definitions.map(toAliasCatalogEntry), + handlers, + }; +} + +export const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [ + { name: 'FOUNDATION_STATIC_CATALOG', entries: FOUNDATION_STATIC_CATALOG }, + { name: 'STATE_SUPPORT_STATIC_CATALOG', entries: STATE_SUPPORT_STATIC_CATALOG }, + { name: 'MUTATION_SURFACES_STATIC_CATALOG', entries: MUTATION_SURFACES_STATIC_CATALOG }, + { name: 'VERIFY_DECISION_STATIC_CATALOG', entries: VERIFY_DECISION_STATIC_CATALOG }, + { name: 'DECISION_ROUTING_STATIC_CATALOG', entries: DECISION_ROUTING_STATIC_CATALOG }, + { name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG }, +] as const; + +export const ALIAS_GROUPS: readonly RegistryAssemblyAliasGroup[] = [ + buildAliasGroup('state'), + buildAliasGroup('roadmap'), + buildAliasGroup('verify'), + buildAliasGroup('validate'), + buildAliasGroup('phase'), + buildAliasGroup('phases'), + buildAliasGroup('init'), +] as const; + +export const STATIC_GROUP_BY_NAME = Object.fromEntries( + STATIC_CATALOG_GROUPS.map((group) => [group.name, group]), +) as Readonly>; + +export const ALIAS_GROUP_BY_FAMILY = Object.fromEntries( + ALIAS_GROUPS.map((group) => [group.family, group]), +) as Readonly>; + +export const REGISTRY_ASSEMBLY_PLAN: readonly RegistryAssemblyStep[] = [ + { kind: 'static', key: 'FOUNDATION_STATIC_CATALOG' }, + { kind: 'alias', key: 'state' }, + { kind: 'static', key: 'STATE_SUPPORT_STATIC_CATALOG' }, + { kind: 'alias', key: 'roadmap' }, + { kind: 'static', key: 'MUTATION_SURFACES_STATIC_CATALOG' }, + { kind: 'alias', key: 'verify' }, + { kind: 'static', key: 'VERIFY_DECISION_STATIC_CATALOG' }, + { kind: 'alias', key: 'validate' }, + { kind: 'static', key: 'DECISION_ROUTING_STATIC_CATALOG' }, + { kind: 'alias', key: 'phase' }, + { kind: 'alias', key: 'phases' }, + { kind: 'alias', key: 'init' }, + { kind: 'static', key: 'DOMAIN_STATIC_CATALOG' }, +] as const; diff --git a/sdk/src/query/registry-assembly.test.ts b/sdk/src/query/registry-assembly.test.ts index a3a12d359..fd29f5d72 100644 --- a/sdk/src/query/registry-assembly.test.ts +++ b/sdk/src/query/registry-assembly.test.ts @@ -15,6 +15,7 @@ import { type RegistryAssemblyAliasGroup, type RegistryAssemblyStaticGroup, } from './registry-assembly-invariants.js'; +import { REGISTRY_ASSEMBLY_PLAN } from './registry-assembly-descriptor.js'; const noop = async () => ({ data: null }); @@ -44,6 +45,11 @@ describe('registry assembly', () => { expect(registry.has(command), `missing mutation command: ${command}`).toBe(true); } }); + + it('uses declarative registry assembly plan', () => { + expect(REGISTRY_ASSEMBLY_PLAN.length).toBeGreaterThan(0); + expect(REGISTRY_ASSEMBLY_PLAN[0]).toEqual({ kind: 'static', key: 'FOUNDATION_STATIC_CATALOG' }); + }); }); describe('registry assembly invariants', () => { diff --git a/sdk/src/query/registry-assembly.ts b/sdk/src/query/registry-assembly.ts index e84ae22b2..5d677687d 100644 --- a/sdk/src/query/registry-assembly.ts +++ b/sdk/src/query/registry-assembly.ts @@ -1,28 +1,20 @@ import { QueryRegistry } from './registry.js'; -import type { AliasCatalogEntry } from './command-catalog.js'; -import type { CommandFamily } from './command-manifest.types.js'; import { GSDEventStream } from '../event-stream.js'; -import type { QueryHandler } from './utils.js'; import { registerAliasCatalog, registerStaticCatalog } from './command-catalog.js'; -import { - FOUNDATION_STATIC_CATALOG, - STATE_SUPPORT_STATIC_CATALOG, - MUTATION_SURFACES_STATIC_CATALOG, - VERIFY_DECISION_STATIC_CATALOG, - DECISION_ROUTING_STATIC_CATALOG, -} from './command-static-catalog-foundation.js'; -import { DOMAIN_STATIC_CATALOG } from './command-static-catalog-domain.js'; import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './query-policy-capability.js'; -import { COMMAND_DEFINITIONS_BY_FAMILY, type CommandDefinition } from './command-definition.js'; import { decorateMutationsWithEvents } from './mutation-event-decorator.js'; -import { FAMILY_HANDLERS } from './command-family-handlers.js'; +import { + STATIC_CATALOG_GROUPS, + ALIAS_GROUPS, + STATIC_GROUP_BY_NAME, + ALIAS_GROUP_BY_FAMILY, + REGISTRY_ASSEMBLY_PLAN, +} from './registry-assembly-descriptor.js'; import { assertAliasCanonicalsHaveHandlers, assertMutationCommandsRegistered, assertNoDuplicateRegisteredCommands, assertRawOutputPolicyCommandsRegistered, - type RegistryAssemblyAliasGroup, - type RegistryAssemblyStaticGroup, } from './registry-assembly-invariants.js'; /** @@ -30,53 +22,6 @@ import { */ export const QUERY_MUTATION_COMMANDS = new Set(QUERY_MUTATION_COMMAND_LIST); -const STATIC_CATALOG_GROUPS: readonly RegistryAssemblyStaticGroup[] = [ - { name: 'FOUNDATION_STATIC_CATALOG', entries: FOUNDATION_STATIC_CATALOG }, - { name: 'STATE_SUPPORT_STATIC_CATALOG', entries: STATE_SUPPORT_STATIC_CATALOG }, - { name: 'MUTATION_SURFACES_STATIC_CATALOG', entries: MUTATION_SURFACES_STATIC_CATALOG }, - { name: 'VERIFY_DECISION_STATIC_CATALOG', entries: VERIFY_DECISION_STATIC_CATALOG }, - { name: 'DECISION_ROUTING_STATIC_CATALOG', entries: DECISION_ROUTING_STATIC_CATALOG }, - { name: 'DOMAIN_STATIC_CATALOG', entries: DOMAIN_STATIC_CATALOG }, -] as const; - -function toAliasCatalogEntry(entry: CommandDefinition): AliasCatalogEntry { - return { - canonical: entry.canonical, - aliases: entry.aliases, - }; -} - -function buildAliasGroup(family: CommandFamily): RegistryAssemblyAliasGroup { - const definitions = COMMAND_DEFINITIONS_BY_FAMILY[family]; - const familyHandlers = FAMILY_HANDLERS[family] as Readonly>; - const handlers: Record = {}; - - for (const entry of definitions) { - const handler = familyHandlers[entry.handler_key]; - if (!handler) continue; - handlers[entry.canonical] = handler; - } - - return { - family, - aliases: definitions.map(toAliasCatalogEntry), - handlers, - }; -} - -const ALIAS_GROUPS: readonly RegistryAssemblyAliasGroup[] = [ - buildAliasGroup('state'), - buildAliasGroup('roadmap'), - buildAliasGroup('verify'), - buildAliasGroup('validate'), - buildAliasGroup('phase'), - buildAliasGroup('phases'), - buildAliasGroup('init'), -] as const; - -const ALIAS_GROUP_BY_FAMILY = Object.fromEntries( - ALIAS_GROUPS.map((group) => [group.family, group]), -) as Readonly>; export function buildRegistry(): QueryRegistry { assertAliasCanonicalsHaveHandlers({ @@ -94,28 +39,18 @@ export function buildRegistry(): QueryRegistry { const registry = new QueryRegistry(); - registerStaticCatalog(registry, FOUNDATION_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.state.aliases, ALIAS_GROUP_BY_FAMILY.state.handlers); + for (const step of REGISTRY_ASSEMBLY_PLAN) { + if (step.kind === 'static') { + const group = STATIC_GROUP_BY_NAME[step.key]; + if (!group) throw new Error(`registry assembly invariant failed: unknown static group: ${step.key}`); + registerStaticCatalog(registry, group.entries); + continue; + } - registerStaticCatalog(registry, STATE_SUPPORT_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.roadmap.aliases, ALIAS_GROUP_BY_FAMILY.roadmap.handlers); - - registerStaticCatalog(registry, MUTATION_SURFACES_STATIC_CATALOG); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.verify.aliases, ALIAS_GROUP_BY_FAMILY.verify.handlers); - - registerStaticCatalog(registry, VERIFY_DECISION_STATIC_CATALOG); - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.validate.aliases, ALIAS_GROUP_BY_FAMILY.validate.handlers); - - registerStaticCatalog(registry, DECISION_ROUTING_STATIC_CATALOG); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phase.aliases, ALIAS_GROUP_BY_FAMILY.phase.handlers); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.phases.aliases, ALIAS_GROUP_BY_FAMILY.phases.handlers); - - registerAliasCatalog(registry, ALIAS_GROUP_BY_FAMILY.init.aliases, ALIAS_GROUP_BY_FAMILY.init.handlers); - - registerStaticCatalog(registry, DOMAIN_STATIC_CATALOG); + const group = ALIAS_GROUP_BY_FAMILY[step.key as keyof typeof ALIAS_GROUP_BY_FAMILY]; + if (!group) throw new Error(`registry assembly invariant failed: unknown alias group: ${step.key}`); + registerAliasCatalog(registry, group.aliases, group.handlers); + } assertMutationCommandsRegistered(registry, QUERY_MUTATION_COMMANDS); assertRawOutputPolicyCommandsRegistered(registry, TRANSPORT_RAW_COMMANDS); From a441f96f370a763b10df0627aa0e458c0fa4b530 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:17:05 -0400 Subject: [PATCH 39/44] chore: update changeset pr reference --- .changeset/plucky-pandas-sprint.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/plucky-pandas-sprint.md b/.changeset/plucky-pandas-sprint.md index 33188ca32..dfed21220 100644 --- a/.changeset/plucky-pandas-sprint.md +++ b/.changeset/plucky-pandas-sprint.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 3107 +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. From 38718e9d4b91bd30c8314952b24a4b8a592fbfd9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:25:43 -0400 Subject: [PATCH 40/44] fix: avoid unsafe Promise cast in execRaw --- sdk/src/gsd-tools.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 169cf948c..a6fdbbd90 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -150,7 +150,10 @@ export class GSDTools { * Use for commands like `config-set` that return plain text, not JSON. */ async execRaw(command: string, args: string[] = []): Promise { - return this.executeWithToolsError(command, args, async () => this.commandExecutor.exec(command, args, 'raw') as string); + return this.executeWithToolsError(command, args, async () => { + const out = await this.commandExecutor.exec(command, args, 'raw'); + return typeof out === 'string' ? out : String(out ?? ''); + }); } From 1642f47908c6c36892f1322c357a53092e6db325 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:36:17 -0400 Subject: [PATCH 41/44] test: align registry wiring assertions with declarative assembly --- tests/bug-2492-context-coverage-gate.test.cjs | 11 +++---- ...sd-sdk-query-registry-integration.test.cjs | 29 +++++++------------ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs index cee82a61d..658702285 100644 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -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,10 @@ 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'); + assert.ok(d.includes('decisions.parse') || d.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decisions.parse handler must be registered'); }); }); diff --git a/tests/gsd-sdk-query-registry-integration.test.cjs b/tests/gsd-sdk-query-registry-integration.test.cjs index cf146c29f..74be9f7fb 100644 --- a/tests/gsd-sdk-query-registry-integration.test.cjs +++ b/tests/gsd-sdk-query-registry-integration.test.cjs @@ -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) { From 40acf1f02ed9c5448d16f2112281ecbab354a015 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:47:10 -0400 Subject: [PATCH 42/44] fix: address CodeRabbit findings on query/transport error handling --- sdk/src/gsd-tools-error.test.ts | 5 +++++ sdk/src/gsd-tools-error.ts | 4 ++-- sdk/src/query-raw-output-projection.test.ts | 5 +++++ sdk/src/query-raw-output-projection.ts | 17 +++++++++++------ sdk/src/query-subprocess-adapter.test.ts | 11 +++++++++++ sdk/src/query-subprocess-adapter.ts | 6 ++++-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/sdk/src/gsd-tools-error.test.ts b/sdk/src/gsd-tools-error.test.ts index 704de685a..2e1973198 100644 --- a/sdk/src/gsd-tools-error.test.ts +++ b/sdk/src/gsd-tools-error.test.ts @@ -13,4 +13,9 @@ describe('GSDToolsError constructors', () => { 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' }); + }); }); diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index 829811830..713476d1b 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -22,7 +22,7 @@ export class GSDToolsError extends Error { ) { super(message, options); this.name = 'GSDToolsError'; - this.classification = options?.classification; + this.classification = options?.classification ?? failureClassification(); } static timeout( @@ -61,5 +61,5 @@ export class GSDToolsError extends Error { ); } - public readonly classification?: GSDToolsErrorClassification; + public readonly classification: GSDToolsErrorClassification; } diff --git a/sdk/src/query-raw-output-projection.test.ts b/sdk/src/query-raw-output-projection.test.ts index 4b7db4e85..811336dec 100644 --- a/sdk/src/query-raw-output-projection.test.ts +++ b/sdk/src/query-raw-output-projection.test.ts @@ -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)'); + }); }); diff --git a/sdk/src/query-raw-output-projection.ts b/sdk/src/query-raw-output-projection.ts index 51f564435..7dcd3d6e1 100644 --- a/sdk/src/query-raw-output-projection.ts +++ b/sdk/src/query-raw-output-projection.ts @@ -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; 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; 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; 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); } diff --git a/sdk/src/query-subprocess-adapter.test.ts b/sdk/src/query-subprocess-adapter.test.ts index fae7c4a5b..3e45040cd 100644 --- a/sdk/src/query-subprocess-adapter.test.ts +++ b/sdk/src/query-subprocess-adapter.test.ts @@ -64,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); diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index af83aac3d..bd2530ce1 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process'; +import { isAbsolute, resolve } from 'node:path'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; import type { QueryToolsErrorFactory } from './query-tools-error-factory.js'; @@ -131,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}`); } } From 78c794c016263a3db02aa848dc5691f6d72f0e2d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 21:48:07 -0400 Subject: [PATCH 43/44] test: remove dead registry wiring assertion --- tests/bug-2492-context-coverage-gate.test.cjs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs index 658702285..840f6ae23 100644 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -175,6 +175,5 @@ describe('SDK wiring for #2492 gates', () => { 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'); - assert.ok(d.includes('decisions.parse') || d.includes('VERIFY_DECISION_STATIC_CATALOG'), 'decisions.parse handler must be registered'); }); }); From 2d25c97706518fefcc7c014a3a6e2f9ea9806249 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 03:17:22 +0000 Subject: [PATCH 44/44] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- sdk/src/query/query-dispatch.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/query-dispatch.ts b/sdk/src/query/query-dispatch.ts index 4349ff087..5f38a6ec9 100644 --- a/sdk/src/query/query-dispatch.ts +++ b/sdk/src/query/query-dispatch.ts @@ -80,10 +80,13 @@ export function formatPick(data: unknown, pickField?: string): unknown { 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, null, 2)}\n`; + return `${JSON.stringify(output === undefined ? null : output, null, 2)}\n`; } export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult {