mandrel 2.67.0 → 2.68.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/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +36 -1
- package/.agents/schemas/agentrc.schema.json +14 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +26 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- 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 (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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,
|