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

@@ -0,0 +1,6 @@
---
type: Changed
pr: 3075
---
**query architecture deepening pass** — extracted Query Runtime Context, Native Dispatch Adapter, and Query CLI Output Modules so dispatch policy, runtime context policy, and CLI projection logic each live behind focused seams with higher locality and leverage.

View File

@@ -15,3 +15,15 @@ Canonical error kind set:
### Command Definition Module
Canonical command metadata Interface powering alias, catalog, and semantics generation.
### Query Runtime Context Module
Module owning query-time context resolution for `projectDir` and `ws`, including precedence and validation policy used by query adapters.
### Native Dispatch Adapter Module
Adapter Module that satisfies native query dispatch at the Dispatch Policy seam, so policy modules consume a focused dispatch Interface instead of closure-wired call sites.
### Query CLI Output Module
Module owning projection from dispatch results/errors to CLI `{ exitCode, stdoutChunks, stderrLines }` output contract.
### Query Command Resolution Module
Canonical command normalization and resolution Interface (`query-command-resolution-strategy`) used by internal query/transport paths after dead-wrapper convergence.

View File

@@ -1,3 +1,23 @@
# Dispatch policy module as single seam for query execution outcomes
We decided to centralize query dispatch outcomes in one Dispatch Policy Module that returns a structured union result (`ok` success or failure with typed `kind`, `details`, and final `exit_code`) instead of mixing throws and ad-hoc error mapping across CLI and SDK paths. This keeps fallback policy, timeout classification, and exit mapping in one place for better locality, prevents drift between native and fallback behavior, and makes callers thin adapters over a stable interface.
## Amendment (2026-05-03): query seam deepening completion
To complete the query architecture pass, we deepened adjacent seams around the Dispatch Policy Module:
- Extracted **Query Runtime Context Module** to own `projectDir` + `ws` resolution policy.
- Extracted **Native Dispatch Adapter Module** so Dispatch Policy consumes a stable native dispatch Interface (not closure-wired call sites).
- Extracted **Query CLI Output Module** to own projection from dispatch results/errors to CLI output contract.
- Converged internal command-resolution and policy imports onto canonical modules and removed dead wrapper modules.
### Dead-wrapper convergence
Removed wrapper Modules after call-site convergence:
- `normalize-query-command.ts`
- `command-resolution.ts`
- `policy-convergence.ts`
- `query-policy-snapshot.ts`
- `query-registry-capability.ts`
This amendment preserves the original ADR direction: keep policy depth high, adapters thin, and locality concentrated in explicit modules.

View File

@@ -20,7 +20,7 @@ import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis }
import type { GSDEventStream } from './event-stream.js';
import { GSDError, exitCodeFor } from './errors.js';
import { createRegistry } from './query/index.js';
import { resolveQueryCommand, type QueryCommandResolution } from './query/command-resolution.js';
import { resolveQueryCommand, type QueryCommandResolution } from './query/query-command-resolution-strategy.js';
import { formatStateLoadRawStdout } from './query/state-project-load.js';
import type { QueryResult } from './query/utils.js';
import { GSDTransport } from './gsd-transport.js';
@@ -556,32 +556,6 @@ export class GSDTools {
}
}
/**
* Run `gsd-sdk query` semantics in-process: normalize argv, resolve registry, dispatch.
* Returns handler JSON payload (same as stdout from the `gsd-sdk query` CLI without `--pick`).
*/
export async function runGsdToolsQuery(projectDir: string, queryArgv: string[]): Promise<unknown> {
const { createRegistry } = await import('./query/index.js');
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
const { resolveQueryCommand } = await import('./query/command-resolution.js');
const { GSDError, ErrorClassification } = await import('./errors.js');
if (queryArgv.length === 0 || !queryArgv[0]) {
throw new GSDError('runGsdToolsQuery requires a command', ErrorClassification.Validation);
}
const registry = createRegistry();
const [normCmd, normArgs] = normalizeQueryCommand(queryArgv[0], queryArgv.slice(1));
const matched = resolveQueryCommand(queryArgv[0], queryArgv.slice(1), registry);
if (!matched) {
throw new GSDError(
`Unknown command: "${[normCmd, ...normArgs].join(' ')}". No native handler registered.`,
ErrorClassification.Validation,
);
}
const result = await registry.dispatch(matched.cmd, matched.args, projectDir);
return result.data;
}
// ─── Path resolution ────────────────────────────────────────────────────────
/**

View File

@@ -1,4 +1,4 @@
import { TRANSPORT_RAW_COMMANDS } from './query/policy-convergence.js';
import { TRANSPORT_RAW_COMMANDS } from './query/query-policy-capability.js';
export type TransportMode = 'json' | 'raw';

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 ──────────────────────────────────────────────────────────

View File

@@ -6,8 +6,10 @@
/**
* Bug #2524: gsd-sdk query --ws <name> silently ignores the workstream flag.
* Tests that --ws is forwarded through the call chain:
* cli.ts -> runQueryCliCommand() -> registry.dispatch() -> planningPaths()
*
* This file is structural/static coverage only (source-file assertions).
* Runtime forwarding coverage for the query adapter path lives in:
* sdk/src/query/query-cli-adapter.test.ts
*
* Uses static source-file text assertions (no sdk/dist/ build required in CI).
*/