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
@@ -1,4 +1,5 @@
1
- import { createRequire } from 'node:module';
1
+ import fs from 'node:fs';
2
+ import { createRequire, SourceMap } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import { Logger } from './Logger.js';
4
5
 
@@ -39,6 +40,71 @@ function isTypeScriptPath(filePath) {
39
40
  return TS_EXTS.has(path.extname(String(filePath)).toLowerCase());
40
41
  }
41
42
 
43
+ /**
44
+ * Trailing `//# sourceMappingURL=…` comment `ts.transpileModule` appends
45
+ * when `sourceMap: true` is requested. Stripping it makes the emitted code
46
+ * byte-identical to the `sourceMap: false` emit, which is what keeps the
47
+ * maintainability path — and every MI score in the committed baseline —
48
+ * untouched by the CRAP path opting into a map.
49
+ */
50
+ const SOURCE_MAPPING_URL_RE = /\n?\/\/# sourceMappingURL=[^\n]*\n?$/;
51
+
52
+ /**
53
+ * Build a `transpiledLine → originalLine` resolver over a raw
54
+ * `sourceMapText` payload, using Node's built-in `SourceMap` (no new
55
+ * runtime dependency).
56
+ *
57
+ * `SourceMap#findEntry(line, column)` is 0-based and returns the mapping at
58
+ * or before the requested position, so a bare `findEntry(line, 0)` can
59
+ * silently answer with a *previous* line's mapping when the requested line
60
+ * carries no mapping at column 0. The resolver therefore walks the columns
61
+ * of the generated line and accepts only an entry that actually originates
62
+ * on that generated line; a line with no mapping at all resolves to `null`
63
+ * and the caller falls back to the un-remapped coordinate.
64
+ *
65
+ * Results are memoised per generated line — a file's methods are looked up
66
+ * once each, but the same line is often probed by several callers.
67
+ *
68
+ * @param {string} sourceMapText Raw JSON source map emitted by TypeScript.
69
+ * @param {string} code The generated (transpiled) code the map describes.
70
+ * @returns {((line: number) => number|null)|null}
71
+ */
72
+ function buildLineMapper(sourceMapText, code) {
73
+ let sourceMap;
74
+ try {
75
+ sourceMap = new SourceMap(JSON.parse(sourceMapText));
76
+ } catch {
77
+ return null;
78
+ }
79
+ const lines = String(code).split('\n');
80
+ const memo = new Map();
81
+ return function mapLine(generatedLine) {
82
+ if (typeof generatedLine !== 'number' || generatedLine < 1) return null;
83
+ if (memo.has(generatedLine)) return memo.get(generatedLine);
84
+ const zeroBased = generatedLine - 1;
85
+ const lineText = lines[zeroBased] ?? '';
86
+ let resolved = null;
87
+ for (let column = 0; column <= lineText.length; column += 1) {
88
+ let entry;
89
+ try {
90
+ entry = sourceMap.findEntry(zeroBased, column);
91
+ } catch {
92
+ break;
93
+ }
94
+ if (
95
+ entry &&
96
+ entry.generatedLine === zeroBased &&
97
+ typeof entry.originalLine === 'number'
98
+ ) {
99
+ resolved = entry.originalLine + 1;
100
+ break;
101
+ }
102
+ }
103
+ memo.set(generatedLine, resolved);
104
+ return resolved;
105
+ };
106
+ }
107
+
42
108
  /**
43
109
  * Pre-transpile TypeScript or TSX sources to JavaScript that the
44
110
  * Esprima-based escomplex kernel can parse. Returns the input unchanged
@@ -54,12 +120,33 @@ function isTypeScriptPath(filePath) {
54
120
  * On transpile failure the helper returns `null` — callers treat that
55
121
  * as "skip this file" rather than crashing the scan.
56
122
  *
123
+ * **Line coordinates (Story #4775).** The transpile does not preserve line
124
+ * numbers: interface elision and the injected JSX-runtime import shift the
125
+ * emitted code relative to the original source. escomplex then reports each
126
+ * method's `lineStart` in *transpiled* coordinates while istanbul's `fnMap`
127
+ * is in *original source* coordinates — so a per-method coverage join keyed
128
+ * on the raw `lineStart` cannot match. Callers that need to join against
129
+ * coverage pass `{ withLineMap: true }` and receive
130
+ * `{ code, mapLine }`, where `mapLine(transpiledLine)` returns the original
131
+ * source line (or `null` when the line has no mapping).
132
+ *
133
+ * `withLineMap` is opt-in precisely so the maintainability path — which is
134
+ * module-level and never joins coverage — keeps paying nothing for a map it
135
+ * would not read, and keeps emitting byte-identical scores. A JavaScript
136
+ * input is a passthrough in both modes: `mapLine` is `null` because the
137
+ * coordinates already *are* original-source coordinates, so no remap is
138
+ * needed and none is computed.
139
+ *
57
140
  * @param {string} filePath
58
141
  * @param {string} source
59
- * @returns {string|null}
142
+ * @param {{withLineMap?: boolean}} [opts]
143
+ * @returns {string|null|{code: string, mapLine: ((line: number) => number|null)|null}}
60
144
  */
61
- export function transpileIfNeeded(filePath, source) {
62
- if (!isTypeScriptPath(filePath)) return source;
145
+ export function transpileIfNeeded(filePath, source, opts = {}) {
146
+ const withLineMap = opts?.withLineMap === true;
147
+ if (!isTypeScriptPath(filePath)) {
148
+ return withLineMap ? { code: source, mapLine: null } : source;
149
+ }
63
150
  const ts = loadTypeScript();
64
151
  if (!ts) {
65
152
  Logger.warn(
@@ -78,12 +165,18 @@ export function transpileIfNeeded(filePath, source) {
78
165
  importHelpers: false,
79
166
  removeComments: false,
80
167
  jsx: ts.JsxEmit.ReactJSX,
81
- sourceMap: false,
168
+ sourceMap: withLineMap,
82
169
  },
83
170
  fileName: path.basename(filePath),
84
171
  reportDiagnostics: false,
85
172
  });
86
- return result.outputText;
173
+ if (!withLineMap) return result.outputText;
174
+ const code = result.outputText.replace(SOURCE_MAPPING_URL_RE, '\n');
175
+ const mapLine =
176
+ typeof result.sourceMapText === 'string'
177
+ ? buildLineMapper(result.sourceMapText, code)
178
+ : null;
179
+ return { code, mapLine };
87
180
  } catch (err) {
88
181
  Logger.warn(
89
182
  `[Maintainability] ⚠ TS transpile failed for ${filePath}: ${err?.message ?? err}; skipping.`,
@@ -91,3 +184,37 @@ export function transpileIfNeeded(filePath, source) {
91
184
  return null;
92
185
  }
93
186
  }
187
+
188
+ /**
189
+ * Prepare a source file for scoring: read it, transpile TS/TSX with a
190
+ * line map, and hand back the JavaScript escomplex will parse alongside the
191
+ * transpiled → original-source line resolver the coverage join needs
192
+ * (Story #4775).
193
+ *
194
+ * Failure is reported as `{error: 'read'}` or `{error: 'transpile'}` rather
195
+ * than a bare null: CRAP drops the file either way, but the combined MI path
196
+ * distinguishes them (a read failure drops the MI score, a transpile failure
197
+ * scores it 0 — matching `calculateForFile`).
198
+ *
199
+ * @param {string} abs Absolute path of the source file.
200
+ * @param {{readFile?: (p: string) => string, transpile?: Function}} [deps]
201
+ * @returns {{code: string, mapLine: ((line: number) => number|null)|null}
202
+ * | {error: 'read'|'transpile'}}
203
+ */
204
+ export function prepareSourceForScoring(abs, deps = {}) {
205
+ const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
206
+ const transpile = deps.transpile ?? transpileIfNeeded;
207
+ let source;
208
+ try {
209
+ source = readFile(abs);
210
+ } catch {
211
+ return { error: 'read' };
212
+ }
213
+ const prepared = transpile(abs, source, { withLineMap: true });
214
+ if (prepared === null || prepared === undefined)
215
+ return { error: 'transpile' };
216
+ // Tolerate a `deps.transpile` stub that still returns a bare string.
217
+ if (typeof prepared === 'string') return { code: prepared, mapLine: null };
218
+ if (typeof prepared.code !== 'string') return { error: 'transpile' };
219
+ return { code: prepared.code, mapLine: prepared.mapLine ?? null };
220
+ }
@@ -32,6 +32,9 @@
32
32
  * skippedFileNoCoverage: boolean,
33
33
  * crapRows: Array<{ method, startLine, cyclomatic, coverage, crap }> | null,
34
34
  * skippedMethodsNoCoverage: number,
35
+ * hasCoverageEntry: boolean,
36
+ * resolvedMethods: number,
37
+ * totalMethods: number,
35
38
  * } }
36
39
  *
37
40
  * A read/transpile/parse failure surfaces as `crapRows: null` so the host
@@ -40,10 +43,10 @@
40
43
  * `miScore` is `null`; on a transpile/parse failure `miScore` is `0`.
41
44
  */
42
45
 
43
- import fs from 'node:fs';
44
46
  import { parentPort } from 'node:worker_threads';
47
+ import { finalizeMethodRows } from '../crap-engine.js';
45
48
  import { analyzeOnce } from '../crap-utils.js';
46
- import { transpileIfNeeded } from '../transpile.js';
49
+ import { prepareSourceForScoring } from '../transpile.js';
47
50
 
48
51
  /**
49
52
  * Pure handler for a single inbound worker message. Exported so unit tests
@@ -58,8 +61,9 @@ import { transpileIfNeeded } from '../transpile.js';
58
61
  * @param {unknown} msg
59
62
  * @param {{
60
63
  * readFile?: (abs: string) => string,
61
- * transpile?: (abs: string, source: string) => string | null,
62
- * analyze?: (source: string, entry: object|null) => {
64
+ * transpile?: (abs: string, source: string, opts?: object) => unknown,
65
+ * prepare?: (abs: string, deps: object) => object,
66
+ * analyze?: (source: string, entry: object|null, mapLine: Function|null) => {
63
67
  * miScore: number,
64
68
  * crapRows: Array<object>,
65
69
  * parseError: boolean,
@@ -85,8 +89,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
85
89
  };
86
90
  }
87
91
  const { abs, relPath, requireCoverage } = item;
88
- const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
89
- const transpile = deps.transpile ?? transpileIfNeeded;
92
+ const prepare = deps.prepare ?? prepareSourceForScoring;
90
93
  const analyze = deps.analyze ?? analyzeOnce;
91
94
 
92
95
  // Coverage entry is pre-resolved on the host and attached to the item.
@@ -94,47 +97,33 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
94
97
  // coverage, or `undefined` when the caller did not supply it (treat as null).
95
98
  const entry = item.coverageEntry ?? null;
96
99
 
97
- // Read the source once. A read failure means neither MI nor CRAP can be
98
- // computed — MI drops (null), CRAP drops (rows null) — matching the two
99
- // passes' read-failure contracts (calculateAll → score null; crap worker
100
- // → rows null).
101
- let source;
102
- try {
103
- source = readFile(abs);
104
- } catch {
105
- return {
106
- kind: 'reply',
107
- message: {
108
- ok: true,
109
- result: {
110
- relPath,
111
- miScore: null,
112
- skippedFileNoCoverage: false,
113
- crapRows: null,
114
- skippedMethodsNoCoverage: 0,
115
- },
100
+ const reply = (result) => ({
101
+ kind: 'reply',
102
+ message: {
103
+ ok: true,
104
+ result: {
105
+ relPath,
106
+ skippedFileNoCoverage: false,
107
+ skippedMethodsNoCoverage: 0,
108
+ hasCoverageEntry: entry !== null,
109
+ resolvedMethods: 0,
110
+ totalMethods: 0,
111
+ ...result,
116
112
  },
117
- };
118
- }
113
+ },
114
+ });
119
115
 
120
- // TS/TSX strip-then-analyze. A transpile failure yields miScore 0
121
- // (calculateForFile returns 0 when transpileIfNeeded returns null) and a
122
- // null CRAP contribution (crap worker returns rows: null).
123
- const prepared = transpile(abs, source);
124
- if (prepared === null) {
125
- return {
126
- kind: 'reply',
127
- message: {
128
- ok: true,
129
- result: {
130
- relPath,
131
- miScore: 0,
132
- skippedFileNoCoverage: false,
133
- crapRows: null,
134
- skippedMethodsNoCoverage: 0,
135
- },
136
- },
137
- };
116
+ // Read + transpile once, carrying the transpiled -> original-source line
117
+ // map the per-method coverage join needs (Story #4775). A read failure
118
+ // means neither MI nor CRAP can be computed (MI drops as null, matching
119
+ // `calculateAll`'s filter); a transpile failure scores MI 0 (matching
120
+ // `calculateForFile`). CRAP drops the file either way.
121
+ const prepared = prepare(abs, deps);
122
+ if (prepared.error) {
123
+ return reply({
124
+ miScore: prepared.error === 'read' ? null : 0,
125
+ crapRows: null,
126
+ });
138
127
  }
139
128
 
140
129
  // ONE parse: analyzeOnce derives both the module MI score and the raw
@@ -144,24 +133,12 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
144
133
  miScore,
145
134
  crapRows: rawCrapRows,
146
135
  parseError,
147
- } = analyze(prepared, entry);
136
+ } = analyze(prepared.code, entry, prepared.mapLine);
148
137
  if (parseError) {
149
- // Parse error: MI scores 0 (parity with calculateForSource's catch
138
+ // Parse error: MI scores 0 (parity with calculateForSource's catch ->
150
139
  // returns 0), CRAP drops the file (rows null, parity with the crap
151
140
  // worker's calculateCrap-throw branch).
152
- return {
153
- kind: 'reply',
154
- message: {
155
- ok: true,
156
- result: {
157
- relPath,
158
- miScore: 0,
159
- skippedFileNoCoverage: false,
160
- crapRows: null,
161
- skippedMethodsNoCoverage: 0,
162
- },
163
- },
164
- };
141
+ return reply({ miScore: 0, crapRows: null });
165
142
  }
166
143
 
167
144
  // CRAP coverage gate runs AFTER the parse so the MI score is always
@@ -169,49 +146,18 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
169
146
  // pass would have skipped it at the file level (no rows, counted) — but the
170
147
  // MI pass would still have scored it, so miScore is returned regardless.
171
148
  if (requireCoverage && entry === null) {
172
- return {
173
- kind: 'reply',
174
- message: {
175
- ok: true,
176
- result: {
177
- relPath,
178
- miScore,
179
- skippedFileNoCoverage: true,
180
- crapRows: [],
181
- skippedMethodsNoCoverage: 0,
182
- },
183
- },
184
- };
149
+ return reply({ miScore, skippedFileNoCoverage: true, crapRows: [] });
185
150
  }
186
151
 
187
- const crapRows = [];
188
- let skippedMethodsNoCoverage = 0;
189
- for (const mr of rawCrapRows) {
190
- if (mr.crap === null || mr.coverage === null) {
191
- skippedMethodsNoCoverage += 1;
192
- continue;
193
- }
194
- crapRows.push({
195
- method: mr.method,
196
- startLine: mr.startLine,
197
- cyclomatic: mr.cyclomatic,
198
- coverage: mr.coverage,
199
- crap: mr.crap,
200
- });
201
- }
202
- return {
203
- kind: 'reply',
204
- message: {
205
- ok: true,
206
- result: {
207
- relPath,
208
- miScore,
209
- skippedFileNoCoverage: false,
210
- crapRows,
211
- skippedMethodsNoCoverage,
212
- },
213
- },
214
- };
152
+ const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
153
+ finalizeMethodRows(rawCrapRows, { requireCoverage });
154
+ return reply({
155
+ miScore,
156
+ crapRows: rows,
157
+ skippedMethodsNoCoverage,
158
+ resolvedMethods,
159
+ totalMethods,
160
+ });
215
161
  }
216
162
 
217
163
  if (parentPort) {
@@ -18,6 +18,9 @@
18
18
  * skippedFileNoCoverage: boolean,
19
19
  * rows: Array<{ method, startLine, cyclomatic, coverage, crap }>,
20
20
  * skippedMethodsNoCoverage: number,
21
+ * hasCoverageEntry: boolean,
22
+ * resolvedMethods: number,
23
+ * totalMethods: number,
21
24
  * } }
22
25
  *
23
26
  * A truly unrecoverable per-file failure (read error, transpile null)
@@ -26,10 +29,9 @@
26
29
  * aborts the whole scan.
27
30
  */
28
31
 
29
- import fs from 'node:fs';
30
32
  import { parentPort } from 'node:worker_threads';
31
- import { calculateCrapForSource } from '../crap-engine.js';
32
- import { transpileIfNeeded } from '../transpile.js';
33
+ import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
34
+ import { prepareSourceForScoring } from '../transpile.js';
33
35
 
34
36
  /**
35
37
  * Pure handler for a single inbound worker message. Exported so unit
@@ -42,7 +44,10 @@ import { transpileIfNeeded } from '../transpile.js';
42
44
  * - `{ kind: 'reply', message }` — caller should `postMessage(message)`.
43
45
  *
44
46
  * Side effects (fs, transpile, escomplex) are wired through `deps` so
45
- * tests pass deterministic stubs.
47
+ * tests pass deterministic stubs. `readFile` / `transpile` are forwarded to
48
+ * `prepareSourceForScoring`, which also derives the transpiled →
49
+ * original-source line map the coverage join needs (Story #4775); a
50
+ * `transpile` stub that still returns a bare string is tolerated.
46
51
  *
47
52
  * Coverage is supplied via `item.coverageEntry` (pre-resolved on the host),
48
53
  * not via a whole-map `coverage` argument. The second parameter is kept as
@@ -52,8 +57,9 @@ import { transpileIfNeeded } from '../transpile.js';
52
57
  * @param {object|null} _coverage - Unused. Coverage is in `item.coverageEntry`.
53
58
  * @param {{
54
59
  * readFile?: (abs: string) => string,
55
- * transpile?: (abs: string, source: string) => string | null,
56
- * calculateCrap?: (source: string, entry: object|null) => Array<object>,
60
+ * transpile?: (abs: string, source: string, opts?: object) => unknown,
61
+ * prepare?: (abs: string, deps: object) => object,
62
+ * calculateCrap?: (source: string, entry: object|null, mapLine: Function|null) => Array<object>,
57
63
  * }} [deps]
58
64
  * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
59
65
  */
@@ -75,9 +81,6 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
75
81
  };
76
82
  }
77
83
  const { abs, relPath, requireCoverage } = item;
78
- const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
79
- const transpile = deps.transpile ?? transpileIfNeeded;
80
- const calculateCrap = deps.calculateCrap ?? calculateCrapForSource;
81
84
 
82
85
  // Coverage entry is pre-resolved on the host and attached to the item.
83
86
  // `item.coverageEntry` may be explicitly `null` when the file has no
@@ -93,84 +96,54 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
93
96
  skippedFileNoCoverage: true,
94
97
  rows: [],
95
98
  skippedMethodsNoCoverage: 0,
99
+ hasCoverageEntry: false,
100
+ resolvedMethods: 0,
101
+ totalMethods: 0,
96
102
  },
97
103
  },
98
104
  };
99
105
  }
100
106
 
101
- let source;
102
- try {
103
- source = readFile(abs);
104
- } catch {
105
- return {
106
- kind: 'reply',
107
- message: {
108
- ok: true,
109
- result: {
110
- relPath,
111
- skippedFileNoCoverage: false,
112
- rows: null,
113
- skippedMethodsNoCoverage: 0,
114
- },
107
+ const dropped = (error) => ({
108
+ kind: 'reply',
109
+ message: {
110
+ ok: true,
111
+ result: {
112
+ relPath,
113
+ skippedFileNoCoverage: false,
114
+ rows: null,
115
+ skippedMethodsNoCoverage: 0,
116
+ hasCoverageEntry: entry !== null,
117
+ resolvedMethods: 0,
118
+ totalMethods: 0,
119
+ ...(error ? { error } : {}),
115
120
  },
116
- };
117
- }
121
+ },
122
+ });
118
123
 
119
- // TS/TSX strip-then-analyze. Coverage lookup above used the original
120
- // source path (vitest's coverage-final.json keys on the .ts file, not
121
- // transpiled output); the transpile is purely about making the code
122
- // parseable by the Esprima-based escomplex kernel.
123
- const prepared = transpile(abs, source);
124
- if (prepared === null) {
125
- return {
126
- kind: 'reply',
127
- message: {
128
- ok: true,
129
- result: {
130
- relPath,
131
- skippedFileNoCoverage: false,
132
- rows: null,
133
- skippedMethodsNoCoverage: 0,
134
- },
135
- },
136
- };
137
- }
124
+ // TS/TSX -> transpile-then-analyze, carrying the source map. The coverage
125
+ // lookup above used the ORIGINAL source path (vitest's coverage-final.json
126
+ // keys on the .ts file, not transpiled output) and the per-method join
127
+ // below uses ORIGINAL source *lines*, remapped from escomplex's transpiled
128
+ // coordinates via `prepared.mapLine` (Story #4775).
129
+ const prepare = deps.prepare ?? prepareSourceForScoring;
130
+ const prepared = prepare(abs, deps);
131
+ if (prepared.error) return dropped(null);
138
132
 
139
133
  let methodRows;
140
134
  try {
141
- methodRows = calculateCrap(prepared, entry);
135
+ methodRows = (deps.calculateCrap ?? calculateCrapForSource)(
136
+ prepared.code,
137
+ entry,
138
+ prepared.mapLine,
139
+ );
142
140
  } catch (err) {
143
- return {
144
- kind: 'reply',
145
- message: {
146
- ok: true,
147
- result: {
148
- relPath,
149
- skippedFileNoCoverage: false,
150
- rows: null,
151
- skippedMethodsNoCoverage: 0,
152
- error:
153
- err && typeof err.message === 'string' ? err.message : String(err),
154
- },
155
- },
156
- };
141
+ return dropped(
142
+ err && typeof err.message === 'string' ? err.message : String(err),
143
+ );
157
144
  }
158
145
 
159
- const rows = [];
160
- let skippedMethodsNoCoverage = 0;
161
- for (const mr of methodRows) {
162
- if (mr.crap === null || mr.coverage === null) {
163
- skippedMethodsNoCoverage += 1;
164
- continue;
165
- }
166
- rows.push({
167
- method: mr.method,
168
- startLine: mr.startLine,
169
- cyclomatic: mr.cyclomatic,
170
- coverage: mr.coverage,
171
- crap: mr.crap,
172
- });
173
- }
146
+ const finalized = finalizeMethodRows(methodRows, { requireCoverage });
174
147
  return {
175
148
  kind: 'reply',
176
149
  message: {
@@ -178,8 +151,8 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
178
151
  result: {
179
152
  relPath,
180
153
  skippedFileNoCoverage: false,
181
- rows,
182
- skippedMethodsNoCoverage,
154
+ hasCoverageEntry: entry !== null,
155
+ ...finalized,
183
156
  },
184
157
  },
185
158
  };
@@ -15,6 +15,7 @@
15
15
 
16
16
  import fs from 'node:fs';
17
17
  import { rm as fsPromisesRm } from 'node:fs/promises';
18
+ import { fileURLToPath } from 'node:url';
18
19
  import { isInsideWorktree, samePath } from '../inspector.js';
19
20
  import { sleepSync } from '../node-modules-strategy.js';
20
21
  import { checkMergeReachability } from './merge-reachability.js';
@@ -493,20 +494,92 @@ async function deleteBranchAfterReap(ctx, { branch, push }) {
493
494
  return { branchDeleted, remoteBranchDeleted };
494
495
  }
495
496
 
497
+ /**
498
+ * The on-disk paths of the code currently executing: the entry script and
499
+ * this module itself.
500
+ *
501
+ * @returns {string[]}
502
+ */
503
+ function runningCodePaths() {
504
+ const paths = [];
505
+ const entry = process.argv[1];
506
+ if (typeof entry === 'string' && entry !== '') paths.push(entry);
507
+ try {
508
+ paths.push(fileURLToPath(import.meta.url));
509
+ } catch {
510
+ // A non-file module URL cannot be inside a worktree; nothing to add.
511
+ }
512
+ return paths;
513
+ }
514
+
515
+ /**
516
+ * Return the running-code path that lives inside `wtPath`, or `null`.
517
+ *
518
+ * `escapeWorktreeCwd` already handles the *cwd* being inside the doomed
519
+ * tree; this is the other half — the **code** being inside it. An agent that
520
+ * invokes `node .agents/scripts/single-story-close.js` with its shell cwd set
521
+ * to the Story worktree runs the WORKTREE's copy of the script, and reaping
522
+ * the tree then deletes the running program out from under itself. Node has
523
+ * already loaded the static module graph, so the process does not die on the
524
+ * spot — it dies later, at the first lazy read or dynamic `import()`, in
525
+ * whatever phase happens to need one. That is how a close whose PR merged,
526
+ * whose Story flipped to `agent::done`, and whose post-land tail was green
527
+ * still exited non-zero with no terminal envelope.
528
+ *
529
+ * Refusing the reap is cheap: the tree survives one extra cycle and the next
530
+ * boot sweep takes it. Reaping it costs the run's return contract.
531
+ *
532
+ * @param {object} ctx
533
+ * @param {string} wtPath
534
+ * @returns {string|null}
535
+ */
536
+ function findRunningCodeInside(ctx, wtPath) {
537
+ return (
538
+ runningCodePaths().find((p) => isInsideWorktree(p, wtPath, ctx.platform)) ??
539
+ null
540
+ );
541
+ }
542
+
543
+ /**
544
+ * The refusals that disqualify a tree before any git work happens, in the
545
+ * order they are checked. Returns the refusal envelope, or `null` when the
546
+ * tree is eligible for the safety checks that follow.
547
+ *
548
+ * @param {object} ctx
549
+ * @param {object} opts
550
+ * @param {string} wtPath
551
+ * @returns {object|null}
552
+ */
553
+ function firstReapRefusal(ctx, opts, wtPath) {
554
+ const known = opts.worktrees
555
+ ? opts.worktrees.some((r) => samePath(r.path, wtPath, ctx.platform))
556
+ : findByPath(ctx, wtPath) !== null;
557
+ if (!known) return { removed: false, reason: 'not-a-worktree', path: wtPath };
558
+
559
+ const selfPath = findRunningCodeInside(ctx, wtPath);
560
+ if (!selfPath) return null;
561
+ ctx.logger.warn(
562
+ `reap-skipped reason=running-from-target-tree path=${wtPath} selfPath=${selfPath} — ` +
563
+ `the running process was loaded from this worktree; removing it would break every ` +
564
+ `later lazy read and dynamic import in this run. Left for the next sweep. ` +
565
+ `Invoke the script by its MAIN-checkout path to reap in-run.`,
566
+ );
567
+ return {
568
+ removed: false,
569
+ reason: 'running-from-target-tree',
570
+ path: wtPath,
571
+ selfPath,
572
+ };
573
+ }
574
+
496
575
  function checkReapPreconditions(ctx, _storyId, opts, wtPath) {
497
576
  if (opts.force) {
498
577
  throw new Error(
499
578
  'WorktreeManager.reap: --force is not permitted by the framework',
500
579
  );
501
580
  }
502
- const known = opts.worktrees
503
- ? opts.worktrees.some((r) => samePath(r.path, wtPath, ctx.platform))
504
- : findByPath(ctx, wtPath) !== null;
505
- if (!known)
506
- return {
507
- ok: false,
508
- result: { removed: false, reason: 'not-a-worktree', path: wtPath },
509
- };
581
+ const refusal = firstReapRefusal(ctx, opts, wtPath);
582
+ if (refusal) return { ok: false, result: refusal };
510
583
  // Story #4539 removed an `epic-branch-required` gate here: a
511
584
  // `story-<id>` worktree used to be unreapable unless the caller supplied
512
585
  // an Epic integration branch. v2 has no Epic branch, and the only v2