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.
- package/.agents/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
* @
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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) =>
|
|
62
|
-
*
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 {
|
|
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) =>
|
|
56
|
-
*
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
120
|
-
// source path (vitest's coverage-final.json
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
135
|
+
methodRows = (deps.calculateCrap ?? calculateCrapForSource)(
|
|
136
|
+
prepared.code,
|
|
137
|
+
entry,
|
|
138
|
+
prepared.mapLine,
|
|
139
|
+
);
|
|
142
140
|
} catch (err) {
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
503
|
-
|
|
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
|