mandrel 2.28.0 → 2.30.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/configuration.md +3 -0
- package/.agents/schemas/agentrc.schema.json +14 -0
- package/.agents/scripts/coverage-capture.js +31 -62
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +41 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +135 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +13 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +7 -87
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +26 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +2 -0
- package/.agents/scripts/lib/config/quality.js +41 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +105 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +116 -0
- package/.agents/scripts/lib/coverage-capture.js +101 -17
- package/.agents/scripts/lib/crap-baseline-index.js +46 -0
- package/.agents/scripts/lib/crap-baseline-join.js +153 -0
- package/.agents/scripts/lib/crap-coordinates.js +43 -0
- package/.agents/scripts/lib/crap-engine.js +26 -51
- package/.agents/scripts/lib/crap-utils-incremental.js +113 -0
- package/.agents/scripts/lib/crap-utils.js +118 -13
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coverage-capture-fullscope.js — the full-scope (pre-#4981, and still the
|
|
3
|
+
* default) capture path for `coverage-capture.js`.
|
|
4
|
+
*
|
|
5
|
+
* Hoisted out of the CLI shell's `runCoverageCapture` verbatim (Story
|
|
6
|
+
* #4981) so that function's cyclomatic complexity does not grow with the
|
|
7
|
+
* incremental-mode branch alongside it — this is a relocation, not new
|
|
8
|
+
* logic; behaviour is byte-for-byte the pre-#4981 body.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { anyChangedUnderTargets } from './coverage-capture.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run the `--skip-when-no-crap-files` check (when requested), the
|
|
15
|
+
* content-digest freshness probe, and — when stale — the full-repo
|
|
16
|
+
* `npm run test:coverage` capture + stamp write.
|
|
17
|
+
*
|
|
18
|
+
* @param {{
|
|
19
|
+
* crap: object,
|
|
20
|
+
* coverage: object,
|
|
21
|
+
* args: { skipWhenNoCrapFiles: boolean, ref: string, cwd: string },
|
|
22
|
+
* getChangedFilesImpl: Function,
|
|
23
|
+
* isCoverageFreshImpl: Function,
|
|
24
|
+
* runCaptureImpl: Function,
|
|
25
|
+
* computeContentDigestImpl: Function,
|
|
26
|
+
* writeCaptureStampImpl: Function,
|
|
27
|
+
* logger: { info: Function, warn: Function, error: Function },
|
|
28
|
+
* }} opts
|
|
29
|
+
* @returns {number} process exit code
|
|
30
|
+
*/
|
|
31
|
+
export function runFullScopeCapture({
|
|
32
|
+
crap,
|
|
33
|
+
coverage,
|
|
34
|
+
args,
|
|
35
|
+
getChangedFilesImpl,
|
|
36
|
+
isCoverageFreshImpl,
|
|
37
|
+
runCaptureImpl,
|
|
38
|
+
computeContentDigestImpl,
|
|
39
|
+
writeCaptureStampImpl,
|
|
40
|
+
logger,
|
|
41
|
+
}) {
|
|
42
|
+
if (args.skipWhenNoCrapFiles) {
|
|
43
|
+
let changed;
|
|
44
|
+
try {
|
|
45
|
+
changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// A bad ref must not silently relax the gate. Fall through to the
|
|
48
|
+
// freshness check so coverage still gets captured if needed.
|
|
49
|
+
logger.warn(
|
|
50
|
+
`[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
|
|
51
|
+
);
|
|
52
|
+
changed = null;
|
|
53
|
+
}
|
|
54
|
+
if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
|
|
55
|
+
logger.info(
|
|
56
|
+
`[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
|
|
57
|
+
);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const freshness = isCoverageFreshImpl({
|
|
63
|
+
coveragePath: crap.coveragePath,
|
|
64
|
+
targetDirs: crap.targetDirs,
|
|
65
|
+
cwd: args.cwd,
|
|
66
|
+
});
|
|
67
|
+
if (freshness.fresh) {
|
|
68
|
+
logger.info(
|
|
69
|
+
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
|
|
70
|
+
);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
logger.info(
|
|
75
|
+
`[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
|
|
76
|
+
);
|
|
77
|
+
const code = runCaptureImpl({
|
|
78
|
+
cwd: args.cwd,
|
|
79
|
+
timeoutMs: coverage?.timeoutMs,
|
|
80
|
+
log: (m) => logger.info(m),
|
|
81
|
+
});
|
|
82
|
+
if (code !== 0) {
|
|
83
|
+
logger.error(
|
|
84
|
+
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
85
|
+
);
|
|
86
|
+
return code;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Persist the content digest next to the fresh artifact so subsequent
|
|
90
|
+
// freshness checks are content-aware (mtime churn from branch switches no
|
|
91
|
+
// longer invalidates). Best-effort — a missing stamp just means the next
|
|
92
|
+
// check falls back to the mtime heuristic.
|
|
93
|
+
const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
94
|
+
if (
|
|
95
|
+
digest &&
|
|
96
|
+
writeCaptureStampImpl({
|
|
97
|
+
cwd: args.cwd,
|
|
98
|
+
coveragePath: crap.coveragePath,
|
|
99
|
+
digest,
|
|
100
|
+
})
|
|
101
|
+
) {
|
|
102
|
+
logger.info('[coverage-capture] Wrote content-digest capture stamp.');
|
|
103
|
+
}
|
|
104
|
+
return code;
|
|
105
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coverage-capture-incremental.js — the incremental-mode capture path for
|
|
3
|
+
* `coverage-capture.js` (Story #4981).
|
|
4
|
+
*
|
|
5
|
+
* Split into its own module (rather than added inline to the CLI shell) so
|
|
6
|
+
* the Story's opt-in branch lands as new code, not a same-file expansion of
|
|
7
|
+
* `runCoverageCapture`. Every collaborator is injected — no seam differs
|
|
8
|
+
* from the ones `coverage-capture.js` already exposes on its `deps`
|
|
9
|
+
* parameter (`.agents/rules/test-seams.md` rules 1-2, 4).
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run the incremental capture path when
|
|
15
|
+
* `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
|
|
16
|
+
*
|
|
17
|
+
* Returns the process exit code when incremental mode handled the run
|
|
18
|
+
* (skip, capture, or a capture failure), or `null` when the caller should
|
|
19
|
+
* fall through to the full-scope path — either incremental mode is
|
|
20
|
+
* disabled, or the changed-files ref could not be resolved (a
|
|
21
|
+
* misconfiguration must not silently relax the gate).
|
|
22
|
+
*
|
|
23
|
+
* @param {{
|
|
24
|
+
* crap: object,
|
|
25
|
+
* coverage: object,
|
|
26
|
+
* args: { ref: string, cwd: string },
|
|
27
|
+
* getChangedFilesImpl: Function,
|
|
28
|
+
* filterFilesUnderTargetsImpl: Function,
|
|
29
|
+
* isCoverageFreshImpl: Function,
|
|
30
|
+
* runCaptureImpl: Function,
|
|
31
|
+
* computeContentDigestImpl: Function,
|
|
32
|
+
* writeCaptureStampImpl: Function,
|
|
33
|
+
* logger: { info: Function, warn: Function, error: Function },
|
|
34
|
+
* }} opts
|
|
35
|
+
* @returns {number | null}
|
|
36
|
+
*/
|
|
37
|
+
export function tryIncrementalCapture({
|
|
38
|
+
crap,
|
|
39
|
+
coverage,
|
|
40
|
+
args,
|
|
41
|
+
getChangedFilesImpl,
|
|
42
|
+
filterFilesUnderTargetsImpl,
|
|
43
|
+
isCoverageFreshImpl,
|
|
44
|
+
runCaptureImpl,
|
|
45
|
+
computeContentDigestImpl,
|
|
46
|
+
writeCaptureStampImpl,
|
|
47
|
+
logger,
|
|
48
|
+
}) {
|
|
49
|
+
if (crap.incrementalCoverage?.enabled !== true) return null;
|
|
50
|
+
|
|
51
|
+
const ref = crap.incrementalCoverage.baseRef || args.ref;
|
|
52
|
+
let changed = null;
|
|
53
|
+
try {
|
|
54
|
+
changed = getChangedFilesImpl({ ref, cwd: args.cwd });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
`[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
|
|
58
|
+
);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const scopedFiles = filterFilesUnderTargetsImpl(changed, crap.targetDirs);
|
|
63
|
+
if (scopedFiles.length === 0) {
|
|
64
|
+
logger.info(
|
|
65
|
+
`[coverage-capture] Incremental mode: no changed files under [${crap.targetDirs.join(', ')}] vs ${ref} — skipping capture.`,
|
|
66
|
+
);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const freshness = isCoverageFreshImpl({
|
|
71
|
+
coveragePath: crap.coveragePath,
|
|
72
|
+
targetDirs: crap.targetDirs,
|
|
73
|
+
cwd: args.cwd,
|
|
74
|
+
requireScope: 'incremental',
|
|
75
|
+
});
|
|
76
|
+
if (freshness.fresh) {
|
|
77
|
+
logger.info(
|
|
78
|
+
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture.`,
|
|
79
|
+
);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
logger.info(
|
|
84
|
+
`[coverage-capture] Incremental mode: capturing coverage scoped to ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}]…`,
|
|
85
|
+
);
|
|
86
|
+
const code = runCaptureImpl({
|
|
87
|
+
cwd: args.cwd,
|
|
88
|
+
timeoutMs: coverage?.timeoutMs,
|
|
89
|
+
log: (m) => logger.info(m),
|
|
90
|
+
files: scopedFiles,
|
|
91
|
+
});
|
|
92
|
+
if (code !== 0) {
|
|
93
|
+
logger.error(
|
|
94
|
+
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
95
|
+
);
|
|
96
|
+
return code;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
100
|
+
if (
|
|
101
|
+
digest &&
|
|
102
|
+
writeCaptureStampImpl({
|
|
103
|
+
cwd: args.cwd,
|
|
104
|
+
coveragePath: crap.coveragePath,
|
|
105
|
+
digest,
|
|
106
|
+
scope: 'incremental',
|
|
107
|
+
files: scopedFiles,
|
|
108
|
+
ref,
|
|
109
|
+
})
|
|
110
|
+
) {
|
|
111
|
+
logger.info(
|
|
112
|
+
'[coverage-capture] Wrote content-digest capture stamp (incremental scope).',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return code;
|
|
116
|
+
}
|
|
@@ -151,10 +151,20 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
|
|
|
151
151
|
* write failure returns `false` rather than throwing — the worst case is a
|
|
152
152
|
* fall back to the mtime heuristic on the next freshness check.
|
|
153
153
|
*
|
|
154
|
+
* `scope` / `files` / `ref` are Story #4981 additions for incremental-mode
|
|
155
|
+
* capture. They are written to the stamp **only when the caller supplies
|
|
156
|
+
* `scope`** — the default (full-scope) call sites never pass it, so the
|
|
157
|
+
* emitted JSON stays the exact `{ digest, capturedAt }` shape byte-for-byte
|
|
158
|
+
* (AC-5). `isCoverageFresh` reads `scope` back to refuse letting a scoped
|
|
159
|
+
* stamp satisfy a full-scope freshness probe (AC-4).
|
|
160
|
+
*
|
|
154
161
|
* @param {{
|
|
155
162
|
* cwd: string,
|
|
156
163
|
* coveragePath: string,
|
|
157
164
|
* digest: string,
|
|
165
|
+
* scope?: 'full' | 'incremental',
|
|
166
|
+
* files?: string[],
|
|
167
|
+
* ref?: string,
|
|
158
168
|
* writeFileSync?: typeof fs.writeFileSync,
|
|
159
169
|
* }} opts
|
|
160
170
|
* @returns {boolean} True when the stamp was written.
|
|
@@ -163,13 +173,20 @@ export function writeCaptureStamp({
|
|
|
163
173
|
cwd,
|
|
164
174
|
coveragePath,
|
|
165
175
|
digest,
|
|
176
|
+
scope,
|
|
177
|
+
files,
|
|
178
|
+
ref,
|
|
166
179
|
writeFileSync = fs.writeFileSync,
|
|
167
180
|
}) {
|
|
168
181
|
if (typeof digest !== 'string' || digest.length === 0) return false;
|
|
182
|
+
const payload = { digest, capturedAt: new Date().toISOString() };
|
|
183
|
+
if (scope !== undefined) payload.scope = scope;
|
|
184
|
+
if (Array.isArray(files)) payload.files = [...files].sort();
|
|
185
|
+
if (typeof ref === 'string' && ref.length > 0) payload.ref = ref;
|
|
169
186
|
try {
|
|
170
187
|
writeFileSync(
|
|
171
188
|
captureStampPath(cwd, coveragePath),
|
|
172
|
-
`${JSON.stringify(
|
|
189
|
+
`${JSON.stringify(payload, null, 2)}\n`,
|
|
173
190
|
);
|
|
174
191
|
return true;
|
|
175
192
|
} catch {
|
|
@@ -177,6 +194,29 @@ export function writeCaptureStamp({
|
|
|
177
194
|
}
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Read a persisted capture stamp's digest and scope tag back into the shape
|
|
199
|
+
* `isCoverageFresh` needs, applying the Story #4981 scope-asymmetry rule
|
|
200
|
+
* (AC-4) in one place. Extracted so the parent function's own branching
|
|
201
|
+
* stays under the cyclomatic ceiling.
|
|
202
|
+
*
|
|
203
|
+
* @param {{digest?: unknown, scope?: unknown} | null} stamp
|
|
204
|
+
* @param {'full' | 'incremental'} requireScope
|
|
205
|
+
* @returns {{ digest: string } | { scopeMismatch: true } | null} `null`
|
|
206
|
+
* means the stamp is missing/unreadable/digest-less — fall through to the
|
|
207
|
+
* mtime heuristic.
|
|
208
|
+
*/
|
|
209
|
+
function readStampForScope(stamp, requireScope) {
|
|
210
|
+
if (typeof stamp?.digest !== 'string' || stamp.digest.length === 0) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const stampScope = stamp.scope === 'incremental' ? 'incremental' : 'full';
|
|
214
|
+
if (stampScope === 'incremental' && requireScope !== 'incremental') {
|
|
215
|
+
return { scopeMismatch: true };
|
|
216
|
+
}
|
|
217
|
+
return { digest: stamp.digest };
|
|
218
|
+
}
|
|
219
|
+
|
|
180
220
|
/**
|
|
181
221
|
* Decide whether the existing coverage artifact is "fresh".
|
|
182
222
|
*
|
|
@@ -191,22 +231,32 @@ export function writeCaptureStamp({
|
|
|
191
231
|
* under `targetDirs`. Missing files, missing target dirs, or any IO error
|
|
192
232
|
* resolve to `false` so the caller captures rather than trusting stale data.
|
|
193
233
|
*
|
|
234
|
+
* **Scope asymmetry (Story #4981, AC-4).** A stamp written by an incremental
|
|
235
|
+
* capture (`scope: 'incremental'`) only covers the files the diff touched —
|
|
236
|
+
* it must never satisfy a caller that requires the full-scope guarantee
|
|
237
|
+
* (`requireScope` defaults to `'full'`, matching every pre-existing caller
|
|
238
|
+
* byte-for-byte). A full-scope stamp (or a legacy stamp with no `scope`
|
|
239
|
+
* field, which predates this Story and is therefore full-scope by
|
|
240
|
+
* construction) satisfies either probe.
|
|
241
|
+
*
|
|
194
242
|
* @param {{
|
|
195
243
|
* coveragePath: string,
|
|
196
244
|
* targetDirs: string[],
|
|
197
245
|
* cwd: string,
|
|
246
|
+
* requireScope?: 'full' | 'incremental',
|
|
198
247
|
* statSync?: typeof fs.statSync,
|
|
199
248
|
* readdirSync?: typeof fs.readdirSync,
|
|
200
249
|
* existsSync?: typeof fs.existsSync,
|
|
201
250
|
* readFileSync?: typeof fs.readFileSync,
|
|
202
251
|
* computeDigest?: typeof computeContentDigest,
|
|
203
252
|
* }} opts
|
|
204
|
-
* @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' }}
|
|
253
|
+
* @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' }}
|
|
205
254
|
*/
|
|
206
255
|
export function isCoverageFresh({
|
|
207
256
|
coveragePath,
|
|
208
257
|
targetDirs,
|
|
209
258
|
cwd,
|
|
259
|
+
requireScope = 'full',
|
|
210
260
|
statSync = fs.statSync,
|
|
211
261
|
readdirSync = fs.readdirSync,
|
|
212
262
|
existsSync = fs.existsSync,
|
|
@@ -224,10 +274,14 @@ export function isCoverageFresh({
|
|
|
224
274
|
} catch {
|
|
225
275
|
// Corrupt/unreadable stamp → fall through to the mtime heuristic.
|
|
226
276
|
}
|
|
227
|
-
|
|
277
|
+
const resolved = readStampForScope(stamp, requireScope);
|
|
278
|
+
if (resolved?.scopeMismatch) {
|
|
279
|
+
return { fresh: false, reason: 'scope-mismatch' };
|
|
280
|
+
}
|
|
281
|
+
if (resolved) {
|
|
228
282
|
const current = computeDigest(cwd, targetDirs);
|
|
229
283
|
if (typeof current === 'string' && current.length > 0) {
|
|
230
|
-
return current ===
|
|
284
|
+
return current === resolved.digest
|
|
231
285
|
? { fresh: true, reason: 'fresh' }
|
|
232
286
|
: { fresh: false, reason: 'stale' };
|
|
233
287
|
}
|
|
@@ -251,28 +305,42 @@ export function isCoverageFresh({
|
|
|
251
305
|
: { fresh: false, reason: 'stale' };
|
|
252
306
|
}
|
|
253
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
|
|
310
|
+
*
|
|
311
|
+
* Both inputs are forward-slash-normalised; `targetDirs` are matched as path
|
|
312
|
+
* prefixes followed by `/`. Shared by `anyChangedUnderTargets` (the pre-push
|
|
313
|
+
* fast-path boolean) and the incremental-coverage scope resolver (Story
|
|
314
|
+
* #4981), which needs the actual file list rather than a yes/no.
|
|
315
|
+
*
|
|
316
|
+
* @param {string[]} changedFiles
|
|
317
|
+
* @param {string[]} targetDirs
|
|
318
|
+
* @returns {string[]} Forward-slash-normalised matches, in `changedFiles` order.
|
|
319
|
+
*/
|
|
320
|
+
export function filterFilesUnderTargets(changedFiles, targetDirs) {
|
|
321
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return [];
|
|
322
|
+
if (!Array.isArray(targetDirs) || targetDirs.length === 0) return [];
|
|
323
|
+
const norms = targetDirs
|
|
324
|
+
.filter((d) => typeof d === 'string' && d.length > 0)
|
|
325
|
+
.map((d) => d.replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
326
|
+
return changedFiles
|
|
327
|
+
.map((file) => String(file).replace(/\\/g, '/'))
|
|
328
|
+
.filter((f) => norms.some((dir) => f === dir || f.startsWith(`${dir}/`)));
|
|
329
|
+
}
|
|
330
|
+
|
|
254
331
|
/**
|
|
255
332
|
* Decide whether any of `changedFiles` lives under one of `targetDirs`.
|
|
256
333
|
* Used by the pre-push fast-path so we can skip the (slow) coverage capture
|
|
257
334
|
* when the push touches only files outside the CRAP scoring scope.
|
|
258
335
|
*
|
|
259
|
-
*
|
|
260
|
-
* prefixes followed by `/`. An empty changed-file list returns `false`.
|
|
336
|
+
* An empty changed-file list returns `false`.
|
|
261
337
|
*
|
|
262
338
|
* @param {string[]} changedFiles
|
|
263
339
|
* @param {string[]} targetDirs
|
|
264
340
|
* @returns {boolean}
|
|
265
341
|
*/
|
|
266
342
|
export function anyChangedUnderTargets(changedFiles, targetDirs) {
|
|
267
|
-
|
|
268
|
-
if (!Array.isArray(targetDirs) || targetDirs.length === 0) return false;
|
|
269
|
-
const norms = targetDirs
|
|
270
|
-
.filter((d) => typeof d === 'string' && d.length > 0)
|
|
271
|
-
.map((d) => d.replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
272
|
-
return changedFiles.some((file) => {
|
|
273
|
-
const f = String(file).replace(/\\/g, '/');
|
|
274
|
-
return norms.some((dir) => f === dir || f.startsWith(`${dir}/`));
|
|
275
|
-
});
|
|
343
|
+
return filterFilesUnderTargets(changedFiles, targetDirs).length > 0;
|
|
276
344
|
}
|
|
277
345
|
|
|
278
346
|
/**
|
|
@@ -295,11 +363,20 @@ export const COVERAGE_TIMEOUT_EXIT_CODE = 124;
|
|
|
295
363
|
* `timeout(1)` convention exit code 124 so callers can pattern-match a
|
|
296
364
|
* runaway runner without inspecting signal names.
|
|
297
365
|
*
|
|
366
|
+
* `files` (Story #4981) scopes the spawn to a file list — `npm run
|
|
367
|
+
* test:coverage -- <files...>`, the standard npm convention for forwarding
|
|
368
|
+
* argv to the underlying script (which most test runners, including Node's
|
|
369
|
+
* own, treat as positional file filters). Omitted or empty means the
|
|
370
|
+
* default full-scope invocation, byte-identical to the pre-#4981 argv
|
|
371
|
+
* (AC-5); a non-empty list makes the scope observable on the emitted
|
|
372
|
+
* command line (AC-1).
|
|
373
|
+
*
|
|
298
374
|
* @param {{
|
|
299
375
|
* cwd: string,
|
|
300
376
|
* timeoutMs?: number,
|
|
301
377
|
* runner?: typeof spawnSync,
|
|
302
378
|
* log?: (m: string) => void,
|
|
379
|
+
* files?: string[] | null,
|
|
303
380
|
* }} opts
|
|
304
381
|
* @returns {number}
|
|
305
382
|
*/
|
|
@@ -308,8 +385,15 @@ export function runCapture({
|
|
|
308
385
|
timeoutMs,
|
|
309
386
|
runner = spawnSync,
|
|
310
387
|
log = () => {},
|
|
388
|
+
files = null,
|
|
311
389
|
} = {}) {
|
|
312
|
-
|
|
390
|
+
const scopedFiles = Array.isArray(files) && files.length > 0 ? files : null;
|
|
391
|
+
const args = [
|
|
392
|
+
'run',
|
|
393
|
+
'test:coverage',
|
|
394
|
+
...(scopedFiles ? ['--', ...scopedFiles] : []),
|
|
395
|
+
];
|
|
396
|
+
log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
|
|
313
397
|
const spawnOpts = {
|
|
314
398
|
cwd,
|
|
315
399
|
stdio: 'inherit',
|
|
@@ -323,7 +407,7 @@ export function runCapture({
|
|
|
323
407
|
) {
|
|
324
408
|
spawnOpts.timeout = timeoutMs;
|
|
325
409
|
}
|
|
326
|
-
const res = runner('npm',
|
|
410
|
+
const res = runner('npm', args, spawnOpts);
|
|
327
411
|
// A timeout-induced kill surfaces as `signal: 'SIGKILL'` (or, on some
|
|
328
412
|
// platforms, as a non-numeric status). Either signal indicates the
|
|
329
413
|
// watchdog tripped — surface the GNU `timeout` convention 124 so the
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-baseline-index.js — per-file method-identity indexing over a CRAP
|
|
3
|
+
* baseline's rows (Story #4981).
|
|
4
|
+
*
|
|
5
|
+
* Split out of `crap-engine.js` (rather than added inline) so the join
|
|
6
|
+
* support lands as a new file, not a same-file expansion of the module the
|
|
7
|
+
* scoring kernel already lives in. Deliberately dependency-free: both
|
|
8
|
+
* `crap-engine.js` (which needs `methodIdentityKey` inside
|
|
9
|
+
* `finalizeMethodRowsWithBaseline`) and `baselines/kinds/crap.js` (which
|
|
10
|
+
* needs the file-scoped index) import FROM here, and this module imports
|
|
11
|
+
* from neither — the only shape that keeps the edge one-directional.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-file half of the method-identity key `baselines/kinds/crap.js`'s
|
|
16
|
+
* `crapRowKey` composes with the file path (`${path}::${method}@${startLine}`).
|
|
17
|
+
* The path component is redundant once a row set is already narrowed to one
|
|
18
|
+
* file, which is exactly what `indexBaselineRowsByFile` does below.
|
|
19
|
+
*
|
|
20
|
+
* @param {{method: string, startLine: number}} row
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function methodIdentityKey(row) {
|
|
24
|
+
return `${row.method}@${row.startLine}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Index baseline rows (accepts either the `{file, method, startLine, crap}`
|
|
29
|
+
* legacy shape `compareCrap`/`scanAndScore` use, or the on-disk `{path, ...}`
|
|
30
|
+
* shape) by file, then by `methodIdentityKey`, for O(1) per-method lookup —
|
|
31
|
+
* exactly the shape `crap-engine.js#finalizeMethodRowsWithBaseline`'s
|
|
32
|
+
* `baselineByKey` expects.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{file?: string, path?: string, method: string, startLine: number, crap: number}>} baselineRows
|
|
35
|
+
* @returns {Map<string, Map<string, {crap: number}>>} file → (method@startLine → row)
|
|
36
|
+
*/
|
|
37
|
+
export function indexBaselineRowsByFile(baselineRows) {
|
|
38
|
+
const byFile = new Map();
|
|
39
|
+
for (const row of baselineRows ?? []) {
|
|
40
|
+
const file = row?.file ?? row?.path;
|
|
41
|
+
if (typeof file !== 'string' || file.length === 0) continue;
|
|
42
|
+
if (!byFile.has(file)) byFile.set(file, new Map());
|
|
43
|
+
byFile.get(file).set(methodIdentityKey(row), row);
|
|
44
|
+
}
|
|
45
|
+
return byFile;
|
|
46
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-baseline-join.js — the incremental-coverage CRAP join (Story #4981).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `crap-engine.js` into its own file (rather than added inline)
|
|
5
|
+
* so the Story's join logic lands as new code, not a same-file expansion of
|
|
6
|
+
* the pre-existing scoring kernel. Imports its coordinate/formula
|
|
7
|
+
* primitives from `crap-coordinates.js` (not `crap-engine.js`) and its
|
|
8
|
+
* identity key from `crap-baseline-index.js` — both leaf modules — so this
|
|
9
|
+
* file stays a one-directional consumer with no edge back into
|
|
10
|
+
* `crap-engine.js`.
|
|
11
|
+
*/
|
|
12
|
+
import { methodIdentityKey } from './crap-baseline-index.js';
|
|
13
|
+
import {
|
|
14
|
+
COORDINATE_ORIGINAL,
|
|
15
|
+
COORDINATE_TRANSPILED,
|
|
16
|
+
crapFormula,
|
|
17
|
+
} from './crap-coordinates.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply the standard `requireCoverage` policy to a single raw method row.
|
|
21
|
+
* The per-row half of `crap-engine.js#finalizeMethodRows`'s loop body,
|
|
22
|
+
* extracted (Story #4981) so `finalizeMethodRowsWithBaseline`, below, can
|
|
23
|
+
* apply the exact same per-row policy to a method whose file was NOT in the
|
|
24
|
+
* diff scope but whose baseline row could not be found — the fail-closed
|
|
25
|
+
* path AC-3 requires.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} mr A raw row from `methodRowsFromReport`.
|
|
28
|
+
* @param {{requireCoverage: boolean, coverageAvailable: boolean}} opts
|
|
29
|
+
* @returns {{ resolved: boolean, row: object | null }} `row: null` means the
|
|
30
|
+
* method is skipped-and-counted; `resolved` tracks the join outcome
|
|
31
|
+
* (independent of whether the row survives the skip policy).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
|
|
34
|
+
const unresolved = mr.crap === null || mr.coverage === null;
|
|
35
|
+
const resolved = !unresolved;
|
|
36
|
+
// Unjoinable is not untested (Story #4901).
|
|
37
|
+
if (
|
|
38
|
+
mr.coordinateSystem === COORDINATE_TRANSPILED ||
|
|
39
|
+
(unresolved && (requireCoverage || !coverageAvailable))
|
|
40
|
+
) {
|
|
41
|
+
return { resolved, row: null };
|
|
42
|
+
}
|
|
43
|
+
const coverage = unresolved ? 0 : mr.coverage;
|
|
44
|
+
const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
|
|
45
|
+
// Everything the scan decided is carried forward; this step overrides only
|
|
46
|
+
// what its own policy resolves. Spreading rather than re-listing each field
|
|
47
|
+
// is why the row's identity marker (Story #4969) and its provenance
|
|
48
|
+
// (Story #4866) survive the step without a line each to remember them —
|
|
49
|
+
// a hand-rebuilt row is how a marker silently stops reaching the baseline.
|
|
50
|
+
return {
|
|
51
|
+
resolved,
|
|
52
|
+
row: {
|
|
53
|
+
...mr,
|
|
54
|
+
coverage,
|
|
55
|
+
crap,
|
|
56
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Incremental-mode join (Story #4981): resolve a file's raw method rows
|
|
63
|
+
* against its committed CRAP-baseline rows instead of requiring fresh
|
|
64
|
+
* coverage, for a file the diff did NOT touch.
|
|
65
|
+
*
|
|
66
|
+
* Rationale: `coverage-capture`'s incremental mode only runs the consumer's
|
|
67
|
+
* test suite scoped to the diff, so an untouched file's coverage entry may
|
|
68
|
+
* legitimately be absent even though nothing about that file's methods
|
|
69
|
+
* changed. Requiring a fresh join for it would either (a) skip-and-count
|
|
70
|
+
* every one of its methods under `requireCoverage: true`, weakening the
|
|
71
|
+
* gate's signal for the vast majority of the tree on every run, or (b) score
|
|
72
|
+
* them at an invented 0% under `requireCoverage: false`, manufacturing a
|
|
73
|
+
* maximal CRAP for code the diff never touched. Neither is a measurement.
|
|
74
|
+
*
|
|
75
|
+
* The join key is `${method}@${startLine}` — the per-file half of the
|
|
76
|
+
* composite identity `kinds/crap.js#crapRowKey` uses for the full baseline
|
|
77
|
+
* compare (`${path}::${method}@${startLine}`); callers pass in a
|
|
78
|
+
* per-file-scoped `baselineByKey` map so this function stays path-agnostic.
|
|
79
|
+
*
|
|
80
|
+
* **Fail-closed (AC-3).** `touched: true` (the file WAS in the diff) or a
|
|
81
|
+
* missing/empty `baselineByKey` reproduces `crap-engine.js#finalizeMethodRows`
|
|
82
|
+
* exactly — this is the pre-#4981 per-row policy, so a caller that never
|
|
83
|
+
* opts in sees byte-identical behaviour (AC-5). For an untouched file, a
|
|
84
|
+
* method whose baseline row cannot be found (new method, moved line, or a
|
|
85
|
+
* baseline that simply never carried it) is NOT invented — it falls back to
|
|
86
|
+
* the same per-row `requireCoverage` skip-and-count policy, via the shared
|
|
87
|
+
* `resolveRawRow`. A method whose coordinate system is transpiled is never
|
|
88
|
+
* resolved from the baseline either, for the same un-joinable reason
|
|
89
|
+
* `resolveRawRow` excludes it (Story #4901).
|
|
90
|
+
*
|
|
91
|
+
* @param {Array<object>} rawRows Rows from `methodRowsFromReport`, all for
|
|
92
|
+
* the SAME file.
|
|
93
|
+
* @param {{
|
|
94
|
+
* requireCoverage?: boolean,
|
|
95
|
+
* coverageAvailable?: boolean,
|
|
96
|
+
* touched?: boolean,
|
|
97
|
+
* baselineByKey?: Map<string, {crap: number}> | null,
|
|
98
|
+
* }} [opts]
|
|
99
|
+
* @returns {{
|
|
100
|
+
* rows: Array<object>,
|
|
101
|
+
* skippedMethodsNoCoverage: number,
|
|
102
|
+
* resolvedMethods: number,
|
|
103
|
+
* totalMethods: number,
|
|
104
|
+
* }}
|
|
105
|
+
*/
|
|
106
|
+
export function finalizeMethodRowsWithBaseline(
|
|
107
|
+
rawRows,
|
|
108
|
+
{
|
|
109
|
+
requireCoverage = true,
|
|
110
|
+
coverageAvailable = true,
|
|
111
|
+
touched = true,
|
|
112
|
+
baselineByKey = null,
|
|
113
|
+
} = {},
|
|
114
|
+
) {
|
|
115
|
+
const useBaseline = !touched && baselineByKey && baselineByKey.size > 0;
|
|
116
|
+
const rows = [];
|
|
117
|
+
let skippedMethodsNoCoverage = 0;
|
|
118
|
+
let resolvedMethods = 0;
|
|
119
|
+
let totalMethods = 0;
|
|
120
|
+
for (const mr of rawRows ?? []) {
|
|
121
|
+
totalMethods += 1;
|
|
122
|
+
if (useBaseline) {
|
|
123
|
+
const base =
|
|
124
|
+
mr.coordinateSystem === COORDINATE_TRANSPILED
|
|
125
|
+
? undefined
|
|
126
|
+
: baselineByKey.get(methodIdentityKey(mr));
|
|
127
|
+
if (base && typeof base.crap === 'number') {
|
|
128
|
+
resolvedMethods += 1;
|
|
129
|
+
rows.push({
|
|
130
|
+
...mr,
|
|
131
|
+
coverage: null,
|
|
132
|
+
crap: base.crap,
|
|
133
|
+
resolvedFromBaseline: true,
|
|
134
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Touched file, no baseline scope, or no baseline row for this method —
|
|
140
|
+
// fail closed to the standard policy rather than inventing a verdict.
|
|
141
|
+
const resolution = resolveRawRow(mr, {
|
|
142
|
+
requireCoverage,
|
|
143
|
+
coverageAvailable,
|
|
144
|
+
});
|
|
145
|
+
if (resolution.resolved) resolvedMethods += 1;
|
|
146
|
+
if (resolution.row === null) {
|
|
147
|
+
skippedMethodsNoCoverage += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
rows.push(resolution.row);
|
|
151
|
+
}
|
|
152
|
+
return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
|
|
153
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-coordinates.js — the CRAP kernel's two line-coordinate-system
|
|
3
|
+
* constants and the CRAP formula itself.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately dependency-free (Story #4981 split, pulled out of
|
|
6
|
+
* `crap-engine.js`): both `crap-engine.js` and `crap-baseline-join.js`
|
|
7
|
+
* need these, and having either import them from the other would close a
|
|
8
|
+
* cycle. `crap-engine.js` re-exports the two constants so its existing
|
|
9
|
+
* importers (`baselines/kinds/crap.js`, `crap-utils.js`, tests) are
|
|
10
|
+
* unaffected.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The two line coordinate systems a CRAP row's `startLine` can be expressed
|
|
15
|
+
* in (Story #4866).
|
|
16
|
+
*
|
|
17
|
+
* `original` — the coordinates of the file a reader can open, and the ones
|
|
18
|
+
* istanbul's `fnMap` is keyed against. A JavaScript source is already in this
|
|
19
|
+
* system; a TS/TSX source reaches it only through a successful sourcemap
|
|
20
|
+
* lookup.
|
|
21
|
+
*
|
|
22
|
+
* `transpiled` — escomplex's own coordinates over the emitted JavaScript,
|
|
23
|
+
* kept only when the sourcemap has no entry originating on the method's
|
|
24
|
+
* generated line. Such a row is NOT an original-source coordinate and must
|
|
25
|
+
* never be presented as one: it cannot be joined to coverage, and it cannot
|
|
26
|
+
* be compared against a baseline row carrying the other provenance.
|
|
27
|
+
*/
|
|
28
|
+
export const COORDINATE_ORIGINAL = 'original';
|
|
29
|
+
export const COORDINATE_TRANSPILED = 'transpiled';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* CRAP formula, exported for callers that need to derive target scores or
|
|
33
|
+
* `fixGuidance` values without re-scoring source.
|
|
34
|
+
*
|
|
35
|
+
* @param {number} cyclomatic
|
|
36
|
+
* @param {number} coverage In [0, 1].
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function crapFormula(cyclomatic, coverage) {
|
|
40
|
+
const c = Number(cyclomatic) || 0;
|
|
41
|
+
const cov = Math.max(0, Math.min(1, Number(coverage) || 0));
|
|
42
|
+
return c * c * (1 - cov) ** 3 + c;
|
|
43
|
+
}
|