mandrel 2.67.0 → 2.69.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 (61) hide show
  1. package/.agents/agents/story-worker.md +15 -11
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +36 -1
  4. package/.agents/schemas/agentrc.schema.json +14 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/coverage-capture.js +65 -9
  8. package/.agents/scripts/evidence-gate.js +106 -8
  9. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  10. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  11. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  12. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  13. package/.agents/scripts/lib/child-exec.js +39 -1
  14. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  15. package/.agents/scripts/lib/close-validation/process.js +23 -24
  16. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  17. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  18. package/.agents/scripts/lib/config/quality.js +7 -1
  19. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  20. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  21. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  22. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  23. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  24. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  25. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  26. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  27. package/.agents/scripts/lib/coverage-capture.js +121 -81
  28. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  29. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  30. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  31. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  32. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  34. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  36. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  40. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  41. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  43. package/.agents/scripts/lib/process-group.js +1 -1
  44. package/.agents/scripts/lib/supervised-suite.js +247 -0
  45. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  46. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  47. package/.agents/scripts/quality-preview.js +112 -14
  48. package/.agents/scripts/stories-wave-tick.js +47 -0
  49. package/.agents/scripts/story-review-compute.js +207 -0
  50. package/.agents/scripts/update-coverage-baseline.js +15 -10
  51. package/.agents/scripts/update-crap-baseline.js +12 -2
  52. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  53. package/.agents/workflows/audit-security.md +0 -1
  54. package/.agents/workflows/helpers/code-review.md +7 -5
  55. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  56. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  57. package/.agents/workflows/helpers/deliver-story.md +2 -1
  58. package/docs/CHANGELOG.md +33 -0
  59. package/lib/cli/registry.js +125 -18
  60. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  61. package/package.json +1 -1
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import { getQuality } from '../config/quality.js';
7
- import { FULL_SUITE_LOCK_EXPIRY_ENV } from '../full-suite-lock.js';
7
+ import { COVERAGE_TIMEOUT_EXIT_CODE } from '../coverage-capture.js';
8
+ import { LOCK_WAIT_EXPIRED_EXIT_CODE } from '../full-suite-lock.js';
8
9
  import { gitSpawn } from '../git-utils.js';
9
10
  import {
10
11
  recordPass as defaultRecordPass,
@@ -16,13 +17,49 @@ import {
16
17
  isFormatterEligible,
17
18
  listChangedFilesForFormatGate,
18
19
  } from './commands.js';
19
- import { DEFAULT_GATES, partitionGates } from './gates.js';
20
+ import { DEFAULT_GATES, GATE_TIMEOUT_HINT, partitionGates } from './gates.js';
20
21
  import { defaultGateRunner } from './process.js';
21
22
  import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
22
23
  import { defaultGetHeadSha } from './projections/head-sha.js';
23
24
 
24
25
  /** @typedef {import('./gates.js').Gate} Gate */
25
26
 
27
+ /**
28
+ * Classify a non-zero gate exit and log it as what it is: `75` is an expired
29
+ * full-suite lock wait (nothing ran), `124` a killed suite (no verdict), and
30
+ * anything else a real failure with the gate's own hint. Returns the
31
+ * `failed[]` entry, carrying `outcome` so callers never re-derive it; its
32
+ * `gate.hint` is the one that fits the outcome (none when deferred), so a
33
+ * caller replaying it never prints the failing-tests hint for a non-failure.
34
+ *
35
+ * @param {{ gate: Gate, status: number, cwd: string, log: (m: string) => void }} args
36
+ * @returns {{ gate: Gate, status: number, cwd: string, outcome: 'deferred'|'timeout'|'failed' }}
37
+ */
38
+ function reportGateExit({ gate, status, cwd, log }) {
39
+ if (status === LOCK_WAIT_EXPIRED_EXIT_CODE) {
40
+ log(
41
+ `[close-validation] ⏸ ${gate.name} deferred (exit ${status}) — the full-suite lock wait expired, so nothing ran in ${cwd}`,
42
+ );
43
+ const { hint: _failureHint, ...deferredGate } = gate;
44
+ return { gate: deferredGate, status, cwd, outcome: 'deferred' };
45
+ }
46
+ if (status === COVERAGE_TIMEOUT_EXIT_CODE) {
47
+ log(
48
+ `[close-validation] ⏱ ${gate.name} timed out (exit ${status}) in ${cwd}`,
49
+ );
50
+ log(`[close-validation] hint: ${GATE_TIMEOUT_HINT}`);
51
+ return {
52
+ gate: { ...gate, hint: GATE_TIMEOUT_HINT },
53
+ status,
54
+ cwd,
55
+ outcome: 'timeout',
56
+ };
57
+ }
58
+ log(`[close-validation] ✖ ${gate.name} failed (exit ${status}) in ${cwd}`);
59
+ if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
60
+ return { gate, status, cwd, outcome: 'failed' };
61
+ }
62
+
26
63
  function applyChangedFileScope({ gate, spawnCwd, log }) {
27
64
  // A skip decided when the gate list was built wins over everything.
28
65
  if (gate.skip) {
@@ -95,10 +132,10 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
95
132
  * getTreeFingerprint?: (cwd: string) => string|null,
96
133
  * recordPass?: typeof defaultRecordPass,
97
134
  * shouldSkip?: typeof defaultShouldSkip,
98
- * deferOnLockExpiry?: boolean,
99
- * }} opts `deferOnLockExpiry`: an expired full-suite lock wait spawns
100
- * nothing (here or in any gate child) and reports `LOCK_WAIT_EXPIRED_EXIT_CODE`.
101
- * @returns {{ ok: boolean, failed: Array<{ gate: Gate, status: number, cwd: string }>, skipped: Array<{ gate: Gate, reason: string }> }}
135
+ * }} opts An expired full-suite lock wait spawns nothing (here or in any gate
136
+ * child) and reports `LOCK_WAIT_EXPIRED_EXIT_CODE`.
137
+ * @returns {{ ok: boolean, failed: Array<{ gate: Gate, status: number, cwd: string, outcome: 'deferred'|'timeout'|'failed' }>, skipped: Array<{ gate: Gate, reason: string }> }}
138
+ * `outcome` classifies the exit: `deferred` (75), `timeout` (124), else `failed`.
102
139
  */
103
140
  export async function runCloseValidation({
104
141
  cwd,
@@ -120,10 +157,9 @@ export async function runCloseValidation({
120
157
  defaultTreeFingerprint(resolvedCwd, gitSpawn),
121
158
  recordPass = defaultRecordPass,
122
159
  shouldSkip = defaultShouldSkip,
123
- deferOnLockExpiry = false,
124
160
  } = {}) {
125
161
  const failed = [];
126
- const lockOpts = fullSuiteLockOptions({ config, deferOnLockExpiry });
162
+ const lockOpts = fullSuiteLockOptions({ config });
127
163
  const skipped = [];
128
164
  const evidenceActive = useEvidence && storyId != null && standalone;
129
165
  const evidenceStoreOpts = { cwd, standalone };
@@ -228,7 +264,7 @@ export async function runCloseValidation({
228
264
  execution = applyChangedFileScope({ gate, spawnCwd, log });
229
265
  } catch (err) {
230
266
  if (!firstIndepFailure) {
231
- firstIndepFailure = { gate, status: 1, cwd: spawnCwd };
267
+ firstIndepFailure = { gate, status: 1 };
232
268
  log(
233
269
  `[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
234
270
  );
@@ -271,7 +307,7 @@ export async function runCloseValidation({
271
307
  }
272
308
  if (result.status !== 0) {
273
309
  if (!firstIndepFailure) {
274
- firstIndepFailure = { gate, status: result.status, cwd: spawnCwd };
310
+ firstIndepFailure = { gate, status: result.status };
275
311
  ac.abort();
276
312
  }
277
313
  return;
@@ -287,13 +323,8 @@ export async function runCloseValidation({
287
323
  await Promise.all(indepTasks);
288
324
 
289
325
  if (firstIndepFailure) {
290
- failed.push(firstIndepFailure);
291
- log(
292
- `[close-validation] ✖ ${firstIndepFailure.gate.name} failed (exit ${firstIndepFailure.status}) in ${spawnCwd}`,
293
- );
294
- if (firstIndepFailure.gate.hint) {
295
- log(`[close-validation] hint: ${firstIndepFailure.gate.hint}`);
296
- }
326
+ const { gate, status } = firstIndepFailure;
327
+ failed.push(reportGateExit({ gate, status, cwd: spawnCwd, log }));
297
328
  return { ok: false, failed, skipped };
298
329
  }
299
330
 
@@ -324,29 +355,36 @@ export async function runCloseValidation({
324
355
  }
325
356
 
326
357
  /**
327
- * The `fullSuiteLock` gate shares coverage capture's timeout so a hung suite
328
- * fails instead of holding the host lock. Under `deferOnLockExpiry` every
329
- * gate child inherits the defer opt-in via env.
358
+ * The `fullSuiteLock` gate shares coverage capture's timeout
359
+ * (`coverage.timeoutMs`) so a hung suite fails instead of holding the host
360
+ * lock; the same figure bounds its lock wait (`resolveFullSuiteLockBudget`).
330
361
  *
331
- * @param {{ config: object|null, deferOnLockExpiry: boolean }} args
362
+ * @param {{ config: object|null }} args
332
363
  * @returns {{ forGate: (gate: object) => object }}
333
364
  */
334
- function fullSuiteLockOptions({ config, deferOnLockExpiry }) {
365
+ function fullSuiteLockOptions({ config }) {
335
366
  const timeoutMs = getQuality(config).coverage?.timeoutMs;
336
- const deferEnv = deferOnLockExpiry
337
- ? { [FULL_SUITE_LOCK_EXPIRY_ENV]: 'defer' }
338
- : null;
339
367
  return {
340
368
  forGate(gate) {
341
- const env = deferEnv ? { ...gate.env, ...deferEnv } : gate.env;
342
369
  return {
343
- ...(env ? { env } : {}),
344
- ...(gate.fullSuiteLock ? { timeoutMs, deferOnLockExpiry } : {}),
370
+ ...(gate.env ? { env: gate.env } : {}),
371
+ ...(gate.fullSuiteLock ? fullSuiteGateOptions(gate, timeoutMs) : {}),
345
372
  };
346
373
  },
347
374
  };
348
375
  }
349
376
 
377
+ /**
378
+ * A full-suite gate's kill bound, and the command its lock-expiry line names.
379
+ *
380
+ * @param {{ cmd: string, args?: string[] }} gate
381
+ * @param {number} timeoutMs
382
+ */
383
+ function fullSuiteGateOptions(gate, timeoutMs) {
384
+ const rerunCommand = [gate.cmd, ...(gate.args ?? [])].join(' ');
385
+ return { timeoutMs, lockOptions: { rerunCommand } };
386
+ }
387
+
350
388
  /**
351
389
  * Mutates the caller's `failed` / `skipped` accumulators.
352
390
  *
@@ -368,21 +406,18 @@ async function runSerialGates(
368
406
  dispatchGate,
369
407
  },
370
408
  ) {
371
- const failGate = (gate, status, message) => {
372
- failed.push({ gate, status, cwd: spawnCwd });
373
- log(message);
374
- if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
409
+ const failGate = (gate, status) => {
410
+ failed.push(reportGateExit({ gate, status, cwd: spawnCwd, log }));
375
411
  };
376
412
  for (const gate of serial) {
377
413
  let execution;
378
414
  try {
379
415
  execution = applyChangedFileScope({ gate, spawnCwd, log });
380
416
  } catch (err) {
381
- failGate(
382
- gate,
383
- 1,
417
+ log(
384
418
  `[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
385
419
  );
420
+ failGate(gate, 1);
386
421
  return;
387
422
  }
388
423
  if (execution.skip) {
@@ -414,11 +449,7 @@ async function runSerialGates(
414
449
  configHash,
415
450
  );
416
451
  if (result.status !== 0) {
417
- failGate(
418
- gate,
419
- result.status,
420
- `[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
421
- );
452
+ failGate(gate, result.status);
422
453
  return;
423
454
  }
424
455
  log(`[close-validation] ✓ ${gate.name}`);
@@ -3,6 +3,12 @@
3
3
  import { COVERAGE_GATE_DEFAULTS } from '../quality.js';
4
4
  import { gateBase, SAFE_STRING } from './shared.js';
5
5
 
6
+ /** Schema bounds on `coverage.timeoutMs`: one minute to two hours. */
7
+ export const COVERAGE_TIMEOUT_BOUNDS = Object.freeze({
8
+ min: 60_000,
9
+ max: 7_200_000,
10
+ });
11
+
6
12
  export const COVERAGE_GATE = {
7
13
  type: 'object',
8
14
  description:
@@ -21,6 +27,21 @@ export const COVERAGE_GATE = {
21
27
  'Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads.',
22
28
  default: COVERAGE_GATE_DEFAULTS.coveragePath,
23
29
  },
30
+ captureScope: {
31
+ type: 'string',
32
+ enum: ['full', 'affected'],
33
+ description:
34
+ 'What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite.',
35
+ default: COVERAGE_GATE_DEFAULTS.captureScope,
36
+ },
37
+ timeoutMs: {
38
+ type: 'integer',
39
+ minimum: COVERAGE_TIMEOUT_BOUNDS.min,
40
+ maximum: COVERAGE_TIMEOUT_BOUNDS.max,
41
+ description:
42
+ 'Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure.',
43
+ default: COVERAGE_GATE_DEFAULTS.timeoutMs,
44
+ },
24
45
  },
25
46
  additionalProperties: false,
26
47
  };
@@ -66,6 +66,8 @@ export const COVERAGE_GATE_DEFAULTS = Object.freeze({
66
66
  tolerance: Object.freeze({ kind: 'absolute', value: 0 }),
67
67
  floors: DEFAULT_COVERAGE_FLOORS,
68
68
  coveragePath: 'coverage/coverage-final.json',
69
+ // `affected` runs the consumer's `test:coverage:affected` instead.
70
+ captureScope: 'full',
69
71
  // On expiry `runCapture` exits 124 so callers can tell a hang from a failure.
70
72
  timeoutMs: 600_000,
71
73
  });
@@ -101,6 +103,8 @@ const COVERAGE_GATE_KEYS = new Set([
101
103
  'tolerance',
102
104
  'floors',
103
105
  'coveragePath',
106
+ 'captureScope',
107
+ 'timeoutMs',
104
108
  ]);
105
109
 
106
110
  const MI_GATE_KEYS = new Set([
@@ -265,6 +269,7 @@ function resolveCoverageGate(userBlock) {
265
269
  enabled: defaults.enabled,
266
270
  baselinePath: defaults.baselinePath,
267
271
  coveragePath: defaults.coveragePath,
272
+ captureScope: defaults.captureScope,
268
273
  tolerance: toleranceScalar(defaults.tolerance, 0),
269
274
  timeoutMs: defaults.timeoutMs,
270
275
  };
@@ -274,11 +279,12 @@ function resolveCoverageGate(userBlock) {
274
279
  enabled: userBlock.enabled ?? defaults.enabled,
275
280
  baselinePath: userBlock.baselinePath ?? defaults.baselinePath,
276
281
  coveragePath: userBlock.coveragePath ?? defaults.coveragePath,
282
+ captureScope: userBlock.captureScope ?? defaults.captureScope,
277
283
  tolerance: toleranceScalar(
278
284
  userBlock.tolerance,
279
285
  toleranceScalar(defaults.tolerance, 0),
280
286
  ),
281
- timeoutMs: defaults.timeoutMs,
287
+ timeoutMs: userBlock.timeoutMs ?? defaults.timeoutMs,
282
288
  };
283
289
  }
284
290
 
@@ -214,6 +214,21 @@ export function storyTerminalEnvelopePath(sid, config) {
214
214
  return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
215
215
  }
216
216
 
217
+ /**
218
+ * The worker's held Story-scope review, beside the terminal envelope so the
219
+ * close that adopts it reads it from the same anchored directory.
220
+ *
221
+ * @param {number} sid
222
+ * @param {object} [config]
223
+ * @returns {string}
224
+ */
225
+ export function storyReviewDepositPath(sid, config) {
226
+ return path.join(
227
+ orchestrationLogDir(config),
228
+ `story-review-${storyId(sid)}.json`,
229
+ );
230
+ }
231
+
217
232
  const runId = (id) => {
218
233
  if (!Number.isInteger(id) || id <= 0) {
219
234
  throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
@@ -25,7 +25,7 @@ const EXECUTION_SCHEMA = {
25
25
  fullSuiteLock: {
26
26
  type: 'boolean',
27
27
  description:
28
- 'Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
28
+ 'Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
29
29
  default: true,
30
30
  },
31
31
  },
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import picomatch from 'picomatch';
9
9
  import { write, writeFile } from './baselines/writer.js';
10
+ import { captureStampPath } from './coverage-capture.js';
10
11
 
11
12
  const COVERAGE_FINAL_PATH = 'coverage/coverage-final.json';
12
13
  export const COVERAGE_BASELINE_PATH = 'baselines/coverage.json';
@@ -202,20 +203,64 @@ export function axisToleranceFor(
202
203
  return Math.max(baseTolerance, eventResolution * NOISE_EVENT_HEADROOM);
203
204
  }
204
205
 
206
+ /** The capture stamp's scope; `full` when absent, unreadable or unscoped. */
207
+ export function readArtifactCaptureScope(
208
+ cwd,
209
+ coveragePath = COVERAGE_FINAL_PATH,
210
+ fsImpl = fs,
211
+ ) {
212
+ try {
213
+ const stamp = JSON.parse(
214
+ fsImpl.readFileSync(captureStampPath(cwd, coveragePath), 'utf8'),
215
+ );
216
+ return typeof stamp?.scope === 'string' ? stamp.scope : 'full';
217
+ } catch {
218
+ return 'full';
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Narrow a refresh scope (`null` = full) to measured files, keeping any file
224
+ * `mustMeasure` names so its missing row fails the refresh.
225
+ */
226
+ function narrowScopeToMeasured(scopeFiles, measuredFiles, mustMeasure) {
227
+ if (scopeFiles === null) return [...measuredFiles];
228
+ const measured = new Set(measuredFiles);
229
+ return scopeFiles.filter((file) => measured.has(file) || mustMeasure(file));
230
+ }
231
+
232
+ /**
233
+ * Baseline rows absent from `current`. From a full artifact they are removed
234
+ * files. From an `affected` artifact they are unmeasured and not reported,
235
+ * except a changed file: that one fails closed as new, as does a changed
236
+ * file with no baseline row the scoped run skipped.
237
+ */
238
+ function classifyAbsent(current, baseline, { artifactScope, changedFiles }) {
239
+ const absent = Object.keys(baseline).filter((f) => current[f] === undefined);
240
+ if (artifactScope !== 'affected') {
241
+ return { removedFiles: absent.map((file) => ({ file })), unmeasured: [] };
242
+ }
243
+ const unmeasured = (changedFiles ?? [])
244
+ .filter((file) => current[file] === undefined)
245
+ .map((file) => ({ file, current: null, reason: 'unmeasured' }));
246
+ return { removedFiles: [], unmeasured };
247
+ }
248
+
205
249
  /**
206
250
  * Classify files: `regressions` (an axis dropped beyond tolerance) and
207
251
  * `newFiles` (else untested code lands at 0%) fail the CLI; `removedFiles`
208
- * and `improvements` are reported only.
252
+ * and `improvements` are reported only. `opts.artifactScope: 'affected'`
253
+ * with the in-scope `opts.changedFiles` reads absent rows as unmeasured.
209
254
  */
210
255
  export function compareScores(
211
256
  current,
212
257
  baseline,
213
258
  tolerance = COVERAGE_TOLERANCE,
259
+ opts = {},
214
260
  ) {
215
261
  const regressions = [];
216
262
  const newFiles = [];
217
263
  const improvements = [];
218
- const removedFiles = [];
219
264
 
220
265
  for (const [file, scores] of Object.entries(current)) {
221
266
  const base = baseline[file];
@@ -252,9 +297,37 @@ export function compareScores(
252
297
  improvements.push({ file });
253
298
  }
254
299
  }
255
- for (const file of Object.keys(baseline)) {
256
- if (current[file] === undefined) removedFiles.push({ file });
257
- }
300
+ const { removedFiles, unmeasured } = classifyAbsent(current, baseline, opts);
301
+ newFiles.push(...unmeasured);
258
302
 
259
303
  return { regressions, newFiles, improvements, removedFiles };
260
304
  }
305
+
306
+ /**
307
+ * `refreshBaseline` scope options. Under an `affected` artifact the scope is
308
+ * narrowed to measured files, so the scope merge preserves a row the scoped
309
+ * run skipped instead of deleting it. A changed file `inCoverageScope` names
310
+ * stays in scope and must produce a row: skipping it fails closed.
311
+ */
312
+ export async function resolveCoverageRefreshScope({
313
+ cwd,
314
+ fullScope,
315
+ diffScopeRef,
316
+ readCaptureScope,
317
+ listMeasured,
318
+ inCoverageScope,
319
+ deriveDiffFiles,
320
+ }) {
321
+ if (readCaptureScope(cwd) !== 'affected') {
322
+ if (fullScope) return { fullScope: true };
323
+ return diffScopeRef ? { baseRef: diffScopeRef } : {};
324
+ }
325
+ const diff = fullScope
326
+ ? null
327
+ : await deriveDiffFiles(diffScopeRef ?? 'origin/main');
328
+ return {
329
+ scopeFiles: narrowScopeToMeasured(diff, listMeasured(cwd), inCoverageScope),
330
+ requireRowsForScopeFiles: true,
331
+ requiredScopeFilePredicate: inCoverageScope,
332
+ };
333
+ }