refactor(query): deepen runtime context/native adapter/output seams (#3076)

* refactor(query): deepen runtime context, native adapter, and cli output seams

* chore(changeset): add fragment for query seam deepening continuation

* refactor(query): converge internal command-resolution imports on canonical seam

* refactor(query): remove dead seam wrappers and converge on canonical modules

* docs(architecture): update context and adr for query seam completion

* fix(query): preserve gsd-tools stderr in cli output and clarify static ws test scope

* test(query): cover whitespace stderr and null exitCode fallback
This commit is contained in:
Tom Boucher
2026-05-03 16:31:48 -04:00
committed by GitHub
parent 5c9f34bd31
commit 9c92c32f6e
28 changed files with 196 additions and 115 deletions

View File

@@ -26,7 +26,7 @@ CJS routing seams mirror these families with thin adapters (`state/verify/init/p
## `gsd-sdk query` routing
1. **`normalizeQueryCommand()`** (`normalize-query-command.ts`) — maps the first argv tokens to the same **command + subcommand** patterns as `gsd-tools` `runCommand()` where needed (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase` with args `['9']`, `scaffold …` → `phase.scaffold`). Re-exported from **`@gsd-build/sdk`** and **`createRegistry`’s module** (`sdk/src/query/index.ts`) so programmatic callers can mirror CLI tokenization without importing a deep path.
1. **`normalizeQueryCommand()`** (`query-command-resolution-strategy.ts`) — maps the first argv tokens to the same **command + subcommand** patterns as `gsd-tools` `runCommand()` where needed (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase` with args `['9']`, `scaffold …` → `phase.scaffold`). Re-exported from **`@gsd-build/sdk`** and **`createRegistry`’s module** (`sdk/src/query/index.ts`) so programmatic callers can mirror CLI tokenization without importing a deep path.
2. **`resolveQueryArgv()`** (`registry.ts`) — **longest-prefix match** on the normalized argv: tries joined keys `a.b.c` then `a b c` for each prefix length, longest first. Example: `state update status X` → handler `state.update` with args `[status, X]`.
3. **Dotted single token**: one token like `init.new-project` matches the registry; if the first pass finds no handler, a single dotted token is split and matching runs again.
4. **CJS fallback (CLI)**: if nothing matches a registered handler and `GSD_QUERY_FALLBACK` is not `off`/`never`/`false`/`0`, the CLI shells out to `gsd-tools.cjs` with argv derived from the normalized tokens (dotted commands are split into CJS-style segments). stderr receives a short bridge warning. Set `GSD_QUERY_FALLBACK=off` for strict mode (parity tests). CLI-only commands such as `graphify` rely on this path until native handlers exist.

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createRegistry } from './index.js';
import { explainQueryCommandNoMatch, resolveQueryCommand, resolveQueryTokens } from './command-resolution.js';
import { explainQueryCommandNoMatch, resolveQueryCommand, resolveQueryTokens } from './query-command-resolution-strategy.js';
describe('command resolution', () => {
it('resolves normalized tokens with metadata', () => {

View File

@@ -1,10 +0,0 @@
export {
resolveQueryCommand,
resolveQueryTokens,
type QueryCommandRegistryLike,
type QueryCommandResolution,
type QueryMatchMode,
type QueryResolutionSource,
explainQueryCommandNoMatch,
type QueryCommandNoMatch,
} from './query-command-resolution-strategy.js';

View File

@@ -4,4 +4,4 @@ export { createRegistry, buildRegistry, decorateRegistryMutations, QUERY_MUTATIO
export type { QueryResult, QueryHandler } from './utils.js';
export { extractField } from './registry.js';
export { normalizeQueryCommand } from './normalize-query-command.js';
export { normalizeQueryCommand } from './query-command-resolution-strategy.js';

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { normalizeQueryCommand } from './normalize-query-command.js';
import { normalizeQueryCommand } from './query-command-resolution-strategy.js';
describe('normalizeQueryCommand', () => {
it('merges nested gsd-tools-style state + subcommand', () => {

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCommand } from './policy-convergence.js';
import { QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS, isQueryMutationCommand } from './query-policy-capability.js';
describe('policy convergence', () => {
it('contains expected raw transport aliases', () => {

View File

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

View File

@@ -41,9 +41,9 @@ describe('query-cli-adapter', () => {
expect(out.stderrLines.join('\n')).toContain('requires a command');
});
it('forwards ws to registry.dispatch via dispatchNative', async () => {
it('forwards ws to registry.dispatch via native adapter', async () => {
runQueryDispatchSpy.mockImplementationOnce(async (input: any) => {
await input.dispatchNative('state', ['show']);
await input.nativeAdapter.dispatch('state', ['show']);
return { ok: true, exit_code: 0, stdout: '', stderr: [] };
});

View File

@@ -1,9 +1,9 @@
import { findProjectRoot } from './helpers.js';
import { createRegistry } from './index.js';
import { runQueryDispatch } from './query-dispatch.js';
import { resolveGsdToolsPath, GSDToolsError } from '../gsd-tools.js';
import { GSDError, exitCodeFor } from '../errors.js';
import { validateWorkstreamName } from '../workstream-utils.js';
import { resolveGsdToolsPath } from '../gsd-tools.js';
import { resolveQueryRuntimeContext } from './query-runtime-context.js';
import { createQueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js';
import { buildQueryCliOutputFromDispatch, buildQueryCliOutputFromError, type QueryCliAdapterOutput } from './query-cli-output.js';
export interface QueryCliAdapterInput {
projectDir: string;
@@ -11,11 +11,6 @@ export interface QueryCliAdapterInput {
queryArgv?: string[];
}
export interface QueryCliAdapterOutput {
exitCode: number;
stdoutChunks: string[];
stderrLines: string[];
}
function queryFallbackToCjsEnabled(): boolean {
const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase();
@@ -23,49 +18,21 @@ function queryFallbackToCjsEnabled(): boolean {
return true;
}
function resolveQueryWorkstream(ws: string | undefined): string | undefined {
if (ws !== undefined) {
return validateWorkstreamName(ws) ? ws : undefined;
}
const envWs = process.env.GSD_WORKSTREAM;
if (!envWs) return undefined;
return validateWorkstreamName(envWs) ? envWs : undefined;
}
export async function runQueryCliCommand(input: QueryCliAdapterInput): Promise<QueryCliAdapterOutput> {
const stderrLines: string[] = [];
const stdoutChunks: string[] = [];
const ws = resolveQueryWorkstream(input.ws);
try {
const projectDir = findProjectRoot(input.projectDir);
const runtime = resolveQueryRuntimeContext({ projectDir: input.projectDir, ws: input.ws });
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir,
ws,
projectDir: runtime.projectDir,
ws: runtime.ws,
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
resolveGsdToolsPath,
dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, projectDir, ws),
nativeAdapter: createQueryNativeDispatchAdapter(registry, runtime.projectDir, runtime.ws),
}, input.queryArgv ?? []);
stderrLines.push(...out.stderr);
if (!out.ok) {
stderrLines.push(out.error.message);
return { exitCode: out.exit_code, stdoutChunks, stderrLines };
}
if (out.stdout) stdoutChunks.push(out.stdout);
return { exitCode: 0, stdoutChunks, stderrLines };
return buildQueryCliOutputFromDispatch(out);
} catch (err) {
if (err instanceof GSDError) {
stderrLines.push(`Error: ${err.message}`);
return { exitCode: exitCodeFor(err.classification), stdoutChunks, stderrLines };
}
if (err instanceof GSDToolsError) {
stderrLines.push(`Error: ${err.message}`);
return { exitCode: err.exitCode ?? 1, stdoutChunks, stderrLines };
}
stderrLines.push(`Error: ${err instanceof Error ? err.message : String(err)}`);
return { exitCode: 1, stdoutChunks, stderrLines };
return buildQueryCliOutputFromError(err);
}
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { GSDToolsError } from '../gsd-tools.js';
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 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 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 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 out = buildQueryCliOutputFromError(err);
expect(out.exitCode).toBe(1);
expect(out.stderrLines).toEqual(['line']);
});
});

View File

@@ -0,0 +1,35 @@
import { GSDError, exitCodeFor } from '../errors.js';
import { GSDToolsError } from '../gsd-tools.js';
import type { QueryDispatchResult } from './query-dispatch-contract.js';
export interface QueryCliAdapterOutput {
exitCode: number;
stdoutChunks: string[];
stderrLines: string[];
}
export function buildQueryCliOutputFromDispatch(out: QueryDispatchResult): QueryCliAdapterOutput {
const stderrLines = [...out.stderr];
const stdoutChunks: string[] = [];
if (!out.ok) {
stderrLines.push(out.error.message);
return { exitCode: out.exit_code, stdoutChunks, stderrLines };
}
if (out.stdout) stdoutChunks.push(out.stdout);
return { exitCode: 0, stdoutChunks, stderrLines };
}
export function buildQueryCliOutputFromError(err: unknown): QueryCliAdapterOutput {
const stdoutChunks: string[] = [];
if (err instanceof GSDError) {
return { stderrLines: [`Error: ${err.message}`], exitCode: exitCodeFor(err.classification), stdoutChunks };
}
if (err instanceof GSDToolsError) {
// Prefer raw subprocess stderr when available so users see the original tool diagnostics.
const stderrLines = err.stderr && err.stderr.trim().length > 0
? err.stderr.split(/\r?\n/).filter(line => line.length > 0)
: [`Error: ${err.message}`];
return { stderrLines, exitCode: err.exitCode ?? 1, stdoutChunks };
}
return { stderrLines: [`Error: ${err instanceof Error ? err.message : String(err)}`], exitCode: 1, stdoutChunks };
}

View File

@@ -1,6 +1,9 @@
import type { QueryRegistry } from './registry.js';
import { normalizeQueryCommand } from './normalize-query-command.js';
import { resolveQueryCommand, type QueryCommandResolution } from './command-resolution.js';
import {
normalizeQueryCommand,
resolveQueryCommand,
type QueryCommandResolution,
} from './query-command-resolution-strategy.js';
export type DispatchMode = 'native' | 'cjs' | 'error';

View File

@@ -2,6 +2,7 @@ import type { QueryRegistry } from './registry.js';
import { runCjsFallbackDispatch } from './query-fallback-executor.js';
import type { QueryDispatchResult } from './query-dispatch-contract.js';
import type { QueryResult } from './utils.js';
import type { QueryNativeDispatchAdapter } from './query-native-dispatch-adapter.js';
import { mapFallbackDispatchError, mapNativeDispatchError, toDispatchFailure } from './query-dispatch-error-mapper.js';
import { formatSuccess } from './query-dispatch-formatting.js';
import { diagnoseUnknownCommand } from './query-command-diagnosis.js';
@@ -17,7 +18,9 @@ export interface QueryDispatchDeps {
ws?: string;
cjsFallbackEnabled: boolean;
resolveGsdToolsPath: (projectDir: string) => string;
dispatchNative: (cmd: string, args: string[]) => Promise<QueryResult>;
/** @deprecated use nativeAdapter */
dispatchNative?: (cmd: string, args: string[]) => Promise<QueryResult>;
nativeAdapter?: QueryNativeDispatchAdapter;
}
@@ -73,8 +76,16 @@ export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: strin
if (!matched) {
return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs));
}
const dispatchNative = deps.nativeAdapter
? (cmd: string, args: string[]) => deps.nativeAdapter!.dispatch(cmd, args)
: deps.dispatchNative;
if (!dispatchNative) {
return toDispatchFailure(mapNativeDispatchError(new Error('Missing native dispatch adapter'), matched.cmd, matched.args));
}
try {
const result = await deps.dispatchNative(matched.cmd, matched.args);
const result = await dispatchNative(matched.cmd, matched.args);
return dispatchSuccess(formatSuccess(result.data, result.format, pickField));
} catch (e) {
return toDispatchFailure(mapNativeDispatchError(e, matched.cmd, matched.args));

View File

@@ -0,0 +1,16 @@
import type { QueryRegistry } from './registry.js';
import type { QueryResult } from './utils.js';
export interface QueryNativeDispatchAdapter {
dispatch(command: string, args: string[]): Promise<QueryResult>;
}
export function createQueryNativeDispatchAdapter(
registry: QueryRegistry,
projectDir: string,
ws?: string,
): QueryNativeDispatchAdapter {
return {
dispatch: (command, args) => registry.dispatch(command, args, projectDir, ws),
};
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { QUERY_POLICY_SNAPSHOT, QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './query-policy-snapshot.js';
import { QUERY_POLICY_SNAPSHOT, QUERY_MUTATION_COMMAND_LIST, TRANSPORT_RAW_COMMANDS } from './query-policy-capability.js';
describe('query-policy-snapshot', () => {
it('exposes policy constants through one snapshot interface', () => {

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { supportsMutationCommand, supportsRawOutputCommand } from './query-registry-capability.js';
import { supportsMutationCommand, supportsRawOutputCommand } from './query-policy-capability.js';
describe('query-registry-capability', () => {
it('reports mutation command capability', () => {

View File

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

View File

@@ -0,0 +1,29 @@
import { findProjectRoot } from './helpers.js';
import { validateWorkstreamName } from '../workstream-utils.js';
export interface QueryRuntimeContextInput {
projectDir: string;
ws?: string;
}
export interface QueryRuntimeContext {
projectDir: string;
ws?: string;
}
export function resolveQueryRuntimeContext(input: QueryRuntimeContextInput): QueryRuntimeContext {
const projectDir = findProjectRoot(input.projectDir);
if (input.ws !== undefined) {
return {
projectDir,
ws: validateWorkstreamName(input.ws) ? input.ws : undefined,
};
}
const envWs = process.env.GSD_WORKSTREAM;
return {
projectDir,
ws: envWs && validateWorkstreamName(envWs) ? envWs : undefined,
};
}

View File

@@ -12,7 +12,7 @@ import {
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 './policy-convergence.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';

View File

@@ -22,7 +22,7 @@
import type { QueryResult, QueryHandler } from './utils.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { resolveQueryTokens } from './command-resolution.js';
import { resolveQueryTokens } from './query-command-resolution-strategy.js';
// ─── extractField ──────────────────────────────────────────────────────────