mandrel 2.27.0 → 2.29.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/docs/execution-reference.md +22 -12
- 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/bdd-scenario-budget.js +68 -0
- 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 +40 -8
- package/.agents/scripts/lib/orchestration/plan-context.js +49 -13
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -5
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -71,6 +71,19 @@ const DEFAULT_MI_FLOORS = Object.freeze({
|
|
|
71
71
|
'*': Object.freeze({ min: 70 }),
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping.
|
|
76
|
+
* Disabled by default: `coverage-capture.js` and the CRAP join keep their
|
|
77
|
+
* pre-#4981 full-repo behaviour byte-for-byte until a consumer sets
|
|
78
|
+
* `enabled: true`. `baseRef: null` means "use the caller's own ref
|
|
79
|
+
* resolution" (the gate's `--ref` flag / `main`) rather than a second,
|
|
80
|
+
* possibly-conflicting default.
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_INCREMENTAL_COVERAGE = Object.freeze({
|
|
83
|
+
enabled: false,
|
|
84
|
+
baseRef: null,
|
|
85
|
+
});
|
|
86
|
+
|
|
74
87
|
/** Framework defaults for the CRAP gate (post-1737 uniform shape). */
|
|
75
88
|
export const CRAP_GATE_DEFAULTS = Object.freeze({
|
|
76
89
|
enabled: true,
|
|
@@ -97,6 +110,7 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
|
|
|
97
110
|
// run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
|
|
98
111
|
// signature of a coordinate-system mismatch.
|
|
99
112
|
minMethodResolutionRate: 0.75,
|
|
113
|
+
incrementalCoverage: DEFAULT_INCREMENTAL_COVERAGE,
|
|
100
114
|
});
|
|
101
115
|
|
|
102
116
|
/** Framework defaults for the coverage gate. */
|
|
@@ -163,6 +177,7 @@ const CRAP_GATE_KEYS = new Set([
|
|
|
163
177
|
'refreshTimeoutMs',
|
|
164
178
|
'ignoreGlobs',
|
|
165
179
|
'minMethodResolutionRate',
|
|
180
|
+
'incrementalCoverage',
|
|
166
181
|
]);
|
|
167
182
|
|
|
168
183
|
const COVERAGE_GATE_KEYS = new Set([
|
|
@@ -240,6 +255,27 @@ function resolveResolutionRate(value, fallback) {
|
|
|
240
255
|
return value;
|
|
241
256
|
}
|
|
242
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Resolve `gates.crap.incrementalCoverage` (Story #4981). A malformed or
|
|
260
|
+
* absent user block resolves to the framework default (disabled), so a
|
|
261
|
+
* consumer that never sets the key gets the exact pre-#4981 shape back.
|
|
262
|
+
*
|
|
263
|
+
* @param {{ enabled?: boolean, baseRef?: string } | undefined} user
|
|
264
|
+
* @param {{ enabled: boolean, baseRef: string | null }} defaults
|
|
265
|
+
* @returns {{ enabled: boolean, baseRef: string | null }}
|
|
266
|
+
*/
|
|
267
|
+
function resolveIncrementalCoverage(user, defaults) {
|
|
268
|
+
if (user == null || typeof user !== 'object') return { ...defaults };
|
|
269
|
+
return {
|
|
270
|
+
enabled:
|
|
271
|
+
typeof user.enabled === 'boolean' ? user.enabled : defaults.enabled,
|
|
272
|
+
baseRef:
|
|
273
|
+
typeof user.baseRef === 'string' && user.baseRef.length > 0
|
|
274
|
+
? user.baseRef
|
|
275
|
+
: defaults.baseRef,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
243
279
|
export function resolveMaintainabilityCrap(
|
|
244
280
|
userCrap,
|
|
245
281
|
gateScoping,
|
|
@@ -267,6 +303,7 @@ export function resolveMaintainabilityCrap(
|
|
|
267
303
|
refreshTag: defaults.refreshTag,
|
|
268
304
|
refreshTimeoutMs: defaults.refreshTimeoutMs,
|
|
269
305
|
ignoreGlobs: [...defaults.ignoreGlobs],
|
|
306
|
+
incrementalCoverage: { ...defaults.incrementalCoverage },
|
|
270
307
|
defaultScope: scoping.defaultScope,
|
|
271
308
|
diffRef: scoping.diffRef,
|
|
272
309
|
};
|
|
@@ -297,6 +334,10 @@ export function resolveMaintainabilityCrap(
|
|
|
297
334
|
ignoreGlobs: Array.isArray(userCrap.ignoreGlobs)
|
|
298
335
|
? userCrap.ignoreGlobs.slice()
|
|
299
336
|
: [...defaults.ignoreGlobs],
|
|
337
|
+
incrementalCoverage: resolveIncrementalCoverage(
|
|
338
|
+
userCrap.incrementalCoverage,
|
|
339
|
+
defaults.incrementalCoverage,
|
|
340
|
+
),
|
|
300
341
|
defaultScope: scoping.defaultScope,
|
|
301
342
|
diffRef: scoping.diffRef,
|
|
302
343
|
};
|
|
@@ -443,6 +443,12 @@ export const DELIVERY_SCHEMA = {
|
|
|
443
443
|
deliverRunner: DELIVER_RUNNER_SCHEMA,
|
|
444
444
|
worktreeIsolation: WORKTREE_ISOLATION_SCHEMA,
|
|
445
445
|
signals: SIGNALS_SCHEMA,
|
|
446
|
+
// `quality.gates.crap.incrementalCoverage` (Story #4981) is declared in
|
|
447
|
+
// `config/gates/crap.schema.js` and reaches AJV validation through this
|
|
448
|
+
// property — QUALITY_SCHEMA → GATES_SCHEMA → CRAP_GATE. No separate
|
|
449
|
+
// declaration lives here; this is the composition point that makes the
|
|
450
|
+
// gate-level schema authoritative for the top-level `.agentrc.json`
|
|
451
|
+
// surface this module validates.
|
|
446
452
|
quality: QUALITY_SCHEMA,
|
|
447
453
|
mergeWatch: MERGE_WATCH_SCHEMA,
|
|
448
454
|
codeReview: CODE_REVIEW_SCHEMA,
|
|
@@ -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
|
+
}
|