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).
244 lines
6.7 KiB
JavaScript
244 lines
6.7 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { routeCjsCommandFamily, routeHubCommandFamily } = require('../gsd-core/bin/lib/cjs-command-router-adapter.cjs');
|
|
const { makeInvalidArgs } = require('../gsd-core/bin/lib/command-routing-hub.cjs');
|
|
|
|
describe('cjs-command-router-adapter routeHubCommandFamily', () => {
|
|
test('routes known subcommand handler through the hub', () => {
|
|
let calls = 0;
|
|
let errorMessage = null;
|
|
|
|
routeHubCommandFamily({
|
|
family: 'unit',
|
|
args: ['unit', 'ok'],
|
|
subcommands: ['ok'],
|
|
handlers: {
|
|
ok: () => {
|
|
calls += 1;
|
|
},
|
|
},
|
|
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(calls, 1);
|
|
assert.equal(errorMessage, null);
|
|
});
|
|
|
|
test('maps unknown subcommands via unknownMessage and filtered availability', () => {
|
|
let errorMessage = null;
|
|
|
|
routeHubCommandFamily({
|
|
family: 'unit',
|
|
args: ['unit', 'missing'],
|
|
subcommands: ['ok', 'legacy'],
|
|
unsupported: { legacy: 'legacy disabled' },
|
|
handlers: { ok: () => {} },
|
|
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(errorMessage, 'Unknown missing. Available: ok');
|
|
});
|
|
|
|
test('returns unsupported subcommand error before dispatch', () => {
|
|
let errorMessage = null;
|
|
|
|
routeHubCommandFamily({
|
|
family: 'unit',
|
|
args: ['unit', 'legacy'],
|
|
subcommands: ['ok', 'legacy'],
|
|
unsupported: { legacy: 'legacy disabled' },
|
|
handlers: { ok: () => {} },
|
|
unknownMessage: () => 'should not be used',
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(errorMessage, 'legacy disabled');
|
|
});
|
|
|
|
test('projects InvalidArgs result reason via error callback', () => {
|
|
let errorMessage = null;
|
|
|
|
routeHubCommandFamily({
|
|
family: 'unit',
|
|
args: ['unit', 'invalid'],
|
|
subcommands: ['invalid'],
|
|
handlers: {
|
|
invalid: () => makeInvalidArgs('--phase', '--phase must be an integer'),
|
|
},
|
|
unknownMessage: () => 'should not be used',
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
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;
|
|
|
|
routeHubCommandFamily({
|
|
family: 'unit',
|
|
args: ['unit', 'boom'],
|
|
subcommands: ['boom'],
|
|
handlers: {
|
|
boom: () => {
|
|
throw new Error('boom');
|
|
},
|
|
},
|
|
unknownMessage: () => 'should not be used',
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(errorMessage, 'boom');
|
|
});
|
|
});
|
|
|
|
describe('cjs-command-router-adapter routeCjsCommandFamily', () => {
|
|
test('routes known subcommand handler via the legacy adapter', () => {
|
|
let calls = 0;
|
|
let errorMessage = null;
|
|
|
|
routeCjsCommandFamily({
|
|
args: ['unit', 'ok'],
|
|
subcommands: ['ok'],
|
|
handlers: {
|
|
ok: () => {
|
|
calls += 1;
|
|
},
|
|
},
|
|
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(calls, 1);
|
|
assert.equal(errorMessage, null);
|
|
});
|
|
|
|
test('honors defaultSubcommand when args[1] is absent', () => {
|
|
let calls = 0;
|
|
let errorMessage = null;
|
|
|
|
routeCjsCommandFamily({
|
|
args: ['unit'],
|
|
subcommands: ['load'],
|
|
defaultSubcommand: 'load',
|
|
handlers: {
|
|
load: () => {
|
|
calls += 1;
|
|
},
|
|
},
|
|
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(calls, 1);
|
|
assert.equal(errorMessage, null);
|
|
});
|
|
|
|
test('converts thrown handler exceptions into error callback messages', () => {
|
|
let errorMessage = null;
|
|
|
|
routeCjsCommandFamily({
|
|
args: ['unit', 'boom'],
|
|
subcommands: ['boom'],
|
|
handlers: {
|
|
boom: () => {
|
|
throw new Error('boom');
|
|
},
|
|
},
|
|
unknownMessage: () => 'should not be used',
|
|
error: (message) => {
|
|
errorMessage = message;
|
|
},
|
|
cwd: '/tmp/proj',
|
|
raw: false,
|
|
});
|
|
|
|
assert.equal(errorMessage, 'boom');
|
|
});
|
|
});
|