Merge pull request #255 from open-gsd/fix/185-migrate-hub-errors-manifest

refactor(#185): migrate hub, errors, and manifest to canonical src seams
This commit is contained in:
Tom Boucher
2026-05-24 22:18:33 -04:00
committed by GitHub
6 changed files with 338 additions and 335 deletions

246
sdk/src/dispatch/hub.ts Normal file
View File

@@ -0,0 +1,246 @@
import type { QueryRegistry } from '../query/registry.js';
import { extractField } from '../query/registry.js';
import { normalizeQueryCommand } from '../query/query-command-resolution-strategy.js';
import { runCjsFallbackDispatch } from '../query/query-fallback-executor.js';
import type { QueryDispatchError, QueryDispatchResult } from '../query/query-dispatch-contract.js';
import type { QueryResult } from '../query/utils.js';
import type { QueryNativeDispatchAdapter } from '../query/query-native-dispatch-adapter.js';
import type { CommandTopology, CommandTopologyMatch } from '../query/command-topology.js';
import { unknownCommandError, validationError, fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from '../query/query-error-taxonomy.js';
import { canUseCjsFallback } from '../query/query-fallback-policy.js';
import { toFailureSignal } from '../query-failure-classification.js';
export interface QueryDispatchDeps {
registry: QueryRegistry;
projectDir: string;
ws?: string;
cjsFallbackEnabled: boolean;
resolveGsdToolsPath: (projectDir: string) => string;
/** @deprecated use topology */
dispatchNative?: (cmd: string, args: string[]) => Promise<QueryResult>;
/** @deprecated use topology */
nativeAdapter?: QueryNativeDispatchAdapter;
topology: CommandTopology;
}
export type DispatchMode = 'native' | 'cjs' | 'error';
export interface DispatchPlan {
mode: DispatchMode;
normalized: { command: string; args: string[]; tokens: string[] };
matched: CommandTopologyMatch | null;
noMatchMessage?: string;
noMatchNormalized?: string;
noMatchAttempted?: string[];
noMatchHints?: string[];
}
export type DispatchSuccessFormat = 'json' | 'text' | undefined;
export interface DispatchInputValidationResult {
queryArgs: string[];
pickField?: string;
error?: QueryDispatchResult;
}
export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult {
return {
ok: false,
error,
stderr,
exit_code: error.code,
};
}
export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult {
return {
ok: true,
stdout,
stderr,
exit_code: 0,
};
}
export function toDispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult {
return dispatchFailure(error, stderr);
}
export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args);
}
export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args);
}
export function formatPick(data: unknown, pickField?: string): unknown {
if (!pickField) return data;
return extractField(data, pickField);
}
export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string {
if (format === 'text' && typeof data === 'string') {
if (pickField) {
throw new Error('--pick is not supported for text output');
}
return data.endsWith('\n') ? data : `${data}\n`;
}
const output = formatPick(data, pickField);
if (pickField && typeof output === 'string') {
return output.endsWith('\n') ? output : `${output}\n`;
}
return `${JSON.stringify(output === undefined ? null : output, null, 2)}\n`;
}
export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult {
const queryArgs = [...queryArgv];
const pickIdx = queryArgs.indexOf('--pick');
if (pickIdx !== -1) {
if (pickIdx + 1 >= queryArgs.length) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: --pick requires a field name',
details: { field: '--pick', reason: 'missing_value' },
})),
};
}
const pickField = queryArgs[pickIdx + 1];
queryArgs.splice(pickIdx, 2);
if (queryArgs.length === 0 || !queryArgs[0]) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: "gsd-sdk query" requires a command',
details: { reason: 'missing_command' },
})),
};
}
return { queryArgs, pickField };
}
if (queryArgs.length === 0 || !queryArgs[0]) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: "gsd-sdk query" requires a command',
details: { reason: 'missing_command' },
})),
};
}
return { queryArgs };
}
export function planQueryDispatch(
queryArgv: string[],
topology: CommandTopology,
cjsFallbackEnabled: boolean,
): DispatchPlan {
const queryCommand = queryArgv[0];
if (!queryCommand) {
return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null };
}
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
const normalizedTokens = [normCmd, ...normArgs];
const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled);
if (resolved.kind === 'match') {
return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved };
}
if (cjsFallbackEnabled) {
return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
}
return {
mode: 'error',
normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens },
matched: null,
noMatchMessage: resolved.message,
noMatchNormalized: resolved.normalized,
noMatchAttempted: resolved.attempted,
noMatchHints: resolved.hints,
};
}
function fail(error: ReturnType<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
return toDispatchFailure(error, stderr);
}
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
const validated = validateQueryDispatchInput(queryArgv);
if (validated.error) return validated.error;
const { queryArgs, pickField } = validated;
const plan = planQueryDispatch(queryArgs, deps.topology, deps.cjsFallbackEnabled);
const normCmd = plan.normalized.command;
const normArgs = plan.normalized.args;
if (!normCmd || !String(normCmd).trim()) {
return fail(validationError({ message: 'Error: "gsd-sdk query" requires a command', details: { reason: 'empty_normalized_command' } }));
}
if (plan.mode === 'error') {
return fail(unknownCommandError({
message: plan.noMatchMessage ?? `Error: Unknown command: "${queryArgs[0] ?? normCmd}"`,
normalized: plan.noMatchNormalized ?? [normCmd, ...normArgs].join(' ').trim(),
attempted: plan.noMatchAttempted ?? [],
hints: plan.noMatchHints ?? [],
}));
}
if (plan.mode === 'cjs') {
if (canUseCjsFallback({ cjsFallbackEnabled: deps.cjsFallbackEnabled })) {
try {
const gsdPath = deps.resolveGsdToolsPath(deps.projectDir);
return await runCjsFallbackDispatch({
projectDir: deps.projectDir,
gsdToolsPath: gsdPath,
normCmd,
normArgs,
ws: deps.ws,
pickField,
});
} catch (e) {
return toDispatchFailure(mapFallbackDispatchError(e, normCmd, normArgs));
}
}
return toDispatchFailure(mapFallbackDispatchError(new Error('CJS fallback denied by policy'), normCmd, normArgs));
}
const matched = plan.matched;
if (!matched) {
return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs));
}
// #3259: guard — if the invocation contains --help / -h AND the matched
// handler is a mutating command (mutation: true in the command manifest),
// short-circuit to a non-mutating stub. Mutating handlers are not help-aware
// by default (fail-closed). This prevents e.g. `milestone.complete --help`
// from writing milestone artifacts to disk.
const helpFlagPresent = matched.args.some((a) => a === '--help' || a === '-h');
if (helpFlagPresent && matched.mutation) {
return dispatchSuccess(
formatSuccess(
{ help: `Usage: gsd-sdk query ${matched.canonical} [args...]` },
undefined,
),
);
}
const dispatchNative = deps.nativeAdapter
? (cmd: string, args: string[]) => deps.nativeAdapter!.dispatch(cmd, args)
: deps.dispatchNative;
try {
const result = dispatchNative
? await dispatchNative(matched.canonical, matched.args)
: await matched.adapter(matched.args, deps.projectDir, deps.ws);
return dispatchSuccess(formatSuccess(result.data, result.format, pickField));
} catch (e) {
return toDispatchFailure(mapNativeDispatchError(e, matched.canonical, matched.args));
}
}

View File

@@ -1,72 +1 @@
/**
* Error classification system for the GSD SDK.
*
* Provides a taxonomy of error types with semantic exit codes,
* enabling CLI consumers and agents to distinguish between
* validation failures, execution errors, blocked states, and
* interruptions.
*
* @example
* ```typescript
* import { GSDError, ErrorClassification, exitCodeFor } from './errors.js';
*
* throw new GSDError('missing required arg', ErrorClassification.Validation);
* // CLI catch handler: process.exitCode = exitCodeFor(err.classification); // 10
* ```
*/
// ─── Error Classification ───────────────────────────────────────────────────
/** Classifies SDK errors into semantic categories for exit code mapping. */
export enum ErrorClassification {
/** Bad input, missing args, schema violations. Exit code 10. */
Validation = 'validation',
/** Runtime failure, file I/O, parse errors. Exit code 1. */
Execution = 'execution',
/** Dependency missing, phase not found. Exit code 11. */
Blocked = 'blocked',
/** Timeout, signal, user cancel. Exit code 1. */
Interruption = 'interruption',
}
// ─── GSDError ───────────────────────────────────────────────────────────────
/**
* Base error class for the GSD SDK with classification support.
*
* @param message - Human-readable error description
* @param classification - Error category for exit code mapping
*/
export class GSDError extends Error {
readonly name = 'GSDError';
readonly classification: ErrorClassification;
constructor(message: string, classification: ErrorClassification) {
super(message);
this.classification = classification;
}
}
// ─── Exit code mapping ──────────────────────────────────────────────────────
/**
* Maps an error classification to a semantic exit code.
*
* @param classification - The error classification to map
* @returns Numeric exit code: 10 (validation), 11 (blocked), 1 (execution/interruption)
*/
export function exitCodeFor(classification: ErrorClassification): number {
switch (classification) {
case ErrorClassification.Validation:
return 10;
case ErrorClassification.Blocked:
return 11;
case ErrorClassification.Execution:
case ErrorClassification.Interruption:
default:
return 1;
}
}
export * from './errors/index.js';

72
sdk/src/errors/index.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Error classification system for the GSD SDK.
*
* Provides a taxonomy of error types with semantic exit codes,
* enabling CLI consumers and agents to distinguish between
* validation failures, execution errors, blocked states, and
* interruptions.
*
* @example
* ```typescript
* import { GSDError, ErrorClassification, exitCodeFor } from './errors.js';
*
* throw new GSDError('missing required arg', ErrorClassification.Validation);
* // CLI catch handler: process.exitCode = exitCodeFor(err.classification); // 10
* ```
*/
// ─── Error Classification ───────────────────────────────────────────────────
/** Classifies SDK errors into semantic categories for exit code mapping. */
export enum ErrorClassification {
/** Bad input, missing args, schema violations. Exit code 10. */
Validation = 'validation',
/** Runtime failure, file I/O, parse errors. Exit code 1. */
Execution = 'execution',
/** Dependency missing, phase not found. Exit code 11. */
Blocked = 'blocked',
/** Timeout, signal, user cancel. Exit code 1. */
Interruption = 'interruption',
}
// ─── GSDError ───────────────────────────────────────────────────────────────
/**
* Base error class for the GSD SDK with classification support.
*
* @param message - Human-readable error description
* @param classification - Error category for exit code mapping
*/
export class GSDError extends Error {
readonly name = 'GSDError';
readonly classification: ErrorClassification;
constructor(message: string, classification: ErrorClassification) {
super(message);
this.classification = classification;
}
}
// ─── Exit code mapping ──────────────────────────────────────────────────────
/**
* Maps an error classification to a semantic exit code.
*
* @param classification - The error classification to map
* @returns Numeric exit code: 10 (validation), 11 (blocked), 1 (execution/interruption)
*/
export function exitCodeFor(classification: ErrorClassification): number {
switch (classification) {
case ErrorClassification.Validation:
return 10;
case ErrorClassification.Blocked:
return 11;
case ErrorClassification.Execution:
case ErrorClassification.Interruption:
default:
return 1;
}
}

17
sdk/src/manifest/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import { STATE_COMMAND_MANIFEST } from '../query/command-manifest.state.js';
import { VERIFY_COMMAND_MANIFEST } from '../query/command-manifest.verify.js';
import { INIT_COMMAND_MANIFEST } from '../query/command-manifest.init.js';
import { PHASE_COMMAND_MANIFEST } from '../query/command-manifest.phase.js';
import { PHASES_COMMAND_MANIFEST } from '../query/command-manifest.phases.js';
import { VALIDATE_COMMAND_MANIFEST } from '../query/command-manifest.validate.js';
import { ROADMAP_COMMAND_MANIFEST } from '../query/command-manifest.roadmap.js';
export const COMMAND_MANIFEST = [
...STATE_COMMAND_MANIFEST,
...VERIFY_COMMAND_MANIFEST,
...INIT_COMMAND_MANIFEST,
...PHASE_COMMAND_MANIFEST,
...PHASES_COMMAND_MANIFEST,
...VALIDATE_COMMAND_MANIFEST,
...ROADMAP_COMMAND_MANIFEST,
] as const;

View File

@@ -1,17 +1 @@
import { STATE_COMMAND_MANIFEST } from './command-manifest.state.js';
import { VERIFY_COMMAND_MANIFEST } from './command-manifest.verify.js';
import { INIT_COMMAND_MANIFEST } from './command-manifest.init.js';
import { PHASE_COMMAND_MANIFEST } from './command-manifest.phase.js';
import { PHASES_COMMAND_MANIFEST } from './command-manifest.phases.js';
import { VALIDATE_COMMAND_MANIFEST } from './command-manifest.validate.js';
import { ROADMAP_COMMAND_MANIFEST } from './command-manifest.roadmap.js';
export const COMMAND_MANIFEST = [
...STATE_COMMAND_MANIFEST,
...VERIFY_COMMAND_MANIFEST,
...INIT_COMMAND_MANIFEST,
...PHASE_COMMAND_MANIFEST,
...PHASES_COMMAND_MANIFEST,
...VALIDATE_COMMAND_MANIFEST,
...ROADMAP_COMMAND_MANIFEST,
] as const;
export * from '../manifest/index.js';

View File

@@ -1,246 +1 @@
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 { 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, 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;
projectDir: string;
ws?: string;
cjsFallbackEnabled: boolean;
resolveGsdToolsPath: (projectDir: string) => string;
/** @deprecated use topology */
dispatchNative?: (cmd: string, args: string[]) => Promise<QueryResult>;
/** @deprecated use topology */
nativeAdapter?: QueryNativeDispatchAdapter;
topology: CommandTopology;
}
export type DispatchMode = 'native' | 'cjs' | 'error';
export interface DispatchPlan {
mode: DispatchMode;
normalized: { command: string; args: string[]; tokens: string[] };
matched: CommandTopologyMatch | null;
noMatchMessage?: string;
noMatchNormalized?: string;
noMatchAttempted?: string[];
noMatchHints?: string[];
}
export type DispatchSuccessFormat = 'json' | 'text' | undefined;
export interface DispatchInputValidationResult {
queryArgs: string[];
pickField?: string;
error?: QueryDispatchResult;
}
export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult {
return {
ok: false,
error,
stderr,
exit_code: error.code,
};
}
export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult {
return {
ok: true,
stdout,
stderr,
exit_code: 0,
};
}
export function toDispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult {
return dispatchFailure(error, stderr);
}
export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args);
}
export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args);
}
export function formatPick(data: unknown, pickField?: string): unknown {
if (!pickField) return data;
return extractField(data, pickField);
}
export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string {
if (format === 'text' && typeof data === 'string') {
if (pickField) {
throw new Error('--pick is not supported for text output');
}
return data.endsWith('\n') ? data : `${data}\n`;
}
const output = formatPick(data, pickField);
if (pickField && typeof output === 'string') {
return output.endsWith('\n') ? output : `${output}\n`;
}
return `${JSON.stringify(output === undefined ? null : output, null, 2)}\n`;
}
export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult {
const queryArgs = [...queryArgv];
const pickIdx = queryArgs.indexOf('--pick');
if (pickIdx !== -1) {
if (pickIdx + 1 >= queryArgs.length) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: --pick requires a field name',
details: { field: '--pick', reason: 'missing_value' },
})),
};
}
const pickField = queryArgs[pickIdx + 1];
queryArgs.splice(pickIdx, 2);
if (queryArgs.length === 0 || !queryArgs[0]) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: "gsd-sdk query" requires a command',
details: { reason: 'missing_command' },
})),
};
}
return { queryArgs, pickField };
}
if (queryArgs.length === 0 || !queryArgs[0]) {
return {
queryArgs,
error: dispatchFailure(validationError({
message: 'Error: "gsd-sdk query" requires a command',
details: { reason: 'missing_command' },
})),
};
}
return { queryArgs };
}
export function planQueryDispatch(
queryArgv: string[],
topology: CommandTopology,
cjsFallbackEnabled: boolean,
): DispatchPlan {
const queryCommand = queryArgv[0];
if (!queryCommand) {
return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null };
}
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
const normalizedTokens = [normCmd, ...normArgs];
const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled);
if (resolved.kind === 'match') {
return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved };
}
if (cjsFallbackEnabled) {
return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null };
}
return {
mode: 'error',
normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens },
matched: null,
noMatchMessage: resolved.message,
noMatchNormalized: resolved.normalized,
noMatchAttempted: resolved.attempted,
noMatchHints: resolved.hints,
};
}
function fail(error: ReturnType<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
return toDispatchFailure(error, stderr);
}
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
const validated = validateQueryDispatchInput(queryArgv);
if (validated.error) return validated.error;
const { queryArgs, pickField } = validated;
const plan = planQueryDispatch(queryArgs, deps.topology, deps.cjsFallbackEnabled);
const normCmd = plan.normalized.command;
const normArgs = plan.normalized.args;
if (!normCmd || !String(normCmd).trim()) {
return fail(validationError({ message: 'Error: "gsd-sdk query" requires a command', details: { reason: 'empty_normalized_command' } }));
}
if (plan.mode === 'error') {
return fail(unknownCommandError({
message: plan.noMatchMessage ?? `Error: Unknown command: "${queryArgs[0] ?? normCmd}"`,
normalized: plan.noMatchNormalized ?? [normCmd, ...normArgs].join(' ').trim(),
attempted: plan.noMatchAttempted ?? [],
hints: plan.noMatchHints ?? [],
}));
}
if (plan.mode === 'cjs') {
if (canUseCjsFallback({ cjsFallbackEnabled: deps.cjsFallbackEnabled })) {
try {
const gsdPath = deps.resolveGsdToolsPath(deps.projectDir);
return await runCjsFallbackDispatch({
projectDir: deps.projectDir,
gsdToolsPath: gsdPath,
normCmd,
normArgs,
ws: deps.ws,
pickField,
});
} catch (e) {
return toDispatchFailure(mapFallbackDispatchError(e, normCmd, normArgs));
}
}
return toDispatchFailure(mapFallbackDispatchError(new Error('CJS fallback denied by policy'), normCmd, normArgs));
}
const matched = plan.matched;
if (!matched) {
return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs));
}
// #3259: guard — if the invocation contains --help / -h AND the matched
// handler is a mutating command (mutation: true in the command manifest),
// short-circuit to a non-mutating stub. Mutating handlers are not help-aware
// by default (fail-closed). This prevents e.g. `milestone.complete --help`
// from writing milestone artifacts to disk.
const helpFlagPresent = matched.args.some((a) => a === '--help' || a === '-h');
if (helpFlagPresent && matched.mutation) {
return dispatchSuccess(
formatSuccess(
{ help: `Usage: gsd-sdk query ${matched.canonical} [args...]` },
undefined,
),
);
}
const dispatchNative = deps.nativeAdapter
? (cmd: string, args: string[]) => deps.nativeAdapter!.dispatch(cmd, args)
: deps.dispatchNative;
try {
const result = dispatchNative
? await dispatchNative(matched.canonical, matched.args)
: await matched.adapter(matched.args, deps.projectDir, deps.ws);
return dispatchSuccess(formatSuccess(result.data, result.format, pickField));
} catch (e) {
return toDispatchFailure(mapNativeDispatchError(e, matched.canonical, matched.args));
}
}
export * from '../dispatch/hub.js';