mandrel 2.16.0 → 2.18.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/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -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/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  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/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  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 +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -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
@@ -19,6 +19,7 @@ import {
19
19
  } from './commands.js';
20
20
  import { DEFAULT_GATES, partitionGates } from './gates.js';
21
21
  import { defaultGateRunner } from './process.js';
22
+ import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
22
23
  import { defaultGetHeadSha } from './projections/head-sha.js';
23
24
 
24
25
  /** @typedef {import('./gates.js').Gate} Gate */
@@ -90,10 +91,23 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
90
91
  * story-close uses it to drive `phaseTimer.mark(...)` for per-gate
91
92
  * wall-clock telemetry. Errors thrown from the hook propagate.
92
93
  *
94
+ * Projection advisories (Story #4776): when `baseBranch` and `storyBranch`
95
+ * are both supplied and every gate passed, the maintainability and CRAP
96
+ * pre-merge projections run through `projections/advisories.js` and log
97
+ * their advisories to the same `log` sink the gates use. They are advisory
98
+ * by construction — the returned `ok` is decided entirely by the gates, so
99
+ * a projected breach never fails a close. They are skipped after a gate
100
+ * failure, where the operator needs the failing gate's evidence, not a
101
+ * baseline-refresh nudge.
102
+ *
93
103
  * @param {{
94
104
  * cwd: string,
95
105
  * worktreePath?: string,
96
106
  * gates?: Gate[],
107
+ * baseBranch?: string|null,
108
+ * storyBranch?: string|null,
109
+ * config?: object|null,
110
+ * runProjections?: typeof defaultRunProjections,
97
111
  * runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
98
112
  * log?: (m: string) => void,
99
113
  * onGateStart?: (gate: Gate) => void,
@@ -114,6 +128,10 @@ export async function runCloseValidation({
114
128
  runner = defaultGateRunner,
115
129
  log = () => {},
116
130
  onGateStart,
131
+ baseBranch = null,
132
+ storyBranch = null,
133
+ config = null,
134
+ runProjections = defaultRunProjections,
117
135
  storyId = null,
118
136
  standalone = false,
119
137
  useEvidence = true,
@@ -346,5 +364,55 @@ export async function runCloseValidation({
346
364
  );
347
365
  }
348
366
 
367
+ // ── Phase 3: advisory projections ───────────────────────────────────
368
+ // Story #4776 — the projection layer's live call site. Deliberately
369
+ // outside the `ok` computation: a projected breach informs, it never
370
+ // fails a close.
371
+ if (failed.length === 0) {
372
+ await runAdvisoryProjections({
373
+ runProjections,
374
+ cwd: spawnCwd,
375
+ baseBranch,
376
+ storyBranch,
377
+ config,
378
+ log,
379
+ });
380
+ }
381
+
349
382
  return { ok: failed.length === 0, failed, skipped };
350
383
  }
384
+
385
+ /**
386
+ * Phase 3 helper — run the advisory projections, absorbing every failure.
387
+ *
388
+ * No-ops without a branch pair to diff (resume / legacy callers), and can
389
+ * never influence the close verdict: the caller has already decided `ok`
390
+ * before this runs, and a throw here is logged, not propagated.
391
+ *
392
+ * @param {{
393
+ * runProjections: typeof defaultRunProjections,
394
+ * cwd: string,
395
+ * baseBranch: string|null,
396
+ * storyBranch: string|null,
397
+ * config: object|null,
398
+ * log: (m: string) => void,
399
+ * }} opts
400
+ * @returns {Promise<void>}
401
+ */
402
+ async function runAdvisoryProjections({
403
+ runProjections,
404
+ cwd,
405
+ baseBranch,
406
+ storyBranch,
407
+ config,
408
+ log,
409
+ }) {
410
+ if (!(baseBranch && storyBranch)) return;
411
+ try {
412
+ await runProjections({ cwd, baseBranch, storyBranch, config, log });
413
+ } catch (err) {
414
+ log(
415
+ `[close-validation] ⚠ projection advisories skipped: ${err?.message ?? err}`,
416
+ );
417
+ }
418
+ }
@@ -13,6 +13,13 @@ export const CRAP_GATE = {
13
13
  targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
14
14
  newMethodCeiling: { type: 'integer', minimum: 1 },
15
15
  requireCoverage: { type: 'boolean' },
16
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN: the
17
+ // fraction of methods that must resolve a coverage entry, counted only
18
+ // over files that HAVE one, before `update-crap-baseline.js` will
19
+ // persist. A broken join is silent by construction (unresolved methods
20
+ // are simply absent), so the updater refuses rather than writing a thin
21
+ // baseline and logging it as success. Default 0.75.
22
+ minMethodResolutionRate: { type: 'number', minimum: 0, maximum: 1 },
16
23
  friction: {
17
24
  type: 'object',
18
25
  properties: { markerKey: { type: 'string', minLength: 1 } },
@@ -88,6 +88,15 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
88
88
  // semantics.
89
89
  refreshTimeoutMs: 60_000,
90
90
  ignoreGlobs: Object.freeze([]),
91
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN. The
92
+ // fraction of methods that must resolve a coverage entry, counted only over
93
+ // files that HAVE one, before `update-crap-baseline.js` will persist. A
94
+ // broken join is silent by construction (unresolved methods are simply
95
+ // absent from the baseline), so the updater refuses rather than writing a
96
+ // thin baseline and logging it as success. 0.75 sits far above a healthy
97
+ // run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
98
+ // signature of a coordinate-system mismatch.
99
+ minMethodResolutionRate: 0.75,
91
100
  });
92
101
 
93
102
  /** Framework defaults for the coverage gate. */
@@ -121,6 +130,15 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
121
130
  // spawned by the baseline-attribution refresh path. Defaults to 60 s.
122
131
  refreshTimeoutMs: 60_000,
123
132
  ignoreGlobs: Object.freeze([]),
133
+ // Story #4775 — fail-closed floor on the per-method coverage JOIN. The
134
+ // fraction of methods that must resolve a coverage entry, counted only over
135
+ // files that HAVE one, before `update-crap-baseline.js` will persist. A
136
+ // broken join is silent by construction (unresolved methods are simply
137
+ // absent from the baseline), so the updater refuses rather than writing a
138
+ // thin baseline and logging it as success. 0.75 sits far above a healthy
139
+ // run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
140
+ // signature of a coordinate-system mismatch.
141
+ minMethodResolutionRate: 0.75,
124
142
  });
125
143
 
126
144
  /**
@@ -144,6 +162,7 @@ const CRAP_GATE_KEYS = new Set([
144
162
  'refreshTag',
145
163
  'refreshTimeoutMs',
146
164
  'ignoreGlobs',
165
+ 'minMethodResolutionRate',
147
166
  ]);
148
167
 
149
168
  const COVERAGE_GATE_KEYS = new Set([
@@ -205,6 +224,22 @@ function warnUnknownKeys(userBlock, knownKeys, blockLabel) {
205
224
  * @param {{ coveragePath: string }} coverageGate resolved coverage gate
206
225
  * @returns {object} flattened legacy-bag view that existing callers read
207
226
  */
227
+ /**
228
+ * Clamp a user-supplied method-resolution floor into `[0, 1]`. A
229
+ * non-numeric, non-finite, or out-of-range value falls back to the framework
230
+ * default rather than silently disabling the guard (a floor of `NaN` would
231
+ * compare false against every rate and never fire).
232
+ *
233
+ * @param {unknown} value
234
+ * @param {number} fallback
235
+ * @returns {number}
236
+ */
237
+ function resolveResolutionRate(value, fallback) {
238
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
239
+ if (value < 0 || value > 1) return fallback;
240
+ return value;
241
+ }
242
+
208
243
  export function resolveMaintainabilityCrap(
209
244
  userCrap,
210
245
  gateScoping,
@@ -227,6 +262,7 @@ export function resolveMaintainabilityCrap(
227
262
  DEFAULT_CRAP_TOLERANCE.value,
228
263
  ),
229
264
  requireCoverage: defaults.requireCoverage,
265
+ minMethodResolutionRate: defaults.minMethodResolutionRate,
230
266
  friction: { ...defaults.friction },
231
267
  refreshTag: defaults.refreshTag,
232
268
  refreshTimeoutMs: defaults.refreshTimeoutMs,
@@ -248,6 +284,10 @@ export function resolveMaintainabilityCrap(
248
284
  toleranceScalar(defaults.tolerance, DEFAULT_CRAP_TOLERANCE.value),
249
285
  ),
250
286
  requireCoverage: userCrap.requireCoverage ?? defaults.requireCoverage,
287
+ minMethodResolutionRate: resolveResolutionRate(
288
+ userCrap.minMethodResolutionRate,
289
+ defaults.minMethodResolutionRate,
290
+ ),
251
291
  friction: { ...defaults.friction, ...(userCrap.friction ?? {}) },
252
292
  refreshTag: userCrap.refreshTag ?? defaults.refreshTag,
253
293
  refreshTimeoutMs: resolvePositiveIntegerMs(
@@ -274,6 +274,33 @@ export function tempRootFrom(config) {
274
274
  : 'temp';
275
275
  }
276
276
 
277
+ /**
278
+ * Directory segment (under `tempRoot`) holding every orchestration run log —
279
+ * the close gate transcripts (`close-gates-<sid>.log`) and the terse-result
280
+ * detail dumps (`story-init-result-<sid>.log`, `sync-result-<branch>.log`, …).
281
+ *
282
+ * Story #4794: the four writers that land here each hand-rolled the temp path
283
+ * from a literal `temp` segment joined onto their own cwd, which ignores
284
+ * `project.paths.tempRoot` entirely. On a consumer that relocates its temp
285
+ * root, the writers wrote to `<cwd>/temp/` while every reader — including the
286
+ * retention purge — resolved the configured root, so the artifacts were
287
+ * invisible to the tooling meant to manage them. Routing all four through this
288
+ * helper also picks up main-checkout anchoring for free, so a close running
289
+ * from a Story worktree lands its logs in the same tree the host reads.
290
+ */
291
+ export const ORCHESTRATION_DIRNAME = 'orchestration';
292
+
293
+ /**
294
+ * `<tempRoot>/orchestration/` — resolved against the configured temp root and
295
+ * anchored to the main checkout, like every other helper in this module.
296
+ *
297
+ * @param {object} [config]
298
+ * @returns {string}
299
+ */
300
+ export function orchestrationLogDir(config) {
301
+ return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
302
+ }
303
+
277
304
  const runId = (id) => {
278
305
  if (!Number.isInteger(id) || id <= 0) {
279
306
  throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
@@ -352,12 +352,81 @@ const AUDIT_TO_STORIES_SCHEMA = {
352
352
  additionalProperties: false,
353
353
  };
354
354
 
355
+ /**
356
+ * `delivery.tempRetention` — auto-purge of spent temp artifacts (Story #4794).
357
+ *
358
+ * `enabled` defaults to `true`: reclaiming a landed Story's gate transcripts
359
+ * and evidence is the behaviour, and the knob exists to turn it off. `classes`
360
+ * lets an operator keep one family while purging the rest; `staleDays` is the
361
+ * age floor for the families no Story id can be recovered from (audit reports,
362
+ * abandoned `plan-<slug>/` dirs).
363
+ */
364
+ const TEMP_RETENTION_SCHEMA = {
365
+ type: 'object',
366
+ description:
367
+ 'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
368
+ 'Classification is an allowlist: only the declared classes below are ever ' +
369
+ 'deleted, so operator scratch files under tempRoot are reported with their ' +
370
+ 'size and left alone. signals.ndjson is never purged by any path.',
371
+ properties: {
372
+ enabled: {
373
+ type: 'boolean',
374
+ description:
375
+ "Master switch. Default true — reclaiming a landed Story's gate " +
376
+ 'transcripts and validation evidence is the behaviour, and this knob ' +
377
+ 'turns it off. When false every purge path is a reported no-op.',
378
+ },
379
+ staleDays: {
380
+ type: 'integer',
381
+ minimum: 1,
382
+ description:
383
+ 'Age floor (days, default 7) for the families no Story id can be ' +
384
+ 'recovered from — roster-level audit reports and abandoned ' +
385
+ 'plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are ' +
386
+ 'purged as soon as their merge is confirmed.',
387
+ },
388
+ classes: {
389
+ type: 'object',
390
+ description:
391
+ 'Per-class opt-out. Each defaults to true; set one false to keep that ' +
392
+ 'family while the rest are purged.',
393
+ properties: {
394
+ orchestrationLogs: {
395
+ type: 'boolean',
396
+ description:
397
+ '<tempRoot>/orchestration/*.log — close gate transcripts and ' +
398
+ 'terse-result detail dumps.',
399
+ },
400
+ validationEvidence: {
401
+ type: 'boolean',
402
+ description:
403
+ 'Per-Story validation-evidence.json, lifecycle.ndjson, and ' +
404
+ 'manifest.md under the standalone and per-run story trees.',
405
+ },
406
+ auditResults: {
407
+ type: 'boolean',
408
+ description: '<tempRoot>/audits/ — audit lens reports.',
409
+ },
410
+ planDirs: {
411
+ type: 'boolean',
412
+ description:
413
+ '<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. ' +
414
+ 'Age-floored only; the current run is always excluded.',
415
+ },
416
+ },
417
+ additionalProperties: false,
418
+ },
419
+ },
420
+ additionalProperties: false,
421
+ };
422
+
355
423
  export const DELIVERY_SCHEMA = {
356
424
  type: 'object',
357
425
  properties: {
358
426
  execution: EXECUTION_SCHEMA,
359
427
  lease: LEASE_SCHEMA,
360
428
  docsFreshness: DOCS_FRESHNESS_SCHEMA,
429
+ tempRetention: TEMP_RETENTION_SCHEMA,
361
430
  deliverRunner: DELIVER_RUNNER_SCHEMA,
362
431
  worktreeIsolation: WORKTREE_ISOLATION_SCHEMA,
363
432
  signals: SIGNALS_SCHEMA,
@@ -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
  /**