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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user