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:
@@ -93,6 +93,58 @@ describe('cjs-command-router-adapter routeHubCommandFamily', () => {
|
||||
assert.equal(errorMessage, '--phase must be an integer');
|
||||
});
|
||||
|
||||
test('projects InvalidArgs exitReason as second error() arg when present (#1644)', () => {
|
||||
let capturedMessage = null;
|
||||
let capturedExitReason = null;
|
||||
let callCount = 0;
|
||||
|
||||
routeHubCommandFamily({
|
||||
family: 'unit',
|
||||
args: ['unit', 'invalid'],
|
||||
subcommands: ['invalid'],
|
||||
handlers: {
|
||||
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer', 'USAGE'),
|
||||
},
|
||||
unknownMessage: () => 'should not be used',
|
||||
error: (message, exitReason) => {
|
||||
callCount += 1;
|
||||
capturedMessage = message;
|
||||
capturedExitReason = exitReason;
|
||||
},
|
||||
cwd: '/tmp/proj',
|
||||
raw: false,
|
||||
});
|
||||
|
||||
assert.equal(callCount, 1);
|
||||
assert.equal(capturedMessage, '--phase must be an integer',
|
||||
`error() message must be the InvalidArgs.reason; got: ${JSON.stringify(capturedMessage)}`);
|
||||
assert.equal(capturedExitReason, 'USAGE',
|
||||
`error() exitReason must be passed as second arg; got: ${JSON.stringify(capturedExitReason)}`);
|
||||
});
|
||||
|
||||
test('omits second error() arg when InvalidArgs has no exitReason (byte-identical with prior behavior)', () => {
|
||||
let capturedArgs = null;
|
||||
|
||||
routeHubCommandFamily({
|
||||
family: 'unit',
|
||||
args: ['unit', 'invalid'],
|
||||
subcommands: ['invalid'],
|
||||
handlers: {
|
||||
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer'),
|
||||
},
|
||||
unknownMessage: () => 'should not be used',
|
||||
error: (...args) => {
|
||||
capturedArgs = args;
|
||||
},
|
||||
cwd: '/tmp/proj',
|
||||
raw: false,
|
||||
});
|
||||
|
||||
assert.equal(capturedArgs.length, 1,
|
||||
`error() must be called with EXACTLY one arg when exitReason absent (preserve byte-identical prior behavior); got ${capturedArgs.length} args`);
|
||||
assert.equal(capturedArgs[0], '--phase must be an integer');
|
||||
});
|
||||
|
||||
test('projects thrown handler exceptions as HandlerFailure message', () => {
|
||||
let errorMessage = null;
|
||||
|
||||
|
||||
@@ -836,3 +836,116 @@ describe('CommandRoutingHub — Finding 4: makeHandlerFailure wraps non-Error ca
|
||||
assert.equal(result.cause, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Amendment #1642: exitReason? field on InvalidArgs (Phase 1, #1644) ───────
|
||||
// The optional exitReason? field carries an ERROR_REASON enum value separately
|
||||
// from the existing `reason` explanation text. The factory conditionally adds
|
||||
// the field only when a truthy third arg is provided, preserving the strict-keys
|
||||
// invariant tested above (L444).
|
||||
|
||||
describe('CommandRoutingHub — exitReason? field on InvalidArgs (#1644 / amendment #1642)', () => {
|
||||
test('makeInvalidArgs(arg, reason) 2-arg form omits exitReason key (strict-keys invariant preserved)', () => {
|
||||
const result = makeInvalidArgs('--phase', '--phase must be an integer');
|
||||
const keys = Object.keys(result).sort();
|
||||
assert.deepStrictEqual(keys, ['arg', 'kind', 'ok', 'reason'],
|
||||
`2-arg form must NOT include exitReason key; got: ${JSON.stringify(keys)}`);
|
||||
assert.equal(result.exitReason, undefined);
|
||||
});
|
||||
|
||||
test('makeInvalidArgs(arg, reason, exitReason) 3-arg form includes exitReason key with the value', () => {
|
||||
const result = makeInvalidArgs('--phase', '--phase must be an integer', 'USAGE');
|
||||
const keys = Object.keys(result).sort();
|
||||
assert.deepStrictEqual(keys, ['arg', 'exitReason', 'kind', 'ok', 'reason'],
|
||||
`3-arg form must include exitReason key; got: ${JSON.stringify(keys)}`);
|
||||
assert.equal(result.exitReason, 'USAGE');
|
||||
});
|
||||
|
||||
test('makeInvalidArgs(arg, reason, undefined) treats undefined as absent (omits key)', () => {
|
||||
const result = makeInvalidArgs('--phase', '--phase must be an integer', undefined);
|
||||
const keys = Object.keys(result).sort();
|
||||
assert.deepStrictEqual(keys, ['arg', 'kind', 'ok', 'reason'],
|
||||
`undefined exitReason must be omitted; got: ${JSON.stringify(keys)}`);
|
||||
});
|
||||
|
||||
test('makeInvalidArgs(arg, reason, "") treats empty string as absent (omits key)', () => {
|
||||
const result = makeInvalidArgs('--phase', '--phase must be an integer', '');
|
||||
const keys = Object.keys(result).sort();
|
||||
assert.deepStrictEqual(keys, ['arg', 'kind', 'ok', 'reason'],
|
||||
`empty-string exitReason must be omitted; got: ${JSON.stringify(keys)}`);
|
||||
});
|
||||
|
||||
test('3-arg factory result is still frozen', () => {
|
||||
const result = makeInvalidArgs('--phase', 'required', 'USAGE');
|
||||
assert.ok(Object.isFrozen(result), '3-arg factory result must be frozen');
|
||||
});
|
||||
|
||||
test('hub.dispatch propagates handler-returned InvalidArgs with exitReason unchanged', () => {
|
||||
const hub = createHub({
|
||||
cjsRegistry: {
|
||||
unit: {
|
||||
check: (_ctx) => ({
|
||||
ok: false,
|
||||
kind: ERROR_KINDS.InvalidArgs,
|
||||
arg: '--flag',
|
||||
reason: 'not supported',
|
||||
exitReason: 'USAGE',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = hub.dispatch({ family: 'unit', subcommand: 'check', args: [], cwd: '/', raw: false });
|
||||
|
||||
assert.ok(!result.ok);
|
||||
assert.equal(result.kind, ERROR_KINDS.InvalidArgs);
|
||||
assert.equal(result.arg, '--flag');
|
||||
assert.equal(result.reason, 'not supported');
|
||||
assert.equal(result.exitReason, 'USAGE',
|
||||
`Hub must propagate exitReason from handler-returned InvalidArgs; got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('hub.dispatch still accepts InvalidArgs WITHOUT exitReason (no contract regression)', () => {
|
||||
const hub = createHub({
|
||||
cjsRegistry: {
|
||||
unit: {
|
||||
check: (_ctx) => ({
|
||||
ok: false,
|
||||
kind: ERROR_KINDS.InvalidArgs,
|
||||
arg: '--flag',
|
||||
reason: 'not supported',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = hub.dispatch({ family: 'unit', subcommand: 'check', args: [], cwd: '/', raw: false });
|
||||
|
||||
assert.ok(!result.ok);
|
||||
assert.equal(result.kind, ERROR_KINDS.InvalidArgs);
|
||||
assert.equal(result.exitReason, undefined,
|
||||
`Hub must not synthesize exitReason when handler omits it; got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('hub validator does NOT reject InvalidArgs with exitReason (well-formed extension)', () => {
|
||||
// The runtime validator (_validateErrResult) coerces MALFORMED returns to HandlerFailure.
|
||||
// A well-formed InvalidArgs with the new exitReason field must NOT be coerced.
|
||||
const hub = createHub({
|
||||
cjsRegistry: {
|
||||
unit: {
|
||||
check: (_ctx) => ({
|
||||
ok: false,
|
||||
kind: ERROR_KINDS.InvalidArgs,
|
||||
arg: '--flag',
|
||||
reason: 'required',
|
||||
exitReason: 'USAGE',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = hub.dispatch({ family: 'unit', subcommand: 'check', args: [], cwd: '/', raw: false });
|
||||
|
||||
assert.equal(result.kind, ERROR_KINDS.InvalidArgs,
|
||||
`Extended InvalidArgs must not be coerced to HandlerFailure; got kind: ${result.kind}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user