mandrel 2.15.0 → 2.17.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 (69) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +6 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/scripts/acceptance-eval.js +52 -12
  8. package/.agents/scripts/audit-to-stories.js +92 -25
  9. package/.agents/scripts/boot-sweep.js +28 -6
  10. package/.agents/scripts/check-baseline-drift.js +138 -0
  11. package/.agents/scripts/coverage-capture.js +74 -25
  12. package/.agents/scripts/deliver-light.js +31 -3
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/process.js +61 -15
  27. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  28. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  29. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  30. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  31. package/.agents/scripts/lib/config/quality.js +40 -0
  32. package/.agents/scripts/lib/coverage-utils.js +92 -9
  33. package/.agents/scripts/lib/crap-engine.js +113 -23
  34. package/.agents/scripts/lib/crap-utils.js +159 -93
  35. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  36. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  37. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  40. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  41. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  43. package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
  47. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  49. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  50. package/.agents/scripts/lib/stdio-flush.js +71 -0
  51. package/.agents/scripts/lib/transpile.js +133 -6
  52. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  53. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  54. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  55. package/.agents/scripts/nav-registry-diff.js +30 -8
  56. package/.agents/scripts/plan-context.js +4 -1
  57. package/.agents/scripts/plan-run-epilogue.js +27 -11
  58. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  59. package/.agents/scripts/single-story-close.js +9 -92
  60. package/.agents/scripts/update-crap-baseline.js +13 -0
  61. package/.agents/workflows/helpers/deliver-light.md +34 -8
  62. package/.agents/workflows/helpers/plan-reference.md +27 -6
  63. package/.agents/workflows/plan.md +4 -2
  64. package/.agents/workflows/prototype.md +104 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +41 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -121,6 +121,8 @@ export function hasCoverageFor(map, relPath) {
121
121
  * raw `fnMap` entry — so callers may key by the escomplex `lineStart`
122
122
  * (which can match either, depending on producer).
123
123
  * - `fnLocByStartLine`: same keying, value is `{fnStart, fnEnd}` derived once.
124
+ * - `fnRanges`: every function's `{fnStart, fnEnd, declLine}` triple, used by
125
+ * the containment / nearest-decl fallbacks when exact-line keying misses.
124
126
  * - `statementsByLine`: `Map<line, {total, covered}>` so range scans don't
125
127
  * re-walk the full statement map.
126
128
  *
@@ -129,9 +131,10 @@ export function hasCoverageFor(map, relPath) {
129
131
  export function buildEntryIndex(entry) {
130
132
  const fnByStartLine = new Map();
131
133
  const fnLocByStartLine = new Map();
134
+ const fnRanges = [];
132
135
  const statementsByLine = new Map();
133
136
  if (!entry || typeof entry !== 'object') {
134
- return { fnByStartLine, fnLocByStartLine, statementsByLine };
137
+ return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
135
138
  }
136
139
  const fnMap = entry.fnMap ?? {};
137
140
  const statementMap = entry.statementMap ?? {};
@@ -152,6 +155,13 @@ export function buildEntryIndex(entry) {
152
155
  fnByStartLine.set(locLine, f);
153
156
  fnLocByStartLine.set(locLine, loc);
154
157
  }
158
+ if (fnStart !== null && fnEnd !== null) {
159
+ fnRanges.push({
160
+ fnStart,
161
+ fnEnd,
162
+ declLine: typeof declLine === 'number' ? declLine : fnStart,
163
+ });
164
+ }
155
165
  }
156
166
 
157
167
  for (const stmtId of Object.keys(statementMap)) {
@@ -167,7 +177,76 @@ export function buildEntryIndex(entry) {
167
177
  if ((statementHits[stmtId] ?? 0) > 0) bucket.covered += 1;
168
178
  }
169
179
 
170
- return { fnByStartLine, fnLocByStartLine, statementsByLine };
180
+ return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
181
+ }
182
+
183
+ /**
184
+ * How far from a `fnMap` declaration line a method start may sit and still
185
+ * be considered the same function.
186
+ *
187
+ * Even after remapping to original-source coordinates (Story #4775), a
188
+ * method's start and istanbul's `decl.start.line` do not always agree on the
189
+ * token: escomplex anchors on the function node, istanbul on the declaration
190
+ * it instruments, and a decorator, a leading `export`, or a multi-line
191
+ * parameter list puts them one line apart. One line of slack absorbs that
192
+ * without letting an unrelated neighbouring function be claimed.
193
+ */
194
+ const DECL_MATCH_WINDOW = 1;
195
+
196
+ /**
197
+ * Resolve the `{fnStart, fnEnd}` range of the function a method start line
198
+ * belongs to, in the coordinate system of the coverage entry.
199
+ *
200
+ * Three strategies, most precise first:
201
+ *
202
+ * 1. **Exact** — the line keys a `fnMap` `decl.start.line` or
203
+ * `loc.start.line`. This is the pre-#4775 behaviour and still wins, so
204
+ * every already-resolving row keeps its exact prior value.
205
+ * 2. **Containment** — the innermost function whose `loc` range contains
206
+ * the line. Smallest span wins, so a nested callback is preferred over
207
+ * the enclosing function that also contains the line.
208
+ * 3. **Nearest declaration** — the closest `decl` line within
209
+ * `DECL_MATCH_WINDOW`, which absorbs the ±1 token disagreement between
210
+ * escomplex's method start and istanbul's declaration line.
211
+ *
212
+ * Returns `null` when none of the three finds a function — the caller
213
+ * surfaces that as "no data" rather than "tested zero times."
214
+ *
215
+ * @param {{fnByStartLine: Map, fnLocByStartLine: Map, fnRanges: Array}} idx
216
+ * @param {number} startLine
217
+ * @returns {{fnStart: number, fnEnd: number}|null}
218
+ */
219
+ function resolveFnRangeForLine(idx, startLine) {
220
+ if (typeof startLine !== 'number') return null;
221
+ if (idx.fnByStartLine.has(startLine)) {
222
+ const loc = idx.fnLocByStartLine.get(startLine);
223
+ if (loc && loc.fnStart !== null && loc.fnEnd !== null) return loc;
224
+ }
225
+ const ranges = idx.fnRanges ?? [];
226
+ let innermost = null;
227
+ let innermostSpan = Number.POSITIVE_INFINITY;
228
+ for (const range of ranges) {
229
+ if (startLine < range.fnStart || startLine > range.fnEnd) continue;
230
+ const span = range.fnEnd - range.fnStart;
231
+ if (span < innermostSpan) {
232
+ innermostSpan = span;
233
+ innermost = range;
234
+ }
235
+ }
236
+ if (innermost) return { fnStart: innermost.fnStart, fnEnd: innermost.fnEnd };
237
+
238
+ let nearest = null;
239
+ let nearestDist = Number.POSITIVE_INFINITY;
240
+ for (const range of ranges) {
241
+ const dist = Math.abs(range.declLine - startLine);
242
+ if (dist > DECL_MATCH_WINDOW) continue;
243
+ if (dist < nearestDist) {
244
+ nearestDist = dist;
245
+ nearest = range;
246
+ }
247
+ }
248
+ if (nearest) return { fnStart: nearest.fnStart, fnEnd: nearest.fnEnd };
249
+ return null;
171
250
  }
172
251
 
173
252
  function getEntryIndex(entry) {
@@ -193,22 +272,26 @@ function getEntryIndex(entry) {
193
272
  * returns 0. A missing / malformed entry or no matching function returns
194
273
  * `null` so the caller can distinguish "no data" from "tested zero times."
195
274
  *
275
+ * `startLine` MUST be in the coverage entry's own (original-source)
276
+ * coordinate system. Callers scoring transpiled TypeScript remap escomplex's
277
+ * transpiled `lineStart` first — see `transpileIfNeeded`'s `withLineMap`
278
+ * option (Story #4775). Matching is exact-then-containment-then-nearest-decl;
279
+ * see `resolveFnRangeForLine`.
280
+ *
196
281
  * The first call on a given entry builds and caches a per-entry index via a
197
282
  * non-enumerable Symbol property; consecutive method lookups in the same
198
283
  * file pay the build cost exactly once.
199
284
  *
200
285
  * @param {object|null} entry One inner value from a `coverage-final.json` map.
201
- * @param {number} startLine The escomplex `lineStart` for the method.
286
+ * @param {number} startLine The method's start line, in entry coordinates.
202
287
  * @returns {number|null}
203
288
  */
204
289
  export function coverageForMethodInEntry(entry, startLine) {
205
290
  if (!entry || typeof entry !== 'object') return null;
206
291
  const idx = getEntryIndex(entry);
207
- if (!idx.fnByStartLine.has(startLine)) return null;
208
- const loc = idx.fnLocByStartLine.get(startLine);
209
- if (!loc) return null;
210
- const { fnStart, fnEnd } = loc;
211
- if (fnStart === null || fnEnd === null) return null;
292
+ const range = resolveFnRangeForLine(idx, startLine);
293
+ if (!range) return null;
294
+ const { fnStart, fnEnd } = range;
212
295
 
213
296
  let total = 0;
214
297
  let covered = 0;
@@ -228,7 +311,7 @@ export function coverageForMethodInEntry(entry, startLine) {
228
311
  *
229
312
  * @param {object|null} map Parsed `coverage-final.json`.
230
313
  * @param {string} relPath Repo-relative path of the source file.
231
- * @param {number} startLine The escomplex `lineStart` for the method.
314
+ * @param {number} startLine The method's start line, in entry coordinates.
232
315
  * @returns {number|null} Coverage in [0, 1], or null when the file or method
233
316
  * is absent.
234
317
  */
@@ -1,6 +1,108 @@
1
1
  import escomplex from 'typhonjs-escomplex';
2
2
  import { coverageForMethodInEntry } from './coverage-utils.js';
3
3
 
4
+ /**
5
+ * Derive the raw per-method CRAP rows from an escomplex report.
6
+ *
7
+ * Single-sourced between `calculateCrapForSource` (CRAP-only path) and
8
+ * `analyzeOnce` (combined MI + CRAP path) so the two cannot drift on how a
9
+ * method's line is remapped or its coverage joined — the parity the
10
+ * combined-parity suite asserts.
11
+ *
12
+ * **Coordinates (Story #4775).** `mapLine` translates escomplex's
13
+ * `lineStart` — which is in *transpiled* coordinates for a TS/TSX source —
14
+ * into the *original source* coordinates istanbul's `fnMap` uses. Without it
15
+ * the join compares two different coordinate systems and either misses or,
16
+ * worse, collides with an unrelated function. A `null` mapper means the two
17
+ * coordinate systems already coincide (plain JavaScript), and a line the map
18
+ * cannot resolve falls back to the un-remapped value rather than dropping the
19
+ * method outright. The remapped line is also what the row reports, so a
20
+ * persisted row points at a line the reader can actually open.
21
+ *
22
+ * @param {object|null} report An `escomplex.analyzeModule` report.
23
+ * @param {object|null} coverageForFile Istanbul coverage entry for this file.
24
+ * @param {((line: number) => number|null)|null} [mapLine]
25
+ * @returns {Array<{
26
+ * method: string,
27
+ * startLine: number,
28
+ * cyclomatic: number,
29
+ * coverage: number|null,
30
+ * crap: number|null,
31
+ * }>}
32
+ */
33
+ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
34
+ const methods = report?.methods ?? [];
35
+ const rows = [];
36
+ for (const m of methods) {
37
+ const rawStartLine = m?.lineStart;
38
+ if (typeof rawStartLine !== 'number') continue;
39
+ const mapped = typeof mapLine === 'function' ? mapLine(rawStartLine) : null;
40
+ const startLine = typeof mapped === 'number' ? mapped : rawStartLine;
41
+ const cyclomatic = m?.cyclomatic ?? 0;
42
+ const coverage = coverageForFile
43
+ ? coverageForMethodInEntry(coverageForFile, startLine)
44
+ : null;
45
+ const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
46
+ rows.push({ method: m.name, startLine, cyclomatic, coverage, crap });
47
+ }
48
+ return rows;
49
+ }
50
+
51
+ /**
52
+ * Apply the scanner's `requireCoverage` policy to raw method rows and report
53
+ * how much of the coverage join actually landed.
54
+ *
55
+ * Two policies, one honest each way (Story #4775, fix part 3):
56
+ *
57
+ * - `requireCoverage: true` — an unresolved method is skipped and counted,
58
+ * exactly as before. The baseline stays a record of measured code.
59
+ * - `requireCoverage: false` — an unresolved method scores as **0%
60
+ * covered** (`crap = c² + c`, the formula's own treatment of untested
61
+ * code) and lands in the baseline. Previously the flag only stopped
62
+ * whole *files* being skipped while each individual method was still
63
+ * dropped, which made it a no-op for baseline population — the caller
64
+ * asked for "score it anyway" and got silence.
65
+ *
66
+ * `resolvedMethods` / `totalMethods` count the *join*, not the fill: a
67
+ * method scored 0% because its coverage was unresolved counts as
68
+ * unresolved. That is what makes them usable as a health signal for the
69
+ * updater's fail-closed resolution-rate floor.
70
+ *
71
+ * @param {Array<object>} rawRows Rows from `methodRowsFromReport`.
72
+ * @param {{requireCoverage?: boolean}} [opts]
73
+ * @returns {{
74
+ * rows: Array<object>,
75
+ * skippedMethodsNoCoverage: number,
76
+ * resolvedMethods: number,
77
+ * totalMethods: number,
78
+ * }}
79
+ */
80
+ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
81
+ const rows = [];
82
+ let skippedMethodsNoCoverage = 0;
83
+ let resolvedMethods = 0;
84
+ let totalMethods = 0;
85
+ for (const mr of rawRows ?? []) {
86
+ totalMethods += 1;
87
+ const unresolved = mr.crap === null || mr.coverage === null;
88
+ if (!unresolved) resolvedMethods += 1;
89
+ if (unresolved && requireCoverage) {
90
+ skippedMethodsNoCoverage += 1;
91
+ continue;
92
+ }
93
+ const coverage = unresolved ? 0 : mr.coverage;
94
+ const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
95
+ rows.push({
96
+ method: mr.method,
97
+ startLine: mr.startLine,
98
+ cyclomatic: mr.cyclomatic,
99
+ coverage,
100
+ crap,
101
+ });
102
+ }
103
+ return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
104
+ }
105
+
4
106
  /**
5
107
  * Score each method in a JavaScript source for Change Risk Anti-Patterns
6
108
  * (CRAP): `c² · (1 − cov)³ + c`, where `c` is cyclomatic complexity and `cov`
@@ -11,15 +113,17 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
11
113
  * `analyzeModule`).
12
114
  * - Methods whose coverage cannot be resolved from `coverageForFile`
13
115
  * produce `coverage: null` and `crap: null`. Callers apply their own
14
- * `requireCoverage` policy at the scanner level; this kernel never
15
- * decides to skip.
116
+ * `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
117
+ * this kernel never decides to skip.
16
118
  * - A parse error returns an empty array — the file is unscorable, not
17
119
  * zero-complexity.
18
120
  *
19
- * @param {string} source JavaScript source text.
121
+ * @param {string} source JavaScript source text (possibly transpiled).
20
122
  * @param {object|null} coverageForFile The inner value from a
21
123
  * `coverage-final.json` map keyed by this file's path, or null when no
22
124
  * coverage data is available for this file.
125
+ * @param {((line: number) => number|null)|null} [mapLine] Transpiled →
126
+ * original line resolver; see `methodRowsFromReport`.
23
127
  * @returns {Array<{
24
128
  * method: string,
25
129
  * startLine: number,
@@ -28,32 +132,18 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
28
132
  * crap: number|null,
29
133
  * }>}
30
134
  */
31
- export function calculateCrapForSource(source, coverageForFile) {
135
+ export function calculateCrapForSource(
136
+ source,
137
+ coverageForFile,
138
+ mapLine = null,
139
+ ) {
32
140
  let report;
33
141
  try {
34
142
  report = escomplex.analyzeModule(source);
35
143
  } catch {
36
144
  return [];
37
145
  }
38
- const methods = report?.methods ?? [];
39
- const rows = [];
40
- for (const m of methods) {
41
- const startLine = m?.lineStart;
42
- if (typeof startLine !== 'number') continue;
43
- const cyclomatic = m?.cyclomatic ?? 0;
44
- const coverage = coverageForFile
45
- ? coverageForMethodInEntry(coverageForFile, startLine)
46
- : null;
47
- const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
48
- rows.push({
49
- method: m.name,
50
- startLine,
51
- cyclomatic,
52
- coverage,
53
- crap,
54
- });
55
- }
56
- return rows;
146
+ return methodRowsFromReport(report, coverageForFile, mapLine);
57
147
  }
58
148
 
59
149
  /**
@@ -2,15 +2,15 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import escomplex from 'typhonjs-escomplex';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
- import {
6
- coverageForMethodInEntry,
7
- findCoverageEntry,
8
- } from './coverage-utils.js';
5
+ import { findCoverageEntry } from './coverage-utils.js';
9
6
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
10
- import { crapFormula } from './crap-engine.js';
7
+ import { finalizeMethodRows, methodRowsFromReport } from './crap-engine.js';
11
8
  import { Logger } from './Logger.js';
12
9
  import { scanDirectory } from './maintainability-utils.js';
13
- import { resolveTsTranspilerVersion, transpileIfNeeded } from './transpile.js';
10
+ import {
11
+ prepareSourceForScoring,
12
+ resolveTsTranspilerVersion,
13
+ } from './transpile.js';
14
14
 
15
15
  const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
16
16
  const COMBINED_MI_CRAP_WORKER_URL = new URL(
@@ -201,6 +201,103 @@ export function buildBaselineEnvelope({
201
201
  };
202
202
  }
203
203
 
204
+ /**
205
+ * How many files to name when reporting the worst unresolved offenders. Long
206
+ * enough to point at a pattern, short enough to stay a readable CLI message.
207
+ */
208
+ const WORST_OFFENDER_LIMIT = 5;
209
+
210
+ /**
211
+ * Method-resolution telemetry (Story #4775, fix part 4).
212
+ *
213
+ * The updater used to persist a 100-row baseline built from 5023 dropped
214
+ * methods and log it as success — the rot that let a broken coverage join
215
+ * sit undetected for five weeks across three repos. These three helpers
216
+ * carry the counters that make a thin result *visible* and therefore
217
+ * refusable.
218
+ *
219
+ * The rate is deliberately measured over files that **do** have a coverage
220
+ * entry: a file the test run never touched has no join to fail, so counting
221
+ * it would dilute the signal the floor is meant to catch.
222
+ */
223
+ function newResolutionAccumulator() {
224
+ return { resolved: 0, total: 0, byFile: [] };
225
+ }
226
+
227
+ function accumulateResolution(acc, relPath, result) {
228
+ if (result?.hasCoverageEntry !== true) return;
229
+ const total = result.totalMethods ?? 0;
230
+ if (total === 0) return;
231
+ const resolved = result.resolvedMethods ?? 0;
232
+ acc.resolved += resolved;
233
+ acc.total += total;
234
+ if (resolved < total) {
235
+ acc.byFile.push({ file: relPath, unresolved: total - resolved, total });
236
+ }
237
+ }
238
+
239
+ function summarizeResolution(acc) {
240
+ const worstFiles = [...acc.byFile]
241
+ .sort((a, b) => b.unresolved - a.unresolved || a.file.localeCompare(b.file))
242
+ .slice(0, WORST_OFFENDER_LIMIT);
243
+ return {
244
+ resolvedMethods: acc.resolved,
245
+ joinableMethods: acc.total,
246
+ rate: acc.total === 0 ? 1 : acc.resolved / acc.total,
247
+ worstFiles,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Minimum number of joinable methods before the resolution-rate floor is
253
+ * enforced. A diff-scoped run can legitimately touch a handful of methods,
254
+ * where one unresolved method is a 50% rate and says nothing about the health
255
+ * of the join. Below this sample the rate is reported, never enforced.
256
+ */
257
+ const MIN_RESOLUTION_SAMPLE = 25;
258
+
259
+ /**
260
+ * Fail-closed guard on the per-method coverage join (Story #4775, fix part 4).
261
+ *
262
+ * The updater used to persist a 100-row baseline distilled from 5023 dropped
263
+ * methods and log it as a success — which is exactly how a broken join stayed
264
+ * invisible for five weeks across three repositories. A thin result is now a
265
+ * refusal: the caller throws before anything is written, and the message names
266
+ * the rate, the counts, and the files carrying the most unresolved methods so
267
+ * the operator can tell "my tests do not cover that" apart from "the join is
268
+ * broken".
269
+ *
270
+ * Returns `null` when the run may proceed, or the operator-facing message when
271
+ * it must not.
272
+ *
273
+ * @param {{resolvedMethods: number, joinableMethods: number, rate: number,
274
+ * worstFiles: Array<{file: string, unresolved: number, total: number}>}
275
+ * | undefined} resolution
276
+ * @param {number} floor
277
+ * @returns {string|null}
278
+ */
279
+ export function checkResolutionFloor(resolution, floor) {
280
+ if (!resolution) return null;
281
+ const { joinableMethods = 0, resolvedMethods = 0, rate = 1 } = resolution;
282
+ if (joinableMethods < MIN_RESOLUTION_SAMPLE) return null;
283
+ if (rate >= floor) return null;
284
+ const worst = (resolution.worstFiles ?? [])
285
+ .map((w) => ` - ${w.file} (${w.unresolved}/${w.total} unresolved)`)
286
+ .join('\n');
287
+ return (
288
+ `[CRAP] Refusing to persist: only ${resolvedMethods}/${joinableMethods} ` +
289
+ `method(s) (${(rate * 100).toFixed(1)}%) resolved a coverage entry in files ` +
290
+ `that HAVE coverage — below the ${(floor * 100).toFixed(1)}% floor ` +
291
+ '(delivery.quality.gates.crap.minMethodResolutionRate).\n' +
292
+ ' A baseline built from a broken join is not sparse, it is wrong: ' +
293
+ 'unresolved methods are absent and coincidental line collisions are ' +
294
+ 'mis-attributed.\n' +
295
+ (worst ? ` Worst unresolved files:\n${worst}\n` : '') +
296
+ " Regenerate coverage ('npm run test:coverage') and re-run; if the " +
297
+ 'rate stays low the coverage artifact and the scanned tree disagree.'
298
+ );
299
+ }
300
+
204
301
  /**
205
302
  * Parse `source` exactly once with escomplex and derive both the
206
303
  * maintainability score and the raw CRAP method rows from that single report.
@@ -216,6 +313,10 @@ export function buildBaselineEnvelope({
216
313
  *
217
314
  * @param {string} source Prepared (possibly transpiled) JavaScript source text.
218
315
  * @param {object|null} coverageForFile Istanbul coverage entry for this file.
316
+ * @param {((line: number) => number|null)|null} [mapLine] Transpiled →
317
+ * original-source line resolver from `transpileIfNeeded(…, {withLineMap:
318
+ * true})`; `null` for JavaScript, whose coordinates already match the
319
+ * coverage entry's.
219
320
  * @returns {{
220
321
  * report: object,
221
322
  * miScore: number,
@@ -229,7 +330,7 @@ export function buildBaselineEnvelope({
229
330
  * parseError: boolean,
230
331
  * }}
231
332
  */
232
- export function analyzeOnce(source, coverageForFile) {
333
+ export function analyzeOnce(source, coverageForFile, mapLine = null) {
233
334
  let report;
234
335
  try {
235
336
  report = escomplex.analyzeModule(source);
@@ -238,18 +339,7 @@ export function analyzeOnce(source, coverageForFile) {
238
339
  }
239
340
  const miScore =
240
341
  typeof report.maintainability === 'number' ? report.maintainability : 0;
241
- const methods = report?.methods ?? [];
242
- const crapRows = [];
243
- for (const m of methods) {
244
- const startLine = m?.lineStart;
245
- if (typeof startLine !== 'number') continue;
246
- const cyclomatic = m?.cyclomatic ?? 0;
247
- const coverage = coverageForFile
248
- ? coverageForMethodInEntry(coverageForFile, startLine)
249
- : null;
250
- const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
251
- crapRows.push({ method: m.name, startLine, cyclomatic, coverage, crap });
252
- }
342
+ const crapRows = methodRowsFromReport(report, coverageForFile, mapLine);
253
343
  return { report, miScore, crapRows, parseError: false };
254
344
  }
255
345
 
@@ -348,6 +438,7 @@ export async function scanAndScore({
348
438
  const rows = [];
349
439
  let skippedFilesNoCoverage = 0;
350
440
  let skippedMethodsNoCoverage = 0;
441
+ const resolution = newResolutionAccumulator();
351
442
  for (const { item, result } of perFile) {
352
443
  if (!result) continue; // unrecoverable per-file failure: drop silently to match pre-pool semantics
353
444
  if (result.skippedFileNoCoverage) {
@@ -366,6 +457,7 @@ export async function scanAndScore({
366
457
  continue;
367
458
  }
368
459
  skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
460
+ accumulateResolution(resolution, item.relPath, result);
369
461
  for (const mr of result.rows) {
370
462
  rows.push({
371
463
  file: item.relPath,
@@ -390,6 +482,7 @@ export async function scanAndScore({
390
482
  scannedFiles,
391
483
  skippedFilesNoCoverage,
392
484
  skippedMethodsNoCoverage,
485
+ resolution: summarizeResolution(resolution),
393
486
  };
394
487
  }
395
488
 
@@ -408,50 +501,33 @@ function scoreFileSerial({ abs, relPath, requireCoverage }, coverage) {
408
501
  skippedFileNoCoverage: true,
409
502
  rows: [],
410
503
  skippedMethodsNoCoverage: 0,
504
+ hasCoverageEntry: false,
505
+ resolvedMethods: 0,
506
+ totalMethods: 0,
411
507
  };
412
508
  }
413
- let source;
414
- try {
415
- source = fs.readFileSync(abs, 'utf-8');
416
- } catch {
417
- return {
418
- skippedFileNoCoverage: false,
419
- rows: null,
420
- skippedMethodsNoCoverage: 0,
421
- };
422
- }
423
- const prepared = transpileIfNeeded(abs, source);
424
- if (prepared === null) {
425
- return {
426
- skippedFileNoCoverage: false,
427
- rows: null,
428
- skippedMethodsNoCoverage: 0,
429
- };
430
- }
431
- const { crapRows, parseError } = analyzeOnce(prepared, entry);
432
- if (parseError) {
433
- return {
434
- skippedFileNoCoverage: false,
435
- rows: null,
436
- skippedMethodsNoCoverage: 0,
437
- };
438
- }
439
- const rows = [];
440
- let skippedMethodsNoCoverage = 0;
441
- for (const mr of crapRows) {
442
- if (mr.crap === null || mr.coverage === null) {
443
- skippedMethodsNoCoverage += 1;
444
- continue;
445
- }
446
- rows.push({
447
- method: mr.method,
448
- startLine: mr.startLine,
449
- cyclomatic: mr.cyclomatic,
450
- coverage: mr.coverage,
451
- crap: mr.crap,
452
- });
453
- }
454
- return { skippedFileNoCoverage: false, rows, skippedMethodsNoCoverage };
509
+ const dropped = {
510
+ skippedFileNoCoverage: false,
511
+ rows: null,
512
+ skippedMethodsNoCoverage: 0,
513
+ hasCoverageEntry: entry !== null,
514
+ resolvedMethods: 0,
515
+ totalMethods: 0,
516
+ };
517
+ const prepared = prepareSourceForScoring(abs);
518
+ if (prepared.error) return dropped;
519
+ const { crapRows, parseError } = analyzeOnce(
520
+ prepared.code,
521
+ entry,
522
+ prepared.mapLine,
523
+ );
524
+ if (parseError) return dropped;
525
+ const finalized = finalizeMethodRows(crapRows, { requireCoverage });
526
+ return {
527
+ skippedFileNoCoverage: false,
528
+ hasCoverageEntry: entry !== null,
529
+ ...finalized,
530
+ };
455
531
  }
456
532
 
457
533
  async function scoreFilesViaPool(queue, coverage) {
@@ -494,33 +570,24 @@ async function scoreFilesViaPool(queue, coverage) {
494
570
  */
495
571
  function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
496
572
  const entry = findCoverageEntry(coverage, relPath);
497
- let source;
498
- try {
499
- source = fs.readFileSync(abs, 'utf-8');
500
- } catch {
501
- return {
502
- relPath,
503
- miScore: null,
504
- skippedFileNoCoverage: false,
505
- crapRows: null,
506
- skippedMethodsNoCoverage: 0,
507
- };
508
- }
509
- const prepared = transpileIfNeeded(abs, source);
510
- if (prepared === null) {
573
+ const prepared = prepareSourceForScoring(abs);
574
+ if (prepared.error) {
511
575
  return {
512
576
  relPath,
513
- miScore: 0,
577
+ miScore: prepared.error === 'read' ? null : 0,
514
578
  skippedFileNoCoverage: false,
515
579
  crapRows: null,
516
580
  skippedMethodsNoCoverage: 0,
581
+ hasCoverageEntry: entry !== null,
582
+ resolvedMethods: 0,
583
+ totalMethods: 0,
517
584
  };
518
585
  }
519
586
  const {
520
587
  miScore,
521
588
  crapRows: rawCrapRows,
522
589
  parseError,
523
- } = analyzeOnce(prepared, entry);
590
+ } = analyzeOnce(prepared.code, entry, prepared.mapLine);
524
591
  if (parseError) {
525
592
  return {
526
593
  relPath,
@@ -528,6 +595,9 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
528
595
  skippedFileNoCoverage: false,
529
596
  crapRows: null,
530
597
  skippedMethodsNoCoverage: 0,
598
+ hasCoverageEntry: entry !== null,
599
+ resolvedMethods: 0,
600
+ totalMethods: 0,
531
601
  };
532
602
  }
533
603
  if (requireCoverage && entry === null) {
@@ -537,29 +607,22 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
537
607
  skippedFileNoCoverage: true,
538
608
  crapRows: [],
539
609
  skippedMethodsNoCoverage: 0,
610
+ hasCoverageEntry: false,
611
+ resolvedMethods: 0,
612
+ totalMethods: 0,
540
613
  };
541
614
  }
542
- const crapRows = [];
543
- let skippedMethodsNoCoverage = 0;
544
- for (const mr of rawCrapRows) {
545
- if (mr.crap === null || mr.coverage === null) {
546
- skippedMethodsNoCoverage += 1;
547
- continue;
548
- }
549
- crapRows.push({
550
- method: mr.method,
551
- startLine: mr.startLine,
552
- cyclomatic: mr.cyclomatic,
553
- coverage: mr.coverage,
554
- crap: mr.crap,
555
- });
556
- }
615
+ const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
616
+ finalizeMethodRows(rawCrapRows, { requireCoverage });
557
617
  return {
558
618
  relPath,
559
619
  miScore,
560
620
  skippedFileNoCoverage: false,
561
- crapRows,
621
+ crapRows: rows,
562
622
  skippedMethodsNoCoverage,
623
+ hasCoverageEntry: entry !== null,
624
+ resolvedMethods,
625
+ totalMethods,
563
626
  };
564
627
  }
565
628
 
@@ -693,6 +756,7 @@ export async function scanAndScoreCombined({
693
756
  const crapRows = [];
694
757
  let skippedFilesNoCoverage = 0;
695
758
  let skippedMethodsNoCoverage = 0;
759
+ const resolution = newResolutionAccumulator();
696
760
 
697
761
  for (const { item, result } of perFile) {
698
762
  if (!result) continue; // unrecoverable per-file failure: drop silently
@@ -716,6 +780,7 @@ export async function scanAndScoreCombined({
716
780
  continue;
717
781
  }
718
782
  skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
783
+ accumulateResolution(resolution, item.relPath, result);
719
784
  for (const mr of result.crapRows) {
720
785
  crapRows.push({
721
786
  file: item.relPath,
@@ -750,6 +815,7 @@ export async function scanAndScoreCombined({
750
815
  scannedFiles,
751
816
  skippedFilesNoCoverage,
752
817
  skippedMethodsNoCoverage,
818
+ resolution: summarizeResolution(resolution),
753
819
  },
754
820
  };
755
821
  }