mandrel 2.16.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 (57) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/schemas/agentrc.schema.json +6 -0
  4. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  5. package/.agents/schemas/baselines/crap.schema.json +4 -0
  6. package/.agents/scripts/acceptance-eval.js +52 -12
  7. package/.agents/scripts/audit-to-stories.js +92 -25
  8. package/.agents/scripts/boot-sweep.js +28 -6
  9. package/.agents/scripts/check-baseline-drift.js +138 -0
  10. package/.agents/scripts/coverage-capture.js +74 -25
  11. package/.agents/scripts/deliver-recover.js +45 -18
  12. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  13. package/.agents/scripts/generate-lens-checklists.js +81 -30
  14. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  15. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  16. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  17. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  18. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  19. package/.agents/scripts/lib/baselines/reader.js +12 -1
  20. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  21. package/.agents/scripts/lib/baselines/writer.js +10 -0
  22. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  23. package/.agents/scripts/lib/cli-utils.js +48 -13
  24. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  25. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  26. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  27. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  28. package/.agents/scripts/lib/config/quality.js +40 -0
  29. package/.agents/scripts/lib/coverage-utils.js +92 -9
  30. package/.agents/scripts/lib/crap-engine.js +113 -23
  31. package/.agents/scripts/lib/crap-utils.js +159 -93
  32. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  33. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  34. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  35. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  37. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
  40. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  41. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  42. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  43. package/.agents/scripts/lib/stdio-flush.js +71 -0
  44. package/.agents/scripts/lib/transpile.js +133 -6
  45. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  46. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  47. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  48. package/.agents/scripts/nav-registry-diff.js +30 -8
  49. package/.agents/scripts/plan-run-epilogue.js +27 -11
  50. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  51. package/.agents/scripts/single-story-close.js +9 -92
  52. package/.agents/scripts/update-crap-baseline.js +13 -0
  53. package/README.md +14 -6
  54. package/docs/CHANGELOG.md +24 -0
  55. package/lib/cli/version-helpers.js +7 -0
  56. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  57. package/package.json +5 -1
@@ -0,0 +1,303 @@
1
+ // .agents/scripts/lib/close-validation/projections/crap.js
2
+ /**
3
+ * crap.js — pre-merge CRAP ceiling projection helper (Story #4776).
4
+ *
5
+ * The CRAP analogue of `projections/maintainability.js`. Both answer the
6
+ * same operator question before the merge runs: *given what this branch
7
+ * changed, which committed baseline rows would the post-merge tree breach,
8
+ * and what is the exact remedy?*
9
+ *
10
+ * Advisory only. `check-baselines` already fails close-validation closed on
11
+ * a real regression; duplicating that here would double-gate the same
12
+ * defect. What this projection adds is the **refresh** half of the loop —
13
+ * naming the breaching methods and the `npm run crap:update` +
14
+ * `baseline-refresh:` remedy while the operator still has the branch in
15
+ * hand, so a corrected baseline does not rot back into staleness.
16
+ *
17
+ * The helper never throws and never mutates anything. Every failure path
18
+ * resolves to `{ ok: true, breaches: [], skipped: '<reason>' }` so the
19
+ * caller can treat the advisory as best-effort.
20
+ *
21
+ * Post-merge approximation: CRAP needs a coverage join, so — unlike the MI
22
+ * projection, which scores a `git show` blob — the scorer reads the working
23
+ * tree. Close-validation runs inside the Story worktree at the branch tip,
24
+ * which is exactly the content a squash-merge lands, so the approximation is
25
+ * exact whenever the merge applies cleanly.
26
+ */
27
+
28
+ import path from 'node:path';
29
+ import {
30
+ compareCrap,
31
+ filterRowsByFileScope,
32
+ } from '../../baselines/kinds/crap.js';
33
+ import { loadFile as loadBaselineFile } from '../../baselines/reader.js';
34
+ import { diffNameOnly } from '../../changed-files.js';
35
+ import { loadCoverage } from '../../coverage-utils.js';
36
+ import { scanAndScore } from '../../crap-utils.js';
37
+ import { cachedGitFetchSync } from '../../git/cached-fetch.js';
38
+ import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
39
+ import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
40
+
41
+ /**
42
+ * Default absolute tolerance on a projected CRAP score, shared with
43
+ * `check-crap`'s regression arm: floating-point noise must not register as
44
+ * a breach.
45
+ */
46
+ export const DEFAULT_CRAP_TOLERANCE = 0.001;
47
+
48
+ /** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
49
+ export const DEFAULT_NEW_METHOD_CEILING = 30;
50
+
51
+ /** Extensions the CRAP scanner can score. */
52
+ const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
53
+
54
+ /**
55
+ * Map the shared predicate's fine-grained `missing-*` reason onto the
56
+ * `missing-args` skipped-reason the sibling MI projection reports, so both
57
+ * projections speak one vocabulary at the advisory boundary.
58
+ *
59
+ * @param {string} reason
60
+ * @returns {string}
61
+ */
62
+ function normaliseSkipReason(reason) {
63
+ return MISSING_ARG_REASONS.has(reason) ? 'missing-args' : reason;
64
+ }
65
+
66
+ /**
67
+ * Read the committed CRAP baseline and re-key its rows onto the `file`
68
+ * field `compareCrap` matches on (the on-disk v2 envelope keys on `path`).
69
+ * Returns `[]` when the baseline is absent or unreadable — the caller maps
70
+ * that to the `no-baseline` skip.
71
+ *
72
+ * Deliberately module-local: `projectCrapBreaches`'s `loadBaseline` default
73
+ * is the single production door to it, and tests inject their own loader.
74
+ * Exporting it would add a second entry point nothing in production reaches
75
+ * — which is precisely the orphaning this Story exists to stop.
76
+ *
77
+ * @param {string} baselinePath absolute path to `baselines/crap.json`
78
+ * @returns {Array<{file: string, method: string, startLine: number, crap: number}>}
79
+ */
80
+ function loadCrapBaselineRows(baselinePath) {
81
+ let envelope;
82
+ try {
83
+ envelope = loadBaselineFile(baselinePath, { kind: 'crap' });
84
+ } catch {
85
+ return [];
86
+ }
87
+ const rows = Array.isArray(envelope?.rows) ? envelope.rows : [];
88
+ return rows.map((row) => ({
89
+ file: row.path,
90
+ method: row.method,
91
+ startLine: row.startLine,
92
+ crap: row.crap,
93
+ }));
94
+ }
95
+
96
+ /**
97
+ * Build the default working-tree CRAP scorer. Returns an async callable
98
+ * `(files) => rows | null`; `null` signals "coverage artifact missing under
99
+ * `requireCoverage`", which the projection reports as the `no-coverage`
100
+ * skip rather than as a clean run.
101
+ *
102
+ * @param {{
103
+ * cwd: string,
104
+ * targetDirs?: string[],
105
+ * ignoreGlobs?: string[],
106
+ * requireCoverage?: boolean,
107
+ * coveragePath?: string,
108
+ * }} opts
109
+ * @returns {(files: string[]) => Promise<Array<object>|null>}
110
+ */
111
+ export function createCrapScorer({
112
+ cwd,
113
+ targetDirs = [],
114
+ ignoreGlobs = [],
115
+ requireCoverage = true,
116
+ coveragePath = 'coverage/coverage-final.json',
117
+ } = {}) {
118
+ return async (files) => {
119
+ const abs = path.isAbsolute(coveragePath)
120
+ ? coveragePath
121
+ : path.resolve(cwd, coveragePath);
122
+ const coverage = loadCoverage(abs);
123
+ if (!coverage && requireCoverage) return null;
124
+ const { rows } = await scanAndScore({
125
+ targetDirs,
126
+ coverage,
127
+ requireCoverage,
128
+ cwd,
129
+ ignoreGlobs,
130
+ scopeFiles: files,
131
+ });
132
+ return rows ?? [];
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Refresh `origin/<baseBranch>` so the diff range resolves even when close
138
+ * has not reached its own base-sync step. Routed through the shared fetch
139
+ * cache so a story-init fetch in the same run satisfies it for free.
140
+ *
141
+ * @param {string} cwd
142
+ * @param {string} baseBranch
143
+ * @param {{ gitSpawn: typeof defaultGitSpawn }} git
144
+ * @returns {{ ok: true } | { ok: false, detail: string }}
145
+ */
146
+ function refreshBaseRef(cwd, baseBranch, git) {
147
+ const res = cachedGitFetchSync(cwd, baseBranch, { gitSpawn: git.gitSpawn });
148
+ if (res.status !== 0) {
149
+ return {
150
+ ok: false,
151
+ detail: res.stderr || res.stdout || `exit ${res.status}`,
152
+ };
153
+ }
154
+ return { ok: true };
155
+ }
156
+
157
+ /**
158
+ * Enumerate the Story branch's changed files, narrowed to the extensions
159
+ * the CRAP scanner can score.
160
+ *
161
+ * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
162
+ * @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
163
+ */
164
+ function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
165
+ try {
166
+ const files = diffNameOnly({
167
+ range: `origin/${baseBranch}...${storyBranch}`,
168
+ cwd,
169
+ gitSpawn: git.gitSpawn,
170
+ });
171
+ return { ok: true, files: files.filter((f) => SCORABLE.test(f)) };
172
+ } catch (err) {
173
+ return { ok: false, detail: err.message };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Project the post-merge CRAP scores for every file changed on the Story
179
+ * branch and return the subset of methods that would breach either their
180
+ * committed baseline row or, for methods with no baseline row, the
181
+ * configured `newMethodCeiling`.
182
+ *
183
+ * Baseline rows are narrowed to the changed-file set before the compare so
184
+ * every untouched file's rows are not spuriously reported as removed.
185
+ *
186
+ * @param {{
187
+ * cwd: string,
188
+ * baseBranch: string,
189
+ * storyBranch: string,
190
+ * baselinePath: string,
191
+ * newMethodCeiling?: number,
192
+ * tolerance?: number,
193
+ * git?: { gitSpawn: typeof defaultGitSpawn },
194
+ * loadBaseline?: (path: string) => Array<object>,
195
+ * scoreFiles?: (files: string[]) => Promise<Array<object>|null>|Array<object>|null,
196
+ * }} opts
197
+ * @returns {Promise<{
198
+ * ok: boolean,
199
+ * breaches: Array<object>,
200
+ * skipped?: string,
201
+ * detail?: string,
202
+ * }>}
203
+ */
204
+ export async function projectCrapBreaches({
205
+ cwd,
206
+ baseBranch,
207
+ storyBranch,
208
+ baselinePath,
209
+ newMethodCeiling = DEFAULT_NEW_METHOD_CEILING,
210
+ tolerance = DEFAULT_CRAP_TOLERANCE,
211
+ git = { gitSpawn: defaultGitSpawn },
212
+ loadBaseline = loadCrapBaselineRows,
213
+ scoreFiles,
214
+ } = {}) {
215
+ const skip = (reason, detail) => ({
216
+ ok: true,
217
+ breaches: [],
218
+ skipped: reason,
219
+ ...(detail === undefined ? {} : { detail }),
220
+ });
221
+
222
+ const validation = validateProjectionInputs({
223
+ cwd,
224
+ baseBranch,
225
+ storyBranch,
226
+ baselinePath,
227
+ });
228
+ if (!validation.ok) return skip(normaliseSkipReason(validation.reason));
229
+
230
+ const baselineRows = loadBaseline(baselinePath);
231
+ if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
232
+ return skip('no-baseline');
233
+ }
234
+
235
+ const fetched = refreshBaseRef(cwd, baseBranch, git);
236
+ if (!fetched.ok) return skip('fetch-failed', fetched.detail);
237
+
238
+ const diffed = diffScorableFiles({ cwd, baseBranch, storyBranch, git });
239
+ if (!diffed.ok) return skip('diff-failed', diffed.detail);
240
+ if (diffed.files.length === 0) return skip('no-scorable-files');
241
+
242
+ const scorer =
243
+ typeof scoreFiles === 'function' ? scoreFiles : createCrapScorer({ cwd });
244
+ let currentRows;
245
+ try {
246
+ currentRows = await scorer(diffed.files);
247
+ } catch (err) {
248
+ return skip('score-failed', err?.message ?? String(err));
249
+ }
250
+ if (currentRows === null || currentRows === undefined) {
251
+ return skip('no-coverage');
252
+ }
253
+ if (currentRows.length === 0) return skip('no-scored-methods');
254
+
255
+ const scopeSet = new Set(diffed.files);
256
+ const result = compareCrap({
257
+ currentRows,
258
+ baselineRows: filterRowsByFileScope(baselineRows, scopeSet),
259
+ newMethodCeiling,
260
+ tolerance,
261
+ });
262
+ const breaches = result.violations ?? [];
263
+ return { ok: breaches.length === 0, breaches };
264
+ }
265
+
266
+ /**
267
+ * Render one breach as an advisory bullet. New-method breaches name the
268
+ * ceiling they cleared; regressions name the baseline row they exceeded.
269
+ *
270
+ * @param {object} b
271
+ * @returns {string}
272
+ */
273
+ function formatBreach(b) {
274
+ const where = `${b.file}::${b.method} (line ${b.startLine})`;
275
+ const projected = Number(b.crap ?? 0).toFixed(2);
276
+ if (b.kind === 'new') {
277
+ return ` • ${where} projected=${projected} ceiling=${b.ceiling} [new method]`;
278
+ }
279
+ const baseline = Number(b.baseline ?? 0).toFixed(2);
280
+ return ` • ${where} projected=${projected} baseline=${baseline} [${b.kind}]`;
281
+ }
282
+
283
+ /**
284
+ * Render the pre-merge CRAP advisory as a human-readable multi-line log
285
+ * block, naming every breaching method and the exact refresh remedy.
286
+ * Returns `null` when there is nothing to surface so callers can `if` past
287
+ * the log call without a string-empty check.
288
+ *
289
+ * @param {Awaited<ReturnType<typeof projectCrapBreaches>>} result
290
+ * @returns {string | null}
291
+ */
292
+ export function formatCrapProjection(result) {
293
+ if (!result || !Array.isArray(result.breaches)) return null;
294
+ if (result.breaches.length === 0) return null;
295
+ const lines = [
296
+ `[close-validation] ⚠ Pre-merge CRAP projection: ${result.breaches.length} method(s) would breach post-merge:`,
297
+ ];
298
+ for (const b of result.breaches) lines.push(formatBreach(b));
299
+ lines.push(
300
+ '[close-validation] To land cleanly, run `npm run crap:update` and commit the refreshed baseline with a `baseline-refresh:` tagged subject (non-empty body) on the story branch before re-running close.',
301
+ );
302
+ return lines.join('\n');
303
+ }
@@ -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(
@@ -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
  */