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.
- package/.agents/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/plan-reference.md +40 -0
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +23 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|