mandrel 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +6 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  4. package/.agents/scripts/deliver-light.js +23 -45
  5. package/.agents/scripts/diagnose-friction.js +95 -4
  6. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  7. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  8. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  9. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  10. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  11. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  12. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  13. package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
  14. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  15. package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
  16. package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
  17. package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
  18. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  19. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  20. package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
  23. package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
  24. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  25. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  26. package/.agents/workflows/helpers/deliver-light.md +21 -4
  27. package/.agents/workflows/helpers/plan-reference.md +40 -0
  28. package/.agents/workflows/plan.md +21 -16
  29. package/docs/CHANGELOG.md +23 -0
  30. package/package.json +1 -1
@@ -0,0 +1,360 @@
1
+ /**
2
+ * escomplex-ast-compat.js — reconcile the `typhonjs-escomplex` code generator
3
+ * with the Babel AST that `typhonjs-escomplex`'s own default parser emits.
4
+ *
5
+ * ## The upstream defect
6
+ *
7
+ * `typhonjs-escomplex` parses with `@typhonjs/babel-parser`, so every AST it
8
+ * analyses is a **Babel** AST. But `typhonjs-escomplex-commons`'
9
+ * `utils/ast/astSyntax.js` — the code generator that `ASTGenerator` drives —
10
+ * was written against **ESTree**. The two disagree on node names
11
+ * (`OptionalMemberExpression` vs a `MemberExpression` with `optional: true`)
12
+ * and on node shapes (Babel's `RegExpLiteral` carries `pattern`/`flags`
13
+ * directly; ESTree wraps them in a `regex` object on a `Literal`).
14
+ *
15
+ * That mismatch is invisible for most code because the metric traversal runs
16
+ * off the syntax plugin's trait tables, not off `astSyntax`. `astSyntax` is
17
+ * only reached where a trait re-serialises a **sub-AST** to synthesise a
18
+ * Halstead operand or a function signature — nine traits do this:
19
+ * `For`/`ForIn`/`ForOf` heads, `Function`/`FunctionExpression`/
20
+ * `ArrowFunctionExpression` parameter lists, `Class`/`ClassExpression`
21
+ * bodies, and `YieldExpression` arguments.
22
+ *
23
+ * Reach one of those with a Babel-only node and `analyzeModule()` throws —
24
+ * aborting the whole module, not just the sub-expression. The constructs that
25
+ * trip it are ordinary modern JavaScript:
26
+ *
27
+ * ```js
28
+ * for (const t of s.split(/[^a-z]+/)) {} // TypeError: …reading 'pattern'
29
+ * for (const t of await xs()) {} // …generator[node.type] is not a function
30
+ * for (const t of a?.b) {} // …generator[node.type] is not a function
31
+ * function f(a = () => import('x')) {} // …this[node.callee.type] is not a function
32
+ * function f(a = { ...b }) {} // TypeError: …reading 'type'
33
+ * function f(a = { m() {} }) {} // TypeError: …reading 'generator'
34
+ * function f(a = class { m() {} }) {} // …this[statement.type] is not a function
35
+ * ```
36
+ *
37
+ * The `{ ...b }` case is upstream issue #24, open and untouched since
38
+ * 2020-12-16 with a byte-identical stack trace. The package last published in
39
+ * June 2022 and the repo that holds the defective file has issues *disabled*,
40
+ * so waiting for an upstream release is not a plan. Consumers of this kernel
41
+ * previously absorbed the breakage as an allowlist of "unscorable" files that
42
+ * grew every time someone wrote a `?.` in a loop head.
43
+ *
44
+ * ## What this module does
45
+ *
46
+ * Installs the missing handlers and repairs the two shape assumptions, on the
47
+ * shared `astSyntax` table, once per process. Every patch is **conditional**:
48
+ * a handler is only installed where the table lacks one, and the two repairs
49
+ * wrap the original rather than replacing its behaviour. If a future kernel
50
+ * bump fixes any of this upstream, the corresponding patch silently stops
51
+ * applying and `install()` reports a shorter list — which
52
+ * `tests/lib/escomplex-ast-compat.test.js` asserts on, so the shrinkage is
53
+ * visible rather than silent.
54
+ *
55
+ * Nothing here changes the score of a file that already parses: the patched
56
+ * paths are exactly the paths that previously threw.
57
+ *
58
+ * @see https://github.com/typhonjs-node-escomplex/typhonjs-escomplex/issues/24
59
+ */
60
+
61
+ import { createRequire } from 'node:module';
62
+
63
+ const require = createRequire(import.meta.url);
64
+
65
+ /** Marker set on every function this module installs, for idempotency. */
66
+ const PATCH_MARKER = Symbol.for('mandrel.escomplexAstCompat');
67
+
68
+ /** Memoised result of the one-time install. */
69
+ let installResult = null;
70
+
71
+ /**
72
+ * Resolve the shared `astSyntax` generator table.
73
+ *
74
+ * This is a deep import into a transitive dependency's `dist/`, which is
75
+ * exactly as fragile as it looks — hence the soft failure. If upstream ever
76
+ * restructures, `install()` reports `available: false` and the engine falls
77
+ * back to today's behaviour (unscorable files, now reported explicitly by
78
+ * the engine rather than silently scored 0).
79
+ *
80
+ * The patch must land on the *same* `typhonjs-escomplex-commons` instance the
81
+ * kernel loads, so `commons` is resolved **through `typhonjs-escomplex`'s own
82
+ * resolution** rather than from here. Resolving it directly would be a coin
83
+ * flip: under a hoisting installer it usually finds the same copy, but under
84
+ * pnpm's isolated layout — or as soon as anything declares `commons` directly —
85
+ * it can find a *different* physical copy, and the patch then lands on a table
86
+ * nobody reads while `install()` cheerfully reports success. Anchoring makes
87
+ * that failure mode unreachable.
88
+ *
89
+ * `requireFn` is the test seam: a cross-checkout verification harness passes
90
+ * its own `createRequire` so the anchor starts from that checkout's escomplex.
91
+ *
92
+ * @param {NodeJS.Require} [requireFn]
93
+ * @returns {Record<string, Function>|null}
94
+ */
95
+ function resolveSyntaxTable(requireFn = require) {
96
+ try {
97
+ const fromKernel = createRequire(requireFn.resolve('typhonjs-escomplex'));
98
+ const mod = fromKernel(
99
+ 'typhonjs-escomplex-commons/dist/utils/ast/astSyntax.js',
100
+ );
101
+ const table = mod?.default ?? mod;
102
+ if (!table || typeof table !== 'object') return null;
103
+ // Sanity-check that this is the table we think it is before mutating it.
104
+ if (typeof table.MemberExpression !== 'function') return null;
105
+ if (Object.isFrozen(table)) return null;
106
+ return table;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Tag a handler as ours so a second `install()` is a no-op.
114
+ *
115
+ * @template {Function} F
116
+ * @param {F} fn
117
+ * @returns {F}
118
+ */
119
+ function mark(fn) {
120
+ fn[PATCH_MARKER] = true;
121
+ return fn;
122
+ }
123
+
124
+ /**
125
+ * Install `name` only if the table has no handler for it. Returns whether the
126
+ * install happened, so the caller can report the applied surface.
127
+ *
128
+ * @param {Record<string, Function>} table
129
+ * @param {string} name
130
+ * @param {Function} handler
131
+ * @returns {boolean}
132
+ */
133
+ function addMissing(table, name, handler) {
134
+ if (typeof table[name] === 'function') return false;
135
+ table[name] = mark(handler);
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Build an ESTree-shaped stand-in for a Babel `ObjectMethod` / `ClassMethod`,
141
+ * whose function bits sit on the node itself rather than under `value`.
142
+ * `MethodDefinition` reads `node.value.{generator,params,body}`, so handing it
143
+ * a synthesized `value` lets the original handler do the work unchanged.
144
+ *
145
+ * @param {object} node
146
+ * @returns {object}
147
+ */
148
+ function asMethodDefinition(node) {
149
+ return {
150
+ ...node,
151
+ kind:
152
+ typeof node.kind === 'string' && node.kind.length > 0
153
+ ? node.kind
154
+ : 'init',
155
+ value: {
156
+ type: 'FunctionExpression',
157
+ generator: Boolean(node.generator),
158
+ async: Boolean(node.async),
159
+ params: node.params ?? [],
160
+ body: node.body,
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Patch the shared `astSyntax` table. Idempotent; safe to call from every
167
+ * entry point that is about to run `analyzeModule`.
168
+ *
169
+ * @param {{ requireFn?: NodeJS.Require, memoise?: boolean }} [options] Test
170
+ * seam only — see {@link resolveSyntaxTable}. Production callers pass
171
+ * nothing.
172
+ * @returns {{ available: boolean, applied: string[] }} `available` is false
173
+ * when the upstream internals could not be resolved. `applied` names each
174
+ * patch that was actually needed, so an upstream fix shows up as a
175
+ * shorter list.
176
+ */
177
+ export function install(options = {}) {
178
+ const { requireFn, memoise = true } = options;
179
+ if (memoise && installResult !== null) return installResult;
180
+
181
+ const table = resolveSyntaxTable(requireFn);
182
+ if (table === null) {
183
+ const unavailable = { available: false, applied: [] };
184
+ if (memoise) installResult = unavailable;
185
+ return unavailable;
186
+ }
187
+
188
+ const applied = [];
189
+ const add = (name, handler) => {
190
+ if (addMissing(table, name, handler)) applied.push(name);
191
+ };
192
+
193
+ // ---- Repair 1: Babel's RegExpLiteral shape -------------------------------
194
+ // Upstream reads `node.regex.pattern`, which only exists on an ESTree
195
+ // `Literal`. Babel puts `pattern`/`flags` on the node. Wrapped rather than
196
+ // replaced so the ESTree delegate path (`Literal` → `RegExpLiteral`) keeps
197
+ // its exact original output.
198
+ if (
199
+ typeof table.RegExpLiteral === 'function' &&
200
+ !table.RegExpLiteral[PATCH_MARKER]
201
+ ) {
202
+ const original = table.RegExpLiteral;
203
+ table.RegExpLiteral = mark(function RegExpLiteral(node, state) {
204
+ if (node?.regex === undefined) {
205
+ state.output.write(
206
+ `new RegExp(${JSON.stringify(node?.pattern ?? '')}, ` +
207
+ `${JSON.stringify(node?.flags ?? '')})`,
208
+ );
209
+ return;
210
+ }
211
+ return original.call(this, node, state);
212
+ });
213
+ applied.push('RegExpLiteral');
214
+ }
215
+
216
+ // ---- Repair 2: ObjectExpression's hard `this.Property()` dispatch --------
217
+ // `ObjectExpression` calls `this.Property(el)` on every element regardless
218
+ // of the element's actual type. Two Babel shapes get mis-routed by that:
219
+ //
220
+ // `{ ...b }` → a `SpreadElement` lands in `Property`, which reads
221
+ // `node.key.type` and throws. Upstream issue #24.
222
+ // `{ m() {} }` → an `ObjectMethod` has `kind: 'method'`, so `Property`'s
223
+ // `node.kind[0] !== 'i'` test sends the raw Babel node to
224
+ // `MethodDefinition`, which reads `node.value.generator`
225
+ // and throws.
226
+ //
227
+ // Patching `Property` rather than `ObjectExpression` keeps the patch small
228
+ // and leaves the indentation-sensitive object serialisation untouched:
229
+ // anything arriving here that is not actually a property node gets
230
+ // re-dispatched on its real type.
231
+ //
232
+ // `Property` and `ObjectProperty` are excluded from re-dispatch because the
233
+ // table aliases `ObjectProperty` to `Property` — re-dispatching either would
234
+ // recurse into this wrapper forever.
235
+ if (typeof table.Property === 'function' && !table.Property[PATCH_MARKER]) {
236
+ const original = table.Property;
237
+ const PROPERTY_TYPES = new Set(['Property', 'ObjectProperty']);
238
+ table.Property = mark(function Property(node, state) {
239
+ const type = node?.type;
240
+ if (
241
+ typeof type === 'string' &&
242
+ !PROPERTY_TYPES.has(type) &&
243
+ typeof this[type] === 'function'
244
+ ) {
245
+ return this[type](node, state);
246
+ }
247
+ return original.call(this, node, state);
248
+ });
249
+ applied.push('Property');
250
+ }
251
+
252
+ // ---- Missing handlers: Babel-only node types ----------------------------
253
+
254
+ // `await x` — mirrors upstream's `YieldExpression`.
255
+ add('AwaitExpression', function AwaitExpression(node, state) {
256
+ const output = state.output;
257
+ output.write('await ');
258
+ output.operators.push('await');
259
+ if (node.argument) this[node.argument.type](node.argument, state);
260
+ });
261
+
262
+ // `a?.b` / `a?.[b]` — mirrors upstream's `MemberExpression`, with `?.`
263
+ // recorded as its own operator so optional access is not counted as plain
264
+ // member access.
265
+ add(
266
+ 'OptionalMemberExpression',
267
+ function OptionalMemberExpression(node, state) {
268
+ const output = state.output;
269
+ this[node.object.type](node.object, state);
270
+ if (node.computed) {
271
+ output.write('?.[');
272
+ this[node.property.type](node.property, state);
273
+ output.write(']');
274
+ output.operators.push('?.[]');
275
+ } else {
276
+ output.write('?.');
277
+ output.operators.push('?.');
278
+ this[node.property.type](node.property, state);
279
+ }
280
+ },
281
+ );
282
+
283
+ // `a?.()` — mirrors upstream's `CallExpression`.
284
+ add('OptionalCallExpression', function OptionalCallExpression(node, state) {
285
+ this[node.callee.type](node.callee, state);
286
+ state.output.write('?.');
287
+ state.output.operators.push('?.()');
288
+ ASTUtil().formatSequence(node.arguments ?? [], state, this);
289
+ });
290
+
291
+ // The callee node of a dynamic `import(...)`. Babel models the `import`
292
+ // keyword as its own node type; ESTree has no equivalent.
293
+ add('Import', function Import(_node, state) {
294
+ state.output.write('import');
295
+ state.output.operators.push('import()');
296
+ });
297
+
298
+ // `{ m() {} }` / `{ get m() {} }` — Babel's ObjectMethod.
299
+ add('ObjectMethod', function ObjectMethod(node, state) {
300
+ return this.MethodDefinition(asMethodDefinition(node), state);
301
+ });
302
+
303
+ // `class { m() {} }` — Babel's ClassMethod (ESTree: MethodDefinition).
304
+ add('ClassMethod', function ClassMethod(node, state) {
305
+ return this.MethodDefinition(asMethodDefinition(node), state);
306
+ });
307
+
308
+ // `class { p = 1 }` — Babel's ClassProperty (ESTree: PropertyDefinition).
309
+ const classProperty = function ClassProperty(node, state) {
310
+ const output = state.output;
311
+ if (node.static) {
312
+ output.write('static ');
313
+ output.operators.push('static');
314
+ }
315
+ if (node.computed) {
316
+ output.write('[');
317
+ this[node.key.type](node.key, state);
318
+ output.write(']');
319
+ } else {
320
+ this[node.key.type](node.key, state);
321
+ }
322
+ if (node.value) {
323
+ output.write(' = ');
324
+ output.operators.push('=');
325
+ this[node.value.type](node.value, state);
326
+ }
327
+ output.write(';');
328
+ };
329
+ add('ClassProperty', classProperty);
330
+ add('PropertyDefinition', classProperty);
331
+
332
+ const result = { available: true, applied };
333
+ if (memoise) installResult = result;
334
+ return result;
335
+ }
336
+
337
+ /**
338
+ * `ASTUtil` is only needed by the `OptionalCallExpression` handler, and only at
339
+ * call time — resolving it lazily keeps `install()` free of a second deep
340
+ * import that could fail at module load. Anchored through the kernel for the
341
+ * same reason as {@link resolveSyntaxTable}.
342
+ *
343
+ * `formatSequence` is a pure helper that takes the traveler and state as
344
+ * arguments, so which copy answers is immaterial — but resolving it the same
345
+ * way keeps one rule in this file rather than two.
346
+ *
347
+ * @returns {{ formatSequence: Function }}
348
+ */
349
+ function ASTUtil() {
350
+ const fromKernel = createRequire(require.resolve('typhonjs-escomplex'));
351
+ const mod = fromKernel(
352
+ 'typhonjs-escomplex-commons/dist/utils/ast/ASTUtil.js',
353
+ );
354
+ return mod?.default ?? mod;
355
+ }
356
+
357
+ // A test that needs to re-run the install against the already-patched table
358
+ // passes `{ memoise: false }` rather than resetting module state — the patches
359
+ // are self-detecting via PATCH_MARKER, so a non-memoised re-run is exactly the
360
+ // idempotency assertion worth making.
@@ -1,25 +1,80 @@
1
1
  import fs from 'node:fs';
2
2
  import escomplex from 'typhonjs-escomplex';
3
+ import { install as installAstCompat } from './escomplex-ast-compat.js';
3
4
  import { transpileIfNeeded } from './transpile.js';
4
5
 
5
6
  /**
6
7
  * Calculates the maintainability score of a JavaScript source file or string.
7
8
  * Uses `typhonjs-escomplex` internally, which provides a maintainability index
8
9
  * based on the Halstead Volume, Cyclomatic Complexity, and Lines of Code.
10
+ *
11
+ * The kernel's code generator predates the Babel AST its own parser emits, so
12
+ * ordinary modern syntax (`?.`, `await` in a loop head, a regex in a loop
13
+ * head, object spread in a default parameter) aborts the whole analysis.
14
+ * `escomplex-ast-compat` repairs that before any scoring runs — see that
15
+ * module for the defect and the upstream status.
16
+ */
17
+ installAstCompat();
18
+
19
+ /**
20
+ * Sentinel score for a file the kernel cannot analyse.
21
+ *
22
+ * A real maintainability index never reaches 0 for runnable code — the
23
+ * escomplex floor is ~10–20 — so 0 has long been used as an out-of-band
24
+ * "unscorable" marker. That overload is the bug: consumers drop `mi === 0`
25
+ * rows, so an unscorable file silently vanishes from the baseline instead of
26
+ * being reported, and no amount of re-seeding can ever give it a row.
27
+ *
28
+ * Deliberately module-private. The numeric return is kept for backwards
29
+ * compatibility, but the *value* is not something a caller should branch on —
30
+ * that is the overload this change exists to stop propagating. Callers that
31
+ * need to tell "unscorable" from "genuinely terrible" read the `unscorable`
32
+ * flag from {@link scoreSource} / {@link scoreFile}.
33
+ */
34
+ const UNSCORABLE = 0;
35
+
36
+ /**
37
+ * Score a raw string, distinguishing "the kernel could not analyse this" from
38
+ * "this scored badly".
39
+ *
40
+ * @param {string} sourceCode The JavaScript source code.
41
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
42
+ * `score` is {@link UNSCORABLE} when `unscorable` is true; `reason` carries
43
+ * the kernel's own error message so a consumer can report *why* rather than
44
+ * just omitting the file.
45
+ */
46
+ export function scoreSource(sourceCode) {
47
+ try {
48
+ const score = escomplex.analyzeModule(sourceCode)?.maintainability;
49
+ return Number.isFinite(score)
50
+ ? { score, unscorable: false, reason: null }
51
+ : unscorable(`kernel returned a non-finite index (${String(score)})`);
52
+ } catch (err) {
53
+ return unscorable(
54
+ `${err?.constructor?.name ?? 'Error'}: ${err?.message ?? 'unknown kernel failure'}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} reason
61
+ * @returns {{ score: number, unscorable: boolean, reason: string }}
9
62
  */
63
+ function unscorable(reason) {
64
+ return { score: UNSCORABLE, unscorable: true, reason };
65
+ }
66
+
10
67
  /**
11
68
  * Calculate score for a raw string of source code.
69
+ *
70
+ * Returns 0 for unscorable input, which is ambiguous by construction — see
71
+ * {@link UNSCORABLE}. Prefer {@link scoreSource} in new code.
72
+ *
12
73
  * @param {string} sourceCode The JavaScript source code.
13
74
  * @returns {number} Score between 0 and 171. Higher is better.
14
75
  */
15
76
  export function calculateForSource(sourceCode) {
16
- try {
17
- const result = escomplex.analyzeModule(sourceCode);
18
- return result.maintainability;
19
- } catch (_err) {
20
- // Return 0 if the parser fails (e.g. invalid syntax)
21
- return 0;
22
- }
77
+ return scoreSource(sourceCode).score;
23
78
  }
24
79
 
25
80
  /**
@@ -34,17 +89,34 @@ export function calculateForSource(sourceCode) {
34
89
  * be parsed (escomplex parse error or TS transpile failure).
35
90
  */
36
91
  export function calculateForFile(filePath) {
92
+ return scoreFile(filePath).score;
93
+ }
94
+
95
+ /**
96
+ * Score a file, distinguishing "unscorable" from "scored badly".
97
+ *
98
+ * The transpile-failure and kernel-failure cases are reported separately
99
+ * because they need different fixes: a transpile failure is usually the
100
+ * consumer's own syntax or `tsconfig`, whereas a kernel failure is the
101
+ * upstream generator gap described in `escomplex-ast-compat.js`.
102
+ *
103
+ * @param {string} filePath Path to the JS/TS source file.
104
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
105
+ */
106
+ export function scoreFile(filePath) {
107
+ let sourceCode;
37
108
  try {
38
- const sourceCode = fs.readFileSync(filePath, 'utf-8');
39
- const prepared = transpileIfNeeded(filePath, sourceCode);
40
- if (prepared === null) return 0;
41
- return calculateForSource(prepared);
109
+ sourceCode = fs.readFileSync(filePath, 'utf-8');
42
110
  } catch (err) {
43
111
  if (err.code === 'ENOENT') {
44
112
  throw new Error(`File not found: ${filePath}`);
45
113
  }
46
114
  throw err;
47
115
  }
116
+
117
+ const prepared = transpileIfNeeded(filePath, sourceCode);
118
+ if (prepared === null) return unscorable('TypeScript transpile failed');
119
+ return scoreSource(prepared);
48
120
  }
49
121
 
50
122
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * maintainability-unscorable.js — reporting for files the MI kernel cannot
3
+ * analyse.
4
+ *
5
+ * A file the kernel throws on has no maintainability index, so it gets no
6
+ * baseline row — a phantom `mi: 0` would poison the `min`/p50 rollup and let
7
+ * real regressions hide behind it. Dropping the row is therefore correct; doing
8
+ * it *silently* is not. Without a report, an unscorable file is
9
+ * indistinguishable from a file nobody added yet: the scorer emits nothing, the
10
+ * scope gate sees an absence it cannot explain, and re-seeding the baseline can
11
+ * never produce the missing row no matter how many times it runs.
12
+ *
13
+ * Kept separate from `maintainability-utils.js` so the scoring path stays about
14
+ * scoring and this stays about explaining.
15
+ */
16
+
17
+ import { Logger } from './Logger.js';
18
+
19
+ /**
20
+ * Report every unscorable file, then summarise.
21
+ *
22
+ * @param {Array<{ relPath: string, unscorable?: boolean, reason?: string|null }>} perFile
23
+ * @returns {number} how many files were unscorable, for the caller's own use.
24
+ */
25
+ export function reportUnscorable(perFile) {
26
+ const unscorable = (perFile ?? []).filter((entry) => entry?.unscorable);
27
+ if (unscorable.length === 0) return 0;
28
+
29
+ for (const { relPath, reason } of unscorable) {
30
+ Logger.error(
31
+ `[Maintainability] UNSCORABLE ${relPath}: ${reason ?? 'unknown kernel failure'}`,
32
+ );
33
+ }
34
+ Logger.error(
35
+ `[Maintainability] ${unscorable.length} file(s) could not be scored and will have ` +
36
+ 'no baseline row, so the maintainability gate cannot see them. If the cause is a ' +
37
+ 'kernel AST gap, add a handler in lib/escomplex-ast-compat.js rather than an ' +
38
+ 'allowlist entry.',
39
+ );
40
+ return unscorable.length;
41
+ }
42
+
43
+ /**
44
+ * Whether a per-file entry carries a real maintainability index and so belongs
45
+ * in the baseline.
46
+ *
47
+ * An unscorable entry carries the sentinel score, not an index — letting it
48
+ * through would write an `mi: 0` phantom and drag the rollup floor down with it
49
+ * (Story #2467). `score === null` is the separate I/O-failure case.
50
+ *
51
+ * Tests for a *number* rather than `score !== null`, because the latter passes
52
+ * anything absent: `undefined !== null` is true, so a missing entry or one with
53
+ * no `score` key at all would have been treated as scored.
54
+ *
55
+ * @param {{ score?: number|null, unscorable?: boolean }} entry
56
+ * @returns {boolean}
57
+ */
58
+ export function isScored(entry) {
59
+ return typeof entry?.score === 'number' && !entry.unscorable;
60
+ }
@@ -4,7 +4,8 @@ import { minimatch } from 'minimatch';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
7
- import { calculateForFile } from './maintainability-engine.js';
7
+ import { scoreFile } from './maintainability-engine.js';
8
+ import { isScored, reportUnscorable } from './maintainability-unscorable.js';
8
9
 
9
10
  const MAINTAINABILITY_WORKER_URL = new URL(
10
11
  './workers/maintainability-worker.js',
@@ -128,6 +129,13 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
128
129
  * worker-side per-item failures surface as a `null` score that is
129
130
  * filtered out before assembly.
130
131
  *
132
+ * A file the kernel cannot analyse is also dropped — a phantom `mi: 0` row
133
+ * poisons the rollup — but it is **reported** on the way out, with the
134
+ * kernel's own error text, and the count is summarised at the end of the run.
135
+ * Silently omitting these is what let a file sit unmeasured indefinitely: the
136
+ * scorer emitted no row, so no amount of re-seeding could ever produce one,
137
+ * and nothing said so.
138
+ *
131
139
  * @param {string[]} paths
132
140
  * @returns {Promise<Record<string, number>>}
133
141
  */
@@ -142,7 +150,7 @@ export async function calculateAll(paths) {
142
150
  if (indexed.length < SERIAL_THRESHOLD) {
143
151
  perFile = indexed.map(({ abs, relPath }) => {
144
152
  try {
145
- return { relPath, score: calculateForFile(abs) };
153
+ return { relPath, ...scoreFile(abs) };
146
154
  } catch (err) {
147
155
  Logger.error(
148
156
  `[Maintainability] Failed to process ${abs}: ${err.message}`,
@@ -166,7 +174,7 @@ export async function calculateAll(paths) {
166
174
  if (r.score === null && r.error) {
167
175
  Logger.error(`[Maintainability] Failed to process ${abs}: ${r.error}`);
168
176
  }
169
- return { relPath, score: r.score };
177
+ return { relPath, ...r };
170
178
  });
171
179
  }
172
180
 
@@ -174,9 +182,10 @@ export async function calculateAll(paths) {
174
182
  a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0,
175
183
  );
176
184
 
185
+ reportUnscorable(perFile);
186
+
177
187
  const scores = {};
178
- for (const { relPath, score } of perFile) {
179
- if (score === null) continue;
188
+ for (const { relPath, score } of perFile.filter(isScored)) {
180
189
  scores[relPath] = score;
181
190
  }
182
191
  return scores;
@@ -75,6 +75,14 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
75
75
  * reflect code findings only.
76
76
  */
77
77
  TOOL_DEGRADED: 'tool-degraded',
78
+ /**
79
+ * The light delivery path refused a scope — a suitability-gate `ask-operator`
80
+ * or a blocked diff backstop. Story #4856 added it because neither rejection
81
+ * emitted anything, so an over-tight ceiling could only reach the framework
82
+ * as anecdote; the roll-up aggregating these by category is what makes the
83
+ * ceilings recalibratable from recorded evidence.
84
+ */
85
+ LIGHT_SCOPE_REJECTED: 'light-scope-rejected',
78
86
  });
79
87
 
80
88
  /** Cap on free-form reason text copied into a signal's `details`. */
@@ -355,9 +363,18 @@ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
355
363
  * `tool-degraded` from the scoped-lint runner from one out of lens
356
364
  * materialization. It is descriptive, never a routing key.
357
365
  *
366
+ * `ts` joined the shape in Story #4850, and it joined it **here** rather than
367
+ * in a second read beside the run-scope gather. The composer had no notion of
368
+ * *when* its corpus happened, so it borrowed the triggering run as the window
369
+ * and titled every proposal with a claim the evidence block below it already
370
+ * contradicted. Carrying the timestamp through the one shared normalizer is
371
+ * what lets the recurrence window be both bounded and describable; re-reading
372
+ * it beside one of the two gathers is precisely the drift that made the
373
+ * recovery-netting unreachable in Story #4649.
374
+ *
358
375
  * @param {unknown} parsed One parsed NDJSON row.
359
376
  * @param {number} fallbackStoryId Stream owner, used when the row has none.
360
- * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, details: object }|null}
377
+ * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, ts: string|null, details: object }|null}
361
378
  */
362
379
  export function normalizeGatheredSignal(parsed, fallbackStoryId) {
363
380
  if (!parsed || typeof parsed !== 'object') return null;
@@ -374,6 +391,7 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
374
391
  source: parsed.source === 'framework' ? 'framework' : 'consumer',
375
392
  storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
376
393
  tool: typeof emitterTool === 'string' ? emitterTool.trim() : '',
394
+ ts: parsableTimestamp(parsed.ts),
377
395
  details:
378
396
  parsed.details && typeof parsed.details === 'object'
379
397
  ? parsed.details
@@ -381,6 +399,24 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
381
399
  };
382
400
  }
383
401
 
402
+ /**
403
+ * The row's `ts` when it is a string a `Date` can read, else `null`.
404
+ *
405
+ * `signal-event.schema.json` requires `ts`, and both producers stamp
406
+ * `new Date().toISOString()` — but the gathers read whatever survives on disk,
407
+ * including rows a truncated write left half-formed. Resolving to `null`
408
+ * rather than guessing a time is what lets the recurrence window exclude an
409
+ * undateable row explicitly instead of aging it in as "recent".
410
+ *
411
+ * @param {unknown} value
412
+ * @returns {string|null}
413
+ */
414
+ function parsableTimestamp(value) {
415
+ if (typeof value !== 'string') return null;
416
+ const trimmed = value.trim();
417
+ return Number.isFinite(Date.parse(trimmed)) ? trimmed : null;
418
+ }
419
+
384
420
  /**
385
421
  * Pure predicate: is this signal a recovery marker for its own category?
386
422
  * Shared with the retro composer so the "recovered" discriminator is read