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:
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export {
|
||||
resolveQueryCommand,
|
||||
resolveQueryTokens,
|
||||
type QueryCommandRegistryLike,
|
||||
type QueryCommandResolution,
|
||||
type QueryMatchMode,
|
||||
type QueryResolutionSource,
|
||||
explainQueryCommandNoMatch,
|
||||
type QueryCommandNoMatch,
|
||||
} from './query-command-resolution-strategy.js';
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { normalizeQueryCommand } from './query-command-resolution-strategy.js';
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
QUERY_POLICY_SNAPSHOT,
|
||||
QUERY_MUTATION_COMMAND_LIST,
|
||||
TRANSPORT_RAW_COMMANDS,
|
||||
isQueryMutationCommand,
|
||||
} from './query-policy-snapshot.js';
|
||||
@@ -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: [] };
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
33
sdk/src/query/query-cli-output.test.ts
Normal file
33
sdk/src/query/query-cli-output.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
35
sdk/src/query/query-cli-output.ts
Normal file
35
sdk/src/query/query-cli-output.ts
Normal 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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
16
sdk/src/query/query-native-dispatch-adapter.ts
Normal file
16
sdk/src/query/query-native-dispatch-adapter.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
QUERY_POLICY_SNAPSHOT,
|
||||
QUERY_MUTATION_COMMAND_LIST,
|
||||
TRANSPORT_RAW_COMMANDS,
|
||||
isQueryMutationCommand,
|
||||
} from './query-policy-capability.js';
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
supportsMutationCommand,
|
||||
supportsRawOutputCommand,
|
||||
} from './query-policy-capability.js';
|
||||
29
sdk/src/query/query-runtime-context.ts
Normal file
29
sdk/src/query/query-runtime-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user