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
@@ -0,0 +1,345 @@
1
+ /**
2
+ * `captureScope: "affected"` capture path for `coverage-capture.js`. The
3
+ * consumer owns how its suite is scoped; mandrel only names the base ref and
4
+ * keeps the artifact complete-looking for every per-file reader.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { resolveChangedFilesRef } from './changed-files.js';
9
+ import {
10
+ captureStampPath,
11
+ reportCaptureFailure,
12
+ stampCapturedTree,
13
+ } from './coverage-capture.js';
14
+ import {
15
+ describeStampFreshness,
16
+ planDeltaRefresh,
17
+ readHeadCommit,
18
+ } from './coverage-capture-delta.js';
19
+ import { tryIncrementalCapture } from './coverage-capture-incremental.js';
20
+
21
+ const AFFECTED_CAPTURE_SCRIPT = 'test:coverage:affected';
22
+ const COVERAGE_BASE_REF_ENV = 'MANDREL_COVERAGE_BASE_REF';
23
+
24
+ /** A configured scope with no script warns, and the caller runs full scope. */
25
+ function shouldCaptureAffected({
26
+ coverage,
27
+ scripts,
28
+ hasNpmScriptImpl,
29
+ logger,
30
+ }) {
31
+ if (coverage?.captureScope !== 'affected') return false;
32
+ if (hasNpmScriptImpl(scripts, AFFECTED_CAPTURE_SCRIPT)) return true;
33
+ logger.warn(
34
+ `[coverage-capture] ⚠ captureScope is "affected" but package.json has no "${AFFECTED_CAPTURE_SCRIPT}" script — running the full npm run test:coverage instead.`,
35
+ );
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Affected capture when it applies, else incremental mode; `null` means
41
+ * neither applies and the caller runs full scope.
42
+ *
43
+ * @returns {Promise<number | null>}
44
+ */
45
+ export async function tryScopedCapture({
46
+ readPackageScriptsImpl,
47
+ hasNpmScriptImpl,
48
+ ...opts
49
+ }) {
50
+ const applies = shouldCaptureAffected({
51
+ coverage: opts.coverage,
52
+ scripts: readPackageScriptsImpl(opts.args.cwd),
53
+ hasNpmScriptImpl,
54
+ logger: opts.logger,
55
+ });
56
+ return applies
57
+ ? await runAffectedCapture(opts)
58
+ : await tryIncrementalCapture(opts);
59
+ }
60
+
61
+ /**
62
+ * Scoped rows win; a prior row of a changed file is dropped, so a changed
63
+ * file the scoped run skipped stays absent and fails closed downstream.
64
+ * Under `keepPrior` (a delta refresh) a narrower run replaces only the
65
+ * dropped files: its partial rows for a file the prior run measured in full
66
+ * would undercount it.
67
+ */
68
+ function mergeCoverageArtifacts({ prior, scoped, cwd, dropFiles, keepPrior }) {
69
+ const drop = new Set(dropFiles.map((f) => path.resolve(cwd, f)));
70
+ const merged = {};
71
+ for (const [abs, entry] of Object.entries(prior ?? {})) {
72
+ if (!drop.has(path.resolve(abs))) merged[abs] = entry;
73
+ }
74
+ return overlayScoped(merged, scoped, keepPrior);
75
+ }
76
+
77
+ function overlayScoped(merged, scoped, keepPrior) {
78
+ const take = ([abs]) => !keepPrior || !(abs in merged);
79
+ return Object.assign(
80
+ merged,
81
+ Object.fromEntries(Object.entries(scoped).filter(take)),
82
+ );
83
+ }
84
+
85
+ function readArtifact(abs, fsImpl) {
86
+ try {
87
+ return JSON.parse(fsImpl.readFileSync(abs, 'utf8'));
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /** An unstamped artifact may be a red run's: its rows are never merged. */
94
+ function readPriorArtifact(cwd, crap, fsImpl) {
95
+ const stamped = fsImpl.existsSync(captureStampPath(cwd, crap.coveragePath));
96
+ return stamped
97
+ ? readArtifact(path.resolve(cwd, crap.coveragePath), fsImpl)
98
+ : null;
99
+ }
100
+
101
+ function readChangedFiles({ getChangedFilesImpl, ref, cwd, logger }) {
102
+ try {
103
+ return getChangedFilesImpl({ ref, cwd });
104
+ } catch (err) {
105
+ logger.warn(
106
+ `[coverage-capture] ⚠ affected mode: ${err?.message ?? err} — capturing without a prior-artifact merge.`,
107
+ );
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** The tree a run is about to measure: its source digest and HEAD commit. */
113
+ function snapshotTree({
114
+ args,
115
+ crap,
116
+ computeContentDigestImpl,
117
+ readHeadCommitImpl,
118
+ }) {
119
+ return {
120
+ preDigest: computeContentDigestImpl(args.cwd, crap.targetDirs),
121
+ commit: readHeadCommitImpl(args.cwd),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Run the affected script against `baseRef`. The pre-spawn digest and
127
+ * commit are what the stamp records; see `stampCapturedTree`.
128
+ *
129
+ * @returns {Promise<{ code: number, preDigest: string | null, commit: string | null }>}
130
+ */
131
+ async function runScoped({
132
+ crap,
133
+ coverage,
134
+ args,
135
+ baseRef,
136
+ probe,
137
+ runCaptureImpl,
138
+ computeContentDigestImpl,
139
+ readHeadCommitImpl,
140
+ logger,
141
+ }) {
142
+ const tree = snapshotTree({
143
+ args,
144
+ crap,
145
+ computeContentDigestImpl,
146
+ readHeadCommitImpl,
147
+ });
148
+ const code = await runCaptureImpl({
149
+ cwd: args.cwd,
150
+ coveragePath: crap.coveragePath,
151
+ timeoutMs: coverage?.timeoutMs,
152
+ script: AFFECTED_CAPTURE_SCRIPT,
153
+ env: { [COVERAGE_BASE_REF_ENV]: baseRef },
154
+ log: (m) => logger.info(m),
155
+ recheckFresh: () => probe().fresh === true,
156
+ });
157
+ return { code, ...tree };
158
+ }
159
+
160
+ /**
161
+ * Merge the scoped rows over `prior`, dropping `dropFiles`' prior rows.
162
+ *
163
+ * @returns {boolean} False when the run wrote no readable artifact.
164
+ */
165
+ function writeMerged({
166
+ crap,
167
+ args,
168
+ prior,
169
+ dropFiles,
170
+ keepPrior,
171
+ logger,
172
+ fsImpl,
173
+ }) {
174
+ const artifactAbs = path.resolve(args.cwd, crap.coveragePath);
175
+ const scoped = readArtifact(artifactAbs, fsImpl);
176
+ if (scoped === null) {
177
+ logger.error(
178
+ `[coverage-capture] ✖ npm run ${AFFECTED_CAPTURE_SCRIPT} exited 0 but wrote no readable artifact at ${crap.coveragePath} — point its coverage output there.`,
179
+ );
180
+ return false;
181
+ }
182
+ const merged = mergeCoverageArtifacts({
183
+ prior,
184
+ scoped,
185
+ cwd: args.cwd,
186
+ dropFiles,
187
+ keepPrior,
188
+ });
189
+ fsImpl.writeFileSync(artifactAbs, JSON.stringify(merged));
190
+ return true;
191
+ }
192
+
193
+ /**
194
+ * Run, merge and stamp. A non-zero run is a red capture — never a fallback.
195
+ *
196
+ * @returns {Promise<number>}
197
+ */
198
+ async function captureAndMerge(opts) {
199
+ const run = await runScoped(opts);
200
+ if (run.code !== 0) return reportCaptureFailure(run.code, opts.logger);
201
+ if (!writeMerged(opts)) return 1;
202
+ stampCapturedTree({
203
+ ...opts,
204
+ targetDirs: opts.crap.targetDirs,
205
+ coveragePath: opts.crap.coveragePath,
206
+ cwd: opts.args.cwd,
207
+ scope: 'affected',
208
+ preDigest: run.preDigest,
209
+ commit: run.commit,
210
+ ...opts.stamp,
211
+ });
212
+ return 0;
213
+ }
214
+
215
+ /** The affected-scope freshness probe, reused as the post-lock recheck. */
216
+ function affectedProbe({ isCoverageFreshImpl, crap, args }) {
217
+ return () =>
218
+ isCoverageFreshImpl({
219
+ coveragePath: crap.coveragePath,
220
+ targetDirs: crap.targetDirs,
221
+ cwd: args.cwd,
222
+ requireScope: 'affected',
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Probe at affected scope and log the verdict with its stamp detail.
228
+ *
229
+ * @returns {{ fresh: boolean, reason: string }}
230
+ */
231
+ function probeAndReport({ probe, crap, args, logger }) {
232
+ const freshness = probe();
233
+ const detail = describeStampFreshness({
234
+ cwd: args.cwd,
235
+ coveragePath: crap.coveragePath,
236
+ requiredScope: 'affected',
237
+ verdict: freshness.reason,
238
+ });
239
+ const action = freshness.fresh ? ' — skipping capture.' : '.';
240
+ logger.info(
241
+ `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason} (affected)${action} ${detail}`,
242
+ );
243
+ return freshness;
244
+ }
245
+
246
+ /** Under `skipWhenUnchanged`, a readable change set with nothing in scope skips. */
247
+ function skipsUnchanged({ crap, changed, scopedFiles, ref, logger }) {
248
+ const skip =
249
+ crap.incrementalCoverage?.skipWhenUnchanged === true &&
250
+ Boolean(changed) &&
251
+ scopedFiles.length === 0;
252
+ if (skip) {
253
+ logger.info(
254
+ `[coverage-capture] Affected mode: no changed files under [${crap.targetDirs.join(', ')}] vs ${ref} — skipping capture.`,
255
+ );
256
+ }
257
+ return skip;
258
+ }
259
+
260
+ /**
261
+ * What the run merges over and is keyed on: the stamped commit and main's
262
+ * delta for an eligible delta refresh, else `ref` and the Story change set.
263
+ *
264
+ * @returns {{ prior: object | null, baseRef: string, dropFiles: string[], keepPrior: boolean }}
265
+ */
266
+ function resolveRunBase({
267
+ freshness,
268
+ crap,
269
+ args,
270
+ changed,
271
+ ref,
272
+ fsImpl,
273
+ ...rest
274
+ }) {
275
+ const prior = changed ? readPriorArtifact(args.cwd, crap, fsImpl) : null;
276
+ const delta = planDeltaRefresh({
277
+ ...rest,
278
+ freshness,
279
+ crap,
280
+ args,
281
+ changed,
282
+ prior,
283
+ });
284
+ return {
285
+ prior,
286
+ baseRef: delta?.baseRef ?? ref,
287
+ dropFiles: delta?.dropFiles ?? changed ?? [],
288
+ keepPrior: delta !== null,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Honours `skipWhenUnchanged` as incremental mode does. Nothing prior is
294
+ * merged with an unreadable change set (a stale row for a changed file must
295
+ * never read as fresh) or with no stamp on disk (an unstamped artifact may
296
+ * be a red run's, and its rows must not ride into a green one).
297
+ */
298
+ async function runAffectedCapture({
299
+ crap,
300
+ args,
301
+ getChangedFilesImpl,
302
+ filterFilesUnderTargetsImpl,
303
+ isCoverageFreshImpl,
304
+ classifyDeltaRefreshImpl,
305
+ readHeadCommitImpl = readHeadCommit,
306
+ logger,
307
+ fsImpl = fs,
308
+ ...rest
309
+ }) {
310
+ const ref = resolveChangedFilesRef({ crap, ref: args.ref });
311
+ const changed = readChangedFiles({
312
+ getChangedFilesImpl,
313
+ ref,
314
+ cwd: args.cwd,
315
+ logger,
316
+ });
317
+ const scopedFiles = filterFilesUnderTargetsImpl(changed, crap.targetDirs);
318
+ if (skipsUnchanged({ crap, changed, scopedFiles, ref, logger })) return 0;
319
+
320
+ const probe = affectedProbe({ isCoverageFreshImpl, crap, args });
321
+ const freshness = probeAndReport({ probe, crap, args, logger });
322
+ if (freshness.fresh) return 0;
323
+
324
+ const base = resolveRunBase({
325
+ freshness,
326
+ crap,
327
+ args,
328
+ changed,
329
+ ref,
330
+ fsImpl,
331
+ classifyDeltaRefreshImpl,
332
+ logger,
333
+ });
334
+ return captureAndMerge({
335
+ ...rest,
336
+ ...base,
337
+ crap,
338
+ args,
339
+ stamp: { files: scopedFiles, ref },
340
+ probe,
341
+ readHeadCommitImpl,
342
+ logger,
343
+ fsImpl,
344
+ });
345
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Delta refresh for `captureScope: "affected"`: after a base-sync merges main
3
+ * commits that share no file with the Story, re-measure only those commits'
4
+ * tests instead of re-running the Story's whole affected scope. Every
5
+ * uncertainty fails closed to an ordinary capture.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { captureStampPath, computeContentDigest } from './coverage-capture.js';
10
+ import { gitSpawn } from './git-utils.js';
11
+
12
+ /** Basenames whose change moves coverage beyond the files it names. */
13
+ const COVERAGE_CONFIG_RE =
14
+ /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|tsconfig.*\.json|(vitest|vite|jest|nyc)\.(config|workspace)\.[cm]?[jt]s|\.c8rc.*|\.nycrc.*)$/;
15
+
16
+ /** @param {string} file @returns {boolean} */
17
+ export function isCoverageConfigFile(file) {
18
+ return COVERAGE_CONFIG_RE.test(path.posix.basename(String(file)));
19
+ }
20
+
21
+ /** A spawn failure reads as a non-zero status, which callers treat as "unknown". */
22
+ function git(cwd, args, spawn) {
23
+ const res = spawn(cwd, ...args);
24
+ return { status: res?.status ?? 1, stdout: res?.stdout ?? '' };
25
+ }
26
+
27
+ /** @returns {string | null} The HEAD sha, or `null` when unavailable. */
28
+ export function readHeadCommit(cwd, io = {}) {
29
+ const res = git(cwd, ['rev-parse', 'HEAD'], io.gitSpawn ?? gitSpawn);
30
+ const sha = res?.status === 0 ? res.stdout.trim() : '';
31
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
32
+ }
33
+
34
+ /** @returns {Record<string, unknown> | null} */
35
+ function readCaptureStamp(cwd, coveragePath, io = {}) {
36
+ const readFileSync = io.readFileSync ?? fs.readFileSync;
37
+ try {
38
+ const stamp = JSON.parse(
39
+ readFileSync(captureStampPath(cwd, coveragePath), 'utf8'),
40
+ );
41
+ return stamp && typeof stamp === 'object' ? stamp : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** A stamp without `scope` is full-scope; no stamp at all is `none`. */
48
+ function stampScopeOf(stamp) {
49
+ if (!stamp) return 'none';
50
+ return typeof stamp.scope === 'string' ? stamp.scope : 'full';
51
+ }
52
+
53
+ /** The suffix every freshness log line carries, so staleness is explainable from the log. */
54
+ export function formatFreshnessDetail({
55
+ stamp,
56
+ requiredScope,
57
+ verdict,
58
+ deltaFiles,
59
+ }) {
60
+ const delta =
61
+ typeof deltaFiles === 'number' ? `, delta: ${deltaFiles} file(s)` : '';
62
+ return `(stamp scope: ${stampScopeOf(stamp)}, required scope: ${requiredScope}, verdict: ${verdict}${delta})`;
63
+ }
64
+
65
+ /** {@link formatFreshnessDetail} for the stamp on disk. */
66
+ export function describeStampFreshness({
67
+ cwd,
68
+ coveragePath,
69
+ requiredScope,
70
+ verdict,
71
+ }) {
72
+ return formatFreshnessDetail({
73
+ stamp: readCaptureStamp(cwd, coveragePath),
74
+ requiredScope,
75
+ verdict,
76
+ });
77
+ }
78
+
79
+ /** A dirty file is in no git delta, so a dirty tree is ineligible. */
80
+ function treeIneligibility({ cwd, commit, spawn }) {
81
+ if (typeof commit !== 'string' || commit.length === 0) {
82
+ return 'stamp records no commit';
83
+ }
84
+ const status = git(cwd, ['status', '--porcelain'], spawn);
85
+ if (status?.status !== 0) return 'git status failed';
86
+ if (status.stdout.trim().length > 0) return 'worktree is dirty';
87
+ const ancestor = git(
88
+ cwd,
89
+ ['merge-base', '--is-ancestor', commit, 'HEAD'],
90
+ spawn,
91
+ );
92
+ if (ancestor?.status !== 0)
93
+ return 'stamped commit is not an ancestor of HEAD';
94
+ return null;
95
+ }
96
+
97
+ function deltaIneligibility({ delta, storyFiles }) {
98
+ if (delta.length === 0) return 'no committed delta since the stamp';
99
+ const story = new Set(storyFiles);
100
+ const shared = delta.find((f) => story.has(f));
101
+ if (shared) return `delta shares ${shared} with the Story's change set`;
102
+ const config = delta.find(isCoverageConfigFile);
103
+ if (config) return `delta touches coverage-determining config ${config}`;
104
+ return null;
105
+ }
106
+
107
+ function parseNames(stdout) {
108
+ return stdout
109
+ .split('\n')
110
+ .map((l) => l.trim().replace(/\\/g, '/'))
111
+ .filter((l) => l.length > 0);
112
+ }
113
+
114
+ /** Any git error or unresolvable input is ineligible. */
115
+ function classifyDeltaRefresh({
116
+ cwd,
117
+ crap,
118
+ storyFiles,
119
+ prior,
120
+ gitSpawnImpl: spawn = gitSpawn,
121
+ readFileSync = fs.readFileSync,
122
+ computeContentDigestImpl = computeContentDigest,
123
+ }) {
124
+ const stamp = readCaptureStamp(cwd, crap.coveragePath, { readFileSync });
125
+ const no = (reason) => ({ eligible: false, reason, stamp });
126
+ if (typeof stamp?.digest !== 'string') return no('no digest stamp');
127
+ const current = computeContentDigestImpl(cwd, crap.targetDirs);
128
+ if (!current || current === stamp.digest) return no('not stale by digest');
129
+ if (!Array.isArray(storyFiles)) return no('Story change set unreadable');
130
+ if (!prior) return no('no prior artifact to merge over');
131
+ const commit = stamp.commit;
132
+ const treeReason = treeIneligibility({ cwd, commit, spawn });
133
+ if (treeReason) return no(treeReason);
134
+ const diff = git(cwd, ['diff', '--name-only', commit, 'HEAD'], spawn);
135
+ if (diff?.status !== 0) return no('git diff failed');
136
+ const delta = parseNames(diff.stdout);
137
+ const deltaReason = deltaIneligibility({ delta, storyFiles });
138
+ if (deltaReason) return no(deltaReason);
139
+ return { eligible: true, commit, delta, stamp };
140
+ }
141
+
142
+ /**
143
+ * A stale stamp whose staleness is wholly main's delta is re-measured keyed
144
+ * on the stamped commit; `null` means capture the Story's scope as usual.
145
+ *
146
+ * @returns {{ baseRef: string, dropFiles: string[] } | null}
147
+ */
148
+ export function planDeltaRefresh({
149
+ freshness,
150
+ crap,
151
+ args,
152
+ changed,
153
+ prior,
154
+ classifyDeltaRefreshImpl = classifyDeltaRefresh,
155
+ logger,
156
+ }) {
157
+ if (freshness.reason !== 'stale') return null;
158
+ const plan = classifyDeltaRefreshImpl({
159
+ cwd: args.cwd,
160
+ crap,
161
+ storyFiles: changed,
162
+ prior,
163
+ });
164
+ if (!plan.eligible) {
165
+ logger.info(
166
+ `[coverage-capture] Affected mode: delta refresh not eligible (${plan.reason}) — capturing the Story's affected scope.`,
167
+ );
168
+ return null;
169
+ }
170
+ const detail = formatFreshnessDetail({
171
+ stamp: plan.stamp,
172
+ requiredScope: 'affected',
173
+ verdict: 'delta-refresh',
174
+ deltaFiles: plan.delta.length,
175
+ });
176
+ logger.info(
177
+ `[coverage-capture] Coverage at ${crap.coveragePath} needs a delta refresh — re-measuring ${plan.delta.length} file(s) changed since ${plan.commit.slice(0, 12)}. ${detail}`,
178
+ );
179
+ return { baseRef: plan.commit, dropFiles: plan.delta };
180
+ }
@@ -7,6 +7,31 @@ import {
7
7
  reportCaptureFailure,
8
8
  stampCapturedTree,
9
9
  } from './coverage-capture.js';
10
+ import {
11
+ describeStampFreshness,
12
+ readHeadCommit,
13
+ } from './coverage-capture-delta.js';
14
+
15
+ /**
16
+ * `--skip-when-no-crap-files`: true when no changed file is under
17
+ * `crap.targetDirs`. A bad ref must not relax the gate, so it answers false.
18
+ *
19
+ * @returns {boolean}
20
+ */
21
+ function noCrapFilesChanged({ crap, args, getChangedFilesImpl, logger }) {
22
+ let changed = null;
23
+ try {
24
+ changed = getChangedFilesImpl({
25
+ ref: resolveChangedFilesRef({ crap, ref: args.ref }),
26
+ cwd: args.cwd,
27
+ });
28
+ } catch (err) {
29
+ logger.warn(
30
+ `[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
31
+ );
32
+ }
33
+ return Boolean(changed) && !anyChangedUnderTargets(changed, crap.targetDirs);
34
+ }
10
35
 
11
36
  /**
12
37
  * Optional no-CRAP-files skip, freshness probe, then capture and stamp. The
@@ -22,6 +47,7 @@ import {
22
47
  * runCaptureImpl: Function,
23
48
  * computeContentDigestImpl: Function,
24
49
  * writeCaptureStampImpl: Function,
50
+ * readHeadCommitImpl?: typeof readHeadCommit,
25
51
  * logger: { info: Function, warn: Function, error: Function },
26
52
  * }} opts
27
53
  * @returns {Promise<number>} process exit code
@@ -35,58 +61,52 @@ export async function runFullScopeCapture({
35
61
  runCaptureImpl,
36
62
  computeContentDigestImpl,
37
63
  writeCaptureStampImpl,
64
+ readHeadCommitImpl = readHeadCommit,
38
65
  logger,
39
66
  }) {
40
- if (args.skipWhenNoCrapFiles) {
41
- let changed;
42
- try {
43
- changed = getChangedFilesImpl({
44
- ref: resolveChangedFilesRef({ crap, ref: args.ref }),
45
- cwd: args.cwd,
46
- });
47
- } catch (err) {
48
- // A bad ref must not relax the gate; fall through to freshness.
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
- }
67
+ if (
68
+ args.skipWhenNoCrapFiles &&
69
+ noCrapFilesChanged({ crap, args, getChangedFilesImpl, logger })
70
+ ) {
71
+ logger.info(
72
+ `[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
73
+ );
74
+ return 0;
60
75
  }
61
76
 
62
- const freshness = isCoverageFreshImpl({
63
- coveragePath: crap.coveragePath,
64
- targetDirs: crap.targetDirs,
77
+ const probe = () =>
78
+ isCoverageFreshImpl({
79
+ coveragePath: crap.coveragePath,
80
+ targetDirs: crap.targetDirs,
81
+ cwd: args.cwd,
82
+ });
83
+ const freshness = probe();
84
+ const detail = describeStampFreshness({
65
85
  cwd: args.cwd,
86
+ coveragePath: crap.coveragePath,
87
+ requiredScope: 'full',
88
+ verdict: freshness.reason,
66
89
  });
67
90
  if (freshness.fresh) {
68
91
  logger.info(
69
- `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
92
+ `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture. ${detail}`,
70
93
  );
71
94
  return 0;
72
95
  }
73
96
 
74
97
  logger.info(
75
- `[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage…`,
98
+ `[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage… ${detail}`,
76
99
  );
77
- // Pre-spawn digest; see `stampCapturedTree`.
100
+ // Pre-spawn digest and commit; see `stampCapturedTree`.
78
101
  const preDigest = computeContentDigestImpl(args.cwd, crap.targetDirs);
102
+ const commit = readHeadCommitImpl(args.cwd);
79
103
  const code = await runCaptureImpl({
80
104
  cwd: args.cwd,
105
+ coveragePath: crap.coveragePath,
81
106
  timeoutMs: coverage?.timeoutMs,
82
107
  log: (m) => logger.info(m),
83
108
  // After queueing behind another suite, which may have stamped this tree.
84
- recheckFresh: () =>
85
- isCoverageFreshImpl({
86
- coveragePath: crap.coveragePath,
87
- targetDirs: crap.targetDirs,
88
- cwd: args.cwd,
89
- }).fresh === true,
109
+ recheckFresh: () => probe().fresh === true,
90
110
  });
91
111
  if (code !== 0) return reportCaptureFailure(code, logger);
92
112
 
@@ -95,6 +115,7 @@ export async function runFullScopeCapture({
95
115
  cwd: args.cwd,
96
116
  targetDirs: crap.targetDirs,
97
117
  coveragePath: crap.coveragePath,
118
+ commit,
98
119
  computeContentDigestImpl,
99
120
  writeCaptureStampImpl,
100
121
  logger,