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.
Files changed (26) hide show
  1. package/.agents/docs/configuration.md +3 -0
  2. package/.agents/docs/execution-reference.md +22 -12
  3. package/.agents/schemas/agentrc.schema.json +14 -0
  4. package/.agents/scripts/coverage-capture.js +31 -62
  5. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +41 -0
  6. package/.agents/scripts/lib/baselines/crap-preview-scan.js +135 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +13 -0
  8. package/.agents/scripts/lib/baselines/preview-gates.js +7 -87
  9. package/.agents/scripts/lib/bdd-scenario-budget.js +68 -0
  10. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +26 -0
  11. package/.agents/scripts/lib/config/gates/crap.schema.js +2 -0
  12. package/.agents/scripts/lib/config/quality.js +41 -0
  13. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  14. package/.agents/scripts/lib/coverage-capture-fullscope.js +105 -0
  15. package/.agents/scripts/lib/coverage-capture-incremental.js +116 -0
  16. package/.agents/scripts/lib/coverage-capture.js +101 -17
  17. package/.agents/scripts/lib/crap-baseline-index.js +46 -0
  18. package/.agents/scripts/lib/crap-baseline-join.js +153 -0
  19. package/.agents/scripts/lib/crap-coordinates.js +43 -0
  20. package/.agents/scripts/lib/crap-engine.js +26 -51
  21. package/.agents/scripts/lib/crap-utils-incremental.js +113 -0
  22. package/.agents/scripts/lib/crap-utils.js +40 -8
  23. package/.agents/scripts/lib/orchestration/plan-context.js +49 -13
  24. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -5
  25. package/docs/CHANGELOG.md +14 -0
  26. 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({ digest, capturedAt: new Date().toISOString() }, null, 2)}\n`,
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
- if (typeof stamp?.digest === 'string' && stamp.digest.length > 0) {
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 === stamp.digest
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
- * Both inputs are forward-slash-normalised; `targetDirs` are matched as path
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
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
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
- log('[coverage-capture] npm run test:coverage');
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', ['run', 'test:coverage'], spawnOpts);
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
+ }