File size: 12,819 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
'use strict';
/**
 * Command Routing Hub β€” issue #3788, simplified in #175, typed in #176, observability in #177.
 *
 * A pure-result dispatch hub that centralizes CJS routing,
 * the error taxonomy, and the no-throw contract that all command-family routers
 * currently duplicate independently.
 *
 * Design:
 *   createHub({ cjsRegistry, manifest }) -> hub
 *   hub.dispatch({ family, subcommand, args, cwd, raw })  -> Result
 *
 *   Result = { ok: true, data }
 *           | { ok: false, kind: 'UnknownCommand',  command: string }
 *           | { ok: false, kind: 'InvalidArgs',     arg: string, reason: string }
 *           | { ok: false, kind: 'HandlerRefusal',  reason: string }
 *           | { ok: false, kind: 'HandlerFailure',  message: string, cause?: Error }
 *
 * Invariants:
 *   - Hub always routes through CJS handlers. There is no SDK path (#175).
 *   - Hub never prints to stdout/stderr, never calls process.exit.
 *   - Hub never throws β€” all internal throws are caught and converted to
 *     { ok: false, kind: 'HandlerFailure', message, cause }.
 *   - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
 *   - Each error variant carries ONLY its own typed payload (#176).
 *     No cross-variant `message`/`details` escape hatches.
 *
 * ADR-457 build-at-publish: the hand-written bin/lib/command-routing-hub.cjs collapsed
 * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
 * the prior hand-written .cjs; only types are added.
 */
const event_cjs_1 = require("./observability/event.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const observabilityLogger = require("./observability/logger.cjs");
const { createNoOpLogger } = observabilityLogger;
// ─── Error kind constants ─────────────────────────────────────────────────────
/**
 * Closed error-kind enum. Export as a frozen object so callers can switch on
 * ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
 *
 * #175: SdkLoadFailed and SdkDispatchFailed removed β€” Hub is CJS-only.
 * #176: Field renamed errorKind β†’ kind; payloads are typed per variant.
 *
 * @readonly
 */
const ERROR_KINDS = Object.freeze({
    /** The requested family/subcommand combination is not present in the manifest. */
    UnknownCommand: 'UnknownCommand',
    /** The handler rejected the supplied arguments before executing. */
    InvalidArgs: 'InvalidArgs',
    /** A CJS handler returned an explicit refusal (e.g. unsupported subcommand). */
    HandlerRefusal: 'HandlerRefusal',
    /** A handler threw an unexpected exception. */
    HandlerFailure: 'HandlerFailure',
});
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
 * Safe JSON serialisation that never throws.
 */
function _safeJson(value) {
    try {
        return JSON.stringify(value);
    }
    catch {
        return String(value);
    }
}
// ─── Typed-payload factories (#176) ──────────────────────────────────────────
// Each factory returns a frozen discriminated-union variant for its kind.
// No cross-variant fields bleed between variants.
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
// the variant invariant.
function makeUnknownCommand(command) {
    return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command });
}
function makeInvalidArgs(arg, reason) {
    return Object.freeze({ ok: false, kind: ERROR_KINDS.InvalidArgs, arg, reason });
}
function makeHandlerRefusal(reason) {
    return Object.freeze({ ok: false, kind: ERROR_KINDS.HandlerRefusal, reason });
}
/**
 * @param message  - Human-readable description of the failure.
 * @param cause    - The original thrown Error, when available.
 *   Non-Error values (strings, plain objects, etc.) are wrapped in an Error
 *   with `.thrown` set to the original value. null/undefined β†’ no cause field.
 */
function makeHandlerFailure(message, cause) {
    const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message };
    if (cause != null) {
        if (cause instanceof Error) {
            obj.cause = cause;
        }
        else {
            // Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined
            const wrapper = new Error('non-Error cause: ' + _safeJson(cause));
            wrapper.thrown = cause;
            obj.cause = wrapper;
        }
    }
    return Object.freeze(obj);
}
// ─── Handler-return shape validator (Finding 1) ───────────────────────────────
/**
 * Required payload fields per ok:false kind.
 * `required` β€” fields that MUST be present (non-undefined) for the variant to be valid.
 * `allowed`  β€” the complete set of allowed fields (including ok, kind).
 */
const _VARIANT_SCHEMA = {
    UnknownCommand: {
        required: ['command'],
        allowed: new Set(['ok', 'kind', 'command']),
    },
    InvalidArgs: {
        required: ['arg', 'reason'],
        allowed: new Set(['ok', 'kind', 'arg', 'reason']),
    },
    HandlerRefusal: {
        required: ['reason'],
        allowed: new Set(['ok', 'kind', 'reason']),
    },
    HandlerFailure: {
        required: ['message'],
        allowed: new Set(['ok', 'kind', 'message', 'cause']),
    },
};
/**
 * Validates a handler-returned { ok: false, ... } result against the typed schema.
 *
 * Returns null if valid, or a string describing the contract violation.
 */
function _validateErrResult(result) {
    const { kind } = result;
    const schema = _VARIANT_SCHEMA[kind];
    // Unknown kind β€” not in the closed enum
    if (!schema) {
        return `handler returned unknown kind '${String(kind)}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
    }
    // Missing required fields
    for (const field of schema.required) {
        if (result[field] === undefined) {
            return (`handler returned malformed Result variant: ` +
                `kind '${String(kind)}' requires field '${field}' but it is missing. ` +
                `got: ${_safeJson(result)}`);
        }
    }
    // Extraneous fields outside the typed payload
    for (const key of Object.keys(result)) {
        if (!schema.allowed.has(key)) {
            return (`handler returned malformed Result variant: ` +
                `kind '${String(kind)}' does not allow field '${key}'. ` +
                `expected fields: ${[...schema.allowed].join(', ')}. ` +
                `got: ${_safeJson(result)}`);
        }
    }
    return null; // valid
}
/**
 * Safe stringify for logger-failure warnings β€” avoids circular-ref crashes.
 */
function _safeJsonForWarn(value) {
    try {
        return JSON.stringify(value);
    }
    catch {
        return String(value);
    }
}
/**
 * Construct a CommandRoutingHub.
 */
function createHub({ cjsRegistry, manifest, logger } = {}) {
    const _cjsRegistry = cjsRegistry;
    const _manifest = manifest;
    // Default to no-op so callers that don't inject a logger get pure-silent behaviour.
    // Consumers can opt into the reference impl by importing createDefaultLogger.
    const _logger = (logger && typeof logger.onEvent === 'function')
        ? logger
        : createNoOpLogger();
    /**
     * Normalise a HubResult into the DispatchEvent result shape.
     *
     * HubResult ok path:   { ok: true, data }        β†’ { kind: 'ok', data }
     * HubResult err paths: { ok: false, kind, ...payload } β†’ { kind, ...payload }
     */
    function _normaliseResult(hubResult) {
        if (hubResult.ok) {
            return { kind: 'ok', data: hubResult.data };
        }
        // err variant: already has kind + typed payload
        // Double-cast through unknown to satisfy strict index-signature check.
        return hubResult;
    }
    /**
     * Emit a DispatchEvent to the injected logger.
     * Logger errors NEVER propagate β€” they are caught and emitted as a warn line to stderr.
     */
    function _notifyLogger(command, args, hubResult, parentTraceId) {
        try {
            const eventResult = _normaliseResult(hubResult);
            const event = (0, event_cjs_1.makeDispatchEvent)({ command, args, result: eventResult, parentTraceId });
            _logger.onEvent(event);
        }
        catch (logErr) {
            // Logger must never break dispatch. Emit a degraded warn line.
            try {
                process.stderr.write(_safeJsonForWarn({
                    level: 'warn',
                    source: 'DispatchLogger',
                    message: 'logger.onEvent failed: ' + String(logErr?.message || logErr),
                }) + '\n');
            }
            catch {
                // If even stderr.write fails, swallow silently β€” dispatch result is returned below.
            }
        }
    }
    /**
     * Dispatch a command through the hub.
     */
    function dispatch(req) {
        const { family, subcommand, args = [], parentTraceId } = req || {};
        const command = subcommand ? `${family} ${subcommand}` : String(family);
        let result;
        try {
            result = _dispatch(req);
        }
        catch (err) {
            if (err instanceof Error) {
                result = makeHandlerFailure(err.message, err);
            }
            else {
                // Finding 2: preserve non-Error throwables via a wrapper Error with .thrown
                const wrapper = new Error('non-Error thrown: ' + _safeJson(err));
                wrapper.thrown = err;
                result = makeHandlerFailure(String(err), wrapper);
            }
        }
        _notifyLogger(command, args, result, parentTraceId);
        return result;
    }
    function _dispatch(req) {
        const { family, subcommand, args = [], cwd, raw } = req;
        // ── manifest check ────────────────────────────────────────────────────────
        if (_manifest) {
            const knownSubcommands = _manifest[family];
            if (!knownSubcommands) {
                return makeUnknownCommand(String(family));
            }
            if (subcommand && !knownSubcommands.includes(subcommand)) {
                return makeUnknownCommand(`${family} ${subcommand}`);
            }
        }
        return _dispatchCjs({ family, subcommand, args, cwd, raw });
    }
    function _dispatchCjs({ family, subcommand, args, cwd, raw }) {
        if (!_cjsRegistry) {
            return makeUnknownCommand(String(family));
        }
        const familyHandlers = _cjsRegistry[family];
        if (!familyHandlers) {
            return makeUnknownCommand(String(family));
        }
        const handler = subcommand ? familyHandlers[subcommand] : familyHandlers[''];
        if (typeof handler !== 'function') {
            return makeUnknownCommand(subcommand ? `${family} ${subcommand}` : String(family));
        }
        // Invoke the handler. It must return a HubResult or throw.
        // If it throws, the outer try/catch in dispatch() catches it.
        const result = handler({ family, subcommand, args, cwd, raw });
        // If the handler returned a HubResult, validate ok:false variants against the typed schema.
        if (result && typeof result === 'object' && 'ok' in result) {
            if (!result.ok) {
                // Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure
                const violation = _validateErrResult(result);
                if (violation !== null) {
                    return makeHandlerFailure('handler returned malformed Result variant: ' + violation, 
                    // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-plus-operands
                    new Error('expected ' + (result['kind'] ?? '<no kind>') + ', got ' + _safeJson(result)));
                }
            }
            return result;
        }
        // If the handler returned nothing (undefined), treat as success with no data.
        if (result === undefined || result === null) {
            return { ok: true, data: null };
        }
        // Any other return value is treated as the data payload.
        return { ok: true, data: result };
    }
    return { dispatch };
}
module.exports = {
    createHub,
    ERROR_KINDS,
    makeUnknownCommand,
    makeInvalidArgs,
    makeHandlerRefusal,
    makeHandlerFailure,
};