refactor(#1644): Hub extension — exitReason? field on InvalidArgs + adapter honestification (#1645)

Phase 1 of parent #1641. Implements the contract documented in the
Phase 0 ADR-0174 §5 amendment (#1642 / #1643).

src/command-routing-hub.cts
  * InvalidArgsResult interface gains optional exitReason?: string
    (carries an ERROR_REASON enum value, separate from reason which is
    the explanation text).
  * makeInvalidArgs(arg, reason, exitReason?) factory conditionally adds
    the field only when the third arg is truthy — preserves the strict-
    keys invariant tested at command-routing-hub.test.cjs:444.
  * _VARIANT_SCHEMA.InvalidArgs.allowed Set extended to include
    'exitReason' so the runtime validator does not coerce well-formed
    extended Results to HandlerFailure.

src/cjs-command-router-adapter.cts
  * Honestified the wrapper comment: the runtime check ('ok' in result)
    already passes any {ok:*} object through, so the historical
    {ok:true, data} return type was a lie for err Results. The lying
    cast is preserved because the Hub's export = syntax doesn't expose
    HubResult for import; the Hub's _validateErrResult runtime-validates
    the actual shape.
  * Result→error() translation branched: when InvalidArgs carries
    exitReason, the adapter calls error(result.reason, result.exitReason)
    so the JSON-error envelope (GSD_JSON_ERRORS=1) preserves the typed
    ERROR_REASON value. When exitReason is absent, error(msg) is called
    with exactly one arg — byte-identical with prior behavior.
  * RouteCjsCommandFamilyOptions.error and RouteHubCommandFamilyOptions
    .error callback types widened from (message) to (message, reason?)
    to match io.cts's actual error() signature.

CONTEXT.md
  * Command Routing Hub predicate updated to document the new field,
    factory signature, and dispatcher translation contract.

Tests (TDD red→green)
  * tests/command-routing-hub.test.cjs: 8 new tests covering 2-arg
    (strict-keys), 3-arg (key present), undefined, empty string, frozen
    result, hub.dispatch propagation, and validator acceptance.
  * tests/cjs-command-router-adapter.test.cjs: 2 new tests covering
    exitReason passed as second arg + byte-identical prior behavior when
    absent.

Verification
  * npm run test:unit: 2448 tests, 0 fail (no regressions)
  * gsd-test-summary on docker: outcome=passed, 0 failures
    (RULESET.PR-FLOW.docker-before-push)

Memtrace blast radius: LOW (get_impact makeInvalidArgs → 3 nodes; the
optional field is non-breaking for the 1 existing caller routePhaseCommand).
This commit is contained in:
Tom Boucher
2026-06-23 22:47:57 -04:00
committed by GitHub
parent bcc5a6d1ba
commit 6214039358
5 changed files with 219 additions and 10 deletions

View File

@@ -25,7 +25,10 @@ interface RouteCjsCommandFamilyOptions {
defaultSubcommand?: string;
unsupported?: Record<string, string>;
unknownMessage: (subcommand: string, available: string[]) => string;
error: (message: string) => void;
// Amendment #1642 (#1644 Phase 1): widened to accept optional ERROR_REASON
// enum value as second arg. io.cts's error() already accepts (msg, reason?);
// the prior one-arg signature was narrower than the runtime contract.
error: (message: string, reason?: string) => void;
cwd?: string;
raw?: boolean;
}
@@ -38,7 +41,7 @@ interface RouteHubCommandFamilyOptions {
defaultSubcommand?: string;
unsupported?: Record<string, string>;
unknownMessage: (subcommand: string, available: string[]) => string;
error: (message: string) => void;
error: (message: string, reason?: string) => void;
cwd?: string;
raw?: boolean;
}
@@ -100,6 +103,18 @@ function routeHubCommandFamily({
const registryHandlers = Object.fromEntries(
Object.entries(handlers).map(([name, handler]) => [
name,
// Honestified via amendment #1642 (#1644 Phase 1): the runtime check
// `'ok' in result` already passes any `{ok:*}` object through, so the
// historical `{ok:true, data}` return type was a lie whenever the
// handler returned an err Result. The lying cast below is preserved
// because the Hub's `export =` syntax doesn't expose `HubResult` for
// import; the Hub's `_validateErrResult` runtime-validates the actual
// shape, so structural compatibility is sufficient. The wrapper's
// 0-arg signature is assignable to the Hub's `(ctx) => HubResult`
// Handler type via TypeScript parameter bivariance; the Hub's per-call
// ctx is intentionally ignored (host-router handlers don't use it;
// capability-router handlers in Phase 2 will return HubResults that
// already carry context).
(): { ok: true; data: unknown } => {
const result = handler();
if (result && typeof result === 'object' && Object.prototype.hasOwnProperty.call(result, 'ok')) {
@@ -128,7 +143,21 @@ function routeHubCommandFamily({
error(unknownMessage(subcommand ?? '', available));
return;
}
if (result.kind === ERROR_KINDS.InvalidArgs || result.kind === ERROR_KINDS.HandlerRefusal) {
if (result.kind === ERROR_KINDS.InvalidArgs) {
// Amendment #1642 (#1644): when the handler provided exitReason, pass it
// as the second arg to error() so the JSON-error envelope
// (GSD_JSON_ERRORS=1) preserves the typed ERROR_REASON value for
// downstream consumers. When exitReason is absent, call error(msg) with
// exactly one arg — byte-identical with prior behavior.
const invalidArgs = result as { reason: string; exitReason?: string };
if (invalidArgs.exitReason) {
error(invalidArgs.reason, invalidArgs.exitReason);
} else {
error(invalidArgs.reason);
}
return;
}
if (result.kind === ERROR_KINDS.HandlerRefusal) {
error((result as { reason: string }).reason);
return;
}

View File

@@ -76,6 +76,14 @@ interface InvalidArgsResult {
kind: 'InvalidArgs';
arg: string;
reason: string;
// Optional ERROR_REASON enum value (e.g. 'USAGE'), carried separately from
// `reason` (the human-readable explanation). Added by amendment #1642 so
// routers migrating from direct `error(msg, ERROR_REASON.USAGE)` calls to
// `makeInvalidArgs(...)` Results can preserve ERROR_REASON granularity
// through the Hub Result → `error(msg, exitReason)` translation. Omitted by
// the factory when the third arg is absent, undefined, or empty string —
// preserves the strict-keys invariant tested at command-routing-hub.test.cjs:444.
exitReason?: string;
}
interface HandlerRefusalResult {
@@ -117,8 +125,14 @@ function makeUnknownCommand(command: string): Readonly<UnknownCommandResult> {
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.UnknownCommand, command });
}
function makeInvalidArgs(arg: string, reason: string): Readonly<InvalidArgsResult> {
return Object.freeze({ ok: false as const, kind: ERROR_KINDS.InvalidArgs, arg, reason });
function makeInvalidArgs(arg: string, reason: string, exitReason?: string): Readonly<InvalidArgsResult> {
const obj: InvalidArgsResult = { ok: false as const, kind: ERROR_KINDS.InvalidArgs, arg, reason };
// Conditionally add exitReason only when truthy — preserves strict-keys
// invariant (2-arg callers must continue to produce a 4-key frozen result).
if (exitReason) {
obj.exitReason = exitReason;
}
return Object.freeze(obj);
}
function makeHandlerRefusal(reason: string): Readonly<HandlerRefusalResult> {
@@ -163,10 +177,11 @@ const _VARIANT_SCHEMA: Record<string, { required: string[]; allowed: Set<string>
required: ['command'],
allowed: new Set(['ok', 'kind', 'command']),
},
InvalidArgs: {
required: ['arg', 'reason'],
allowed: new Set(['ok', 'kind', 'arg', 'reason']),
},
InvalidArgs: {
required: ['arg', 'reason'],
// Amendment #1642: exitReason? is allowed but not required.
allowed: new Set(['ok', 'kind', 'arg', 'reason', 'exitReason']),
},
HandlerRefusal: {
required: ['reason'],
allowed: new Set(['ok', 'kind', 'reason']),