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
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveChangedFilesRef } from './changed-files.js';
|
|
4
4
|
import { reportCaptureFailure, stampCapturedTree } from './coverage-capture.js';
|
|
5
|
+
import {
|
|
6
|
+
describeStampFreshness,
|
|
7
|
+
readHeadCommit,
|
|
8
|
+
} from './coverage-capture-delta.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The changed-file set; `null` when incremental mode is off, or (warned)
|
|
12
|
+
* when the ref cannot be resolved.
|
|
13
|
+
*
|
|
14
|
+
* @returns {{ changed: string[] } | null}
|
|
15
|
+
*/
|
|
16
|
+
function readChanged({ crap, getChangedFilesImpl, ref, args, logger }) {
|
|
17
|
+
if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
|
|
18
|
+
try {
|
|
19
|
+
return { changed: getChangedFilesImpl({ ref, cwd: args.cwd }) };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
logger.warn(
|
|
22
|
+
`[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
|
|
23
|
+
);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
|
|
6
28
|
/**
|
|
7
29
|
* Under `skipWhenUnchanged`: the changed-file set decides whether to capture,
|
|
@@ -20,6 +42,7 @@ import { reportCaptureFailure, stampCapturedTree } from './coverage-capture.js';
|
|
|
20
42
|
* runCaptureImpl: Function,
|
|
21
43
|
* computeContentDigestImpl: Function,
|
|
22
44
|
* writeCaptureStampImpl: Function,
|
|
45
|
+
* readHeadCommitImpl?: typeof readHeadCommit,
|
|
23
46
|
* logger: { info: Function, warn: Function, error: Function },
|
|
24
47
|
* }} opts
|
|
25
48
|
* @returns {Promise<number | null>}
|
|
@@ -34,22 +57,17 @@ export async function tryIncrementalCapture({
|
|
|
34
57
|
runCaptureImpl,
|
|
35
58
|
computeContentDigestImpl,
|
|
36
59
|
writeCaptureStampImpl,
|
|
60
|
+
readHeadCommitImpl = readHeadCommit,
|
|
37
61
|
logger,
|
|
38
62
|
}) {
|
|
39
|
-
if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
|
|
40
|
-
|
|
41
63
|
const ref = resolveChangedFilesRef({ crap, ref: args.ref });
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
changed = getChangedFilesImpl({ ref, cwd: args.cwd });
|
|
45
|
-
} catch (err) {
|
|
46
|
-
logger.warn(
|
|
47
|
-
`[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
|
|
48
|
-
);
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
64
|
+
const read = readChanged({ crap, getChangedFilesImpl, ref, args, logger });
|
|
65
|
+
if (read === null) return null;
|
|
51
66
|
|
|
52
|
-
const scopedFiles = filterFilesUnderTargetsImpl(
|
|
67
|
+
const scopedFiles = filterFilesUnderTargetsImpl(
|
|
68
|
+
read.changed,
|
|
69
|
+
crap.targetDirs,
|
|
70
|
+
);
|
|
53
71
|
if (scopedFiles.length === 0) {
|
|
54
72
|
logger.info(
|
|
55
73
|
`[coverage-capture] Incremental mode: no changed files under [${crap.targetDirs.join(', ')}] vs ${ref} — skipping capture.`,
|
|
@@ -57,35 +75,39 @@ export async function tryIncrementalCapture({
|
|
|
57
75
|
return 0;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
const probe = () =>
|
|
79
|
+
isCoverageFreshImpl({
|
|
80
|
+
coveragePath: crap.coveragePath,
|
|
81
|
+
targetDirs: crap.targetDirs,
|
|
82
|
+
cwd: args.cwd,
|
|
83
|
+
requireScope: 'incremental',
|
|
84
|
+
});
|
|
85
|
+
const freshness = probe();
|
|
86
|
+
const detail = describeStampFreshness({
|
|
63
87
|
cwd: args.cwd,
|
|
64
|
-
|
|
88
|
+
coveragePath: crap.coveragePath,
|
|
89
|
+
requiredScope: 'incremental',
|
|
90
|
+
verdict: freshness.reason,
|
|
65
91
|
});
|
|
66
92
|
if (freshness.fresh) {
|
|
67
93
|
logger.info(
|
|
68
|
-
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture
|
|
94
|
+
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture. ${detail}`,
|
|
69
95
|
);
|
|
70
96
|
return 0;
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
logger.info(
|
|
74
|
-
`[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing
|
|
100
|
+
`[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing… ${detail}`,
|
|
75
101
|
);
|
|
76
|
-
// Pre-spawn digest; see `stampCapturedTree`.
|
|
102
|
+
// Pre-spawn digest and commit; see `stampCapturedTree`.
|
|
77
103
|
const preDigest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
104
|
+
const commit = readHeadCommitImpl(args.cwd);
|
|
78
105
|
const code = await runCaptureImpl({
|
|
79
106
|
cwd: args.cwd,
|
|
107
|
+
coveragePath: crap.coveragePath,
|
|
80
108
|
timeoutMs: coverage?.timeoutMs,
|
|
81
109
|
log: (m) => logger.info(m),
|
|
82
|
-
recheckFresh: () =>
|
|
83
|
-
isCoverageFreshImpl({
|
|
84
|
-
coveragePath: crap.coveragePath,
|
|
85
|
-
targetDirs: crap.targetDirs,
|
|
86
|
-
cwd: args.cwd,
|
|
87
|
-
requireScope: 'incremental',
|
|
88
|
-
}).fresh === true,
|
|
110
|
+
recheckFresh: () => probe().fresh === true,
|
|
89
111
|
});
|
|
90
112
|
if (code !== 0) return reportCaptureFailure(code, logger);
|
|
91
113
|
|
|
@@ -97,6 +119,7 @@ export async function tryIncrementalCapture({
|
|
|
97
119
|
scope: 'incremental',
|
|
98
120
|
files: scopedFiles,
|
|
99
121
|
ref,
|
|
122
|
+
commit,
|
|
100
123
|
computeContentDigestImpl,
|
|
101
124
|
writeCaptureStampImpl,
|
|
102
125
|
logger,
|
|
@@ -14,7 +14,7 @@ const COVERAGE_CAPTURE_USAGE = {
|
|
|
14
14
|
invocation:
|
|
15
15
|
'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--require-credited] [--ref <git-ref>] [--cwd <path>]',
|
|
16
16
|
summary:
|
|
17
|
-
'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
|
|
17
|
+
'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale (`npm run test:coverage:affected`, with the base ref in MANDREL_COVERAGE_BASE_REF, under delivery.quality.gates.coverage.captureScope="affected"). Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
|
|
18
18
|
flags: [
|
|
19
19
|
[
|
|
20
20
|
'--skip-when-no-crap-files',
|
|
@@ -3,20 +3,17 @@
|
|
|
3
3
|
* reads it: the scorer skips uncovered methods, so a stale artifact silently
|
|
4
4
|
* weakens the gate.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { LOCK_WAIT_EXPIRED_EXIT_CODE } from './full-suite-lock.js';
|
|
11
|
-
import {
|
|
12
|
-
groupSpawnOptions,
|
|
13
|
-
superviseGroup,
|
|
14
|
-
TIMEOUT_EXIT_CODE,
|
|
15
|
-
} from './process-group.js';
|
|
11
|
+
import { TIMEOUT_EXIT_CODE } from './process-group.js';
|
|
16
12
|
import {
|
|
17
13
|
isScorableSourceFile,
|
|
18
14
|
SCORABLE_SOURCE_EXT_RE,
|
|
19
15
|
} from './source-extensions.js';
|
|
16
|
+
import { runSupervisedSuite } from './supervised-suite.js';
|
|
20
17
|
|
|
21
18
|
/**
|
|
22
19
|
* Newest mtime across the scorable sources the CRAP scanner walks; unreadable
|
|
@@ -156,18 +153,40 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
|
|
|
156
153
|
}
|
|
157
154
|
}
|
|
158
155
|
|
|
156
|
+
/** @param {string} value */
|
|
157
|
+
const nonEmpty = (value) => typeof value === 'string' && value.length > 0;
|
|
158
|
+
|
|
159
|
+
/** Scoped-stamp fields, each written only when it carries a value. */
|
|
160
|
+
function optionalStampFields({ scope, files, ref, commit }) {
|
|
161
|
+
const out = {};
|
|
162
|
+
if (scope !== undefined) out.scope = scope;
|
|
163
|
+
if (Array.isArray(files)) out.files = [...files].sort();
|
|
164
|
+
if (nonEmpty(ref)) out.ref = ref;
|
|
165
|
+
if (nonEmpty(commit)) out.commit = commit;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {Record<string, unknown>} fields */
|
|
170
|
+
function definedOnly(fields) {
|
|
171
|
+
return Object.fromEntries(
|
|
172
|
+
Object.entries(fields).filter(([, value]) => value !== undefined),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
/**
|
|
160
177
|
* Best-effort: a write failure returns `false` (next check falls back to
|
|
161
178
|
* mtime). Full-scope callers omit `scope`, keeping the `{ digest, capturedAt }`
|
|
162
|
-
* shape.
|
|
179
|
+
* shape. `commit` is the HEAD sha the run measured; a stamp without it reads
|
|
180
|
+
* exactly as before and is never delta-refresh eligible.
|
|
163
181
|
*
|
|
164
182
|
* @param {{
|
|
165
183
|
* cwd: string,
|
|
166
184
|
* coveragePath: string,
|
|
167
185
|
* digest: string,
|
|
168
|
-
* scope?: 'full' | 'incremental',
|
|
186
|
+
* scope?: 'full' | 'incremental' | 'affected',
|
|
169
187
|
* files?: string[],
|
|
170
188
|
* ref?: string,
|
|
189
|
+
* commit?: string | null,
|
|
171
190
|
* writeFileSync?: typeof fs.writeFileSync,
|
|
172
191
|
* }} opts
|
|
173
192
|
* @returns {boolean} True when the stamp was written.
|
|
@@ -179,13 +198,15 @@ export function writeCaptureStamp({
|
|
|
179
198
|
scope,
|
|
180
199
|
files,
|
|
181
200
|
ref,
|
|
201
|
+
commit,
|
|
182
202
|
writeFileSync = fs.writeFileSync,
|
|
183
203
|
}) {
|
|
184
204
|
if (typeof digest !== 'string' || digest.length === 0) return false;
|
|
185
|
-
const payload = {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
const payload = {
|
|
206
|
+
digest,
|
|
207
|
+
capturedAt: new Date().toISOString(),
|
|
208
|
+
...optionalStampFields({ scope, files, ref, commit }),
|
|
209
|
+
};
|
|
189
210
|
try {
|
|
190
211
|
writeFileSync(
|
|
191
212
|
captureStampPath(cwd, coveragePath),
|
|
@@ -197,42 +218,45 @@ export function writeCaptureStamp({
|
|
|
197
218
|
}
|
|
198
219
|
}
|
|
199
220
|
|
|
221
|
+
/** Stamp scopes narrower than `full`; each satisfies only its own probe. */
|
|
222
|
+
const PARTIAL_STAMP_SCOPES = new Set(['incremental', 'affected']);
|
|
223
|
+
|
|
200
224
|
/**
|
|
201
225
|
* @param {{digest?: unknown, scope?: unknown} | null} stamp
|
|
202
|
-
* @param {'full' | 'incremental'} requireScope
|
|
226
|
+
* @param {'full' | 'incremental' | 'affected'} requireScope
|
|
203
227
|
* @returns {{ digest: string } | { scopeMismatch: true } | null} `null`
|
|
204
|
-
*
|
|
228
|
+
* means no usable stamp: nothing vouches for the artifact.
|
|
205
229
|
*/
|
|
206
230
|
function readStampForScope(stamp, requireScope) {
|
|
207
231
|
if (typeof stamp?.digest !== 'string' || stamp.digest.length === 0) {
|
|
208
232
|
return null;
|
|
209
233
|
}
|
|
210
|
-
|
|
211
|
-
if (stampScope === 'incremental' && requireScope !== 'incremental') {
|
|
234
|
+
if (PARTIAL_STAMP_SCOPES.has(stamp.scope) && stamp.scope !== requireScope) {
|
|
212
235
|
return { scopeMismatch: true };
|
|
213
236
|
}
|
|
214
237
|
return { digest: stamp.digest };
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
/**
|
|
218
|
-
*
|
|
219
|
-
* mtime
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
241
|
+
* Only a matching stamp vouches for the artifact, because only a green run
|
|
242
|
+
* writes one; artifact mtime says when a run wrote it, never that the run
|
|
243
|
+
* passed, so a red run's artifact must not read as fresh. Without a verdict,
|
|
244
|
+
* `no-sources` (empty source set — "found nothing" is not "nothing changed")
|
|
245
|
+
* outranks `unstamped`. A partial (incremental / affected) stamp satisfies
|
|
246
|
+
* only a probe of its own scope; a stamp with no `scope` is full-scope.
|
|
223
247
|
*
|
|
224
248
|
* @param {{
|
|
225
249
|
* coveragePath: string,
|
|
226
250
|
* targetDirs: string[],
|
|
227
251
|
* cwd: string,
|
|
228
|
-
* requireScope?: 'full' | 'incremental',
|
|
252
|
+
* requireScope?: 'full' | 'incremental' | 'affected',
|
|
229
253
|
* statSync?: typeof fs.statSync,
|
|
230
254
|
* readdirSync?: typeof fs.readdirSync,
|
|
231
255
|
* existsSync?: typeof fs.existsSync,
|
|
232
256
|
* readFileSync?: typeof fs.readFileSync,
|
|
233
257
|
* computeDigest?: typeof computeContentDigest,
|
|
234
258
|
* }} opts
|
|
235
|
-
* @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' }}
|
|
259
|
+
* @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' | 'unstamped' }}
|
|
236
260
|
*/
|
|
237
261
|
export function isCoverageFresh({
|
|
238
262
|
coveragePath,
|
|
@@ -254,7 +278,7 @@ export function isCoverageFresh({
|
|
|
254
278
|
try {
|
|
255
279
|
stamp = JSON.parse(readFileSync(stampPath, 'utf8'));
|
|
256
280
|
} catch {
|
|
257
|
-
// Corrupt/unreadable stamp
|
|
281
|
+
// Corrupt/unreadable stamp vouches for nothing.
|
|
258
282
|
}
|
|
259
283
|
const resolved = readStampForScope(stamp, requireScope);
|
|
260
284
|
if (resolved?.scopeMismatch) {
|
|
@@ -270,21 +294,14 @@ export function isCoverageFresh({
|
|
|
270
294
|
}
|
|
271
295
|
}
|
|
272
296
|
|
|
273
|
-
let coverageMtime;
|
|
274
|
-
try {
|
|
275
|
-
coverageMtime = statSync(absCoverage).mtimeMs;
|
|
276
|
-
} catch {
|
|
277
|
-
return { fresh: false, reason: 'missing' };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
297
|
const newestSrc = newestSourceMtime(cwd, targetDirs, {
|
|
281
298
|
statSync,
|
|
282
299
|
readdirSync,
|
|
283
300
|
});
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
return {
|
|
302
|
+
fresh: false,
|
|
303
|
+
reason: newestSrc === 0 ? 'no-sources' : 'unstamped',
|
|
304
|
+
};
|
|
288
305
|
}
|
|
289
306
|
|
|
290
307
|
/**
|
|
@@ -358,9 +375,10 @@ export function creditedCapture(runCaptureFn, { requireCredited, logger }) {
|
|
|
358
375
|
* cwd: string,
|
|
359
376
|
* targetDirs: string[],
|
|
360
377
|
* coveragePath: string,
|
|
361
|
-
* scope?: 'full' | 'incremental',
|
|
378
|
+
* scope?: 'full' | 'incremental' | 'affected',
|
|
362
379
|
* files?: string[],
|
|
363
380
|
* ref?: string,
|
|
381
|
+
* commit?: string | null,
|
|
364
382
|
* computeContentDigestImpl: typeof computeContentDigest,
|
|
365
383
|
* writeCaptureStampImpl: typeof writeCaptureStamp,
|
|
366
384
|
* logger: { info: Function, warn: Function, error: Function },
|
|
@@ -375,6 +393,7 @@ export function stampCapturedTree({
|
|
|
375
393
|
scope,
|
|
376
394
|
files,
|
|
377
395
|
ref,
|
|
396
|
+
commit,
|
|
378
397
|
computeContentDigestImpl,
|
|
379
398
|
writeCaptureStampImpl,
|
|
380
399
|
logger,
|
|
@@ -393,9 +412,7 @@ export function stampCapturedTree({
|
|
|
393
412
|
cwd,
|
|
394
413
|
coveragePath,
|
|
395
414
|
digest: preDigest,
|
|
396
|
-
...(scope
|
|
397
|
-
...(files === undefined ? {} : { files }),
|
|
398
|
-
...(ref === undefined ? {} : { ref }),
|
|
415
|
+
...definedOnly({ scope, files, ref, commit: commit || undefined }),
|
|
399
416
|
});
|
|
400
417
|
if (written) {
|
|
401
418
|
logger.info(
|
|
@@ -434,73 +451,96 @@ export function anyChangedUnderTargets(changedFiles, targetDirs) {
|
|
|
434
451
|
return filterFilesUnderTargets(changedFiles, targetDirs).length > 0;
|
|
435
452
|
}
|
|
436
453
|
|
|
454
|
+
/**
|
|
455
|
+
* @param {{ cwd: string, coveragePath?: string }} opts No `coveragePath`,
|
|
456
|
+
* no stamp to drop.
|
|
457
|
+
*/
|
|
458
|
+
function dropCaptureStamp({ cwd, coveragePath }) {
|
|
459
|
+
if (!coveragePath) return;
|
|
460
|
+
fs.rmSync(captureStampPath(cwd, coveragePath), { force: true });
|
|
461
|
+
}
|
|
462
|
+
|
|
437
463
|
/** GNU `timeout(1)` code, so callers tell a hang (124) from failing tests. */
|
|
438
464
|
export const COVERAGE_TIMEOUT_EXIT_CODE = TIMEOUT_EXIT_CODE;
|
|
439
465
|
|
|
440
466
|
/**
|
|
441
|
-
* Spawn `npm run
|
|
467
|
+
* Spawn `npm run <script>` asynchronously as its own process group, so
|
|
442
468
|
* the lock heartbeat keeps running and a timeout or signal kills every
|
|
443
469
|
* worker. Takes no positional file args: node's runner would execute a
|
|
444
|
-
* forwarded source file as a test instead of filtering the suite.
|
|
470
|
+
* forwarded source file as a test instead of filtering the suite. Scope a
|
|
471
|
+
* run through `env` instead. Given `coveragePath`, the capture stamp is
|
|
472
|
+
* removed before the spawn: the suite is about to overwrite the artifact, so
|
|
473
|
+
* a red, killed or crashed run must leave no stamp vouching for it — only
|
|
474
|
+
* `stampCapturedTree` after a green run restores one.
|
|
445
475
|
*
|
|
446
476
|
* @param {{
|
|
447
477
|
* cwd: string,
|
|
478
|
+
* coveragePath?: string,
|
|
448
479
|
* timeoutMs?: number,
|
|
449
|
-
*
|
|
480
|
+
* script?: string,
|
|
481
|
+
* env?: Record<string, string>,
|
|
482
|
+
* spawnImpl?: Function,
|
|
450
483
|
* log?: (m: string) => void,
|
|
484
|
+
* lockWaitMs?: number,
|
|
485
|
+
* onTimings?: (timings: import('./supervised-suite.js').SuiteTimings) => void,
|
|
451
486
|
* }} opts
|
|
452
487
|
* @returns {Promise<number>}
|
|
453
488
|
*/
|
|
454
|
-
export function runCapture({
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
log = () => {},
|
|
459
|
-
} = {}) {
|
|
460
|
-
const args = ['run', 'test:coverage'];
|
|
489
|
+
export function runCapture(opts = {}) {
|
|
490
|
+
const { script = 'test:coverage', log = () => {} } = opts;
|
|
491
|
+
dropCaptureStamp(opts);
|
|
492
|
+
const args = ['run', script];
|
|
461
493
|
log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
|
|
462
|
-
return
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
...groupSpawnOptions(),
|
|
468
|
-
});
|
|
469
|
-
const supervisor = superviseGroup(child, { timeoutMs });
|
|
470
|
-
child.on('error', () => {
|
|
471
|
-
supervisor.release();
|
|
472
|
-
resolve(1);
|
|
473
|
-
});
|
|
474
|
-
child.on('exit', (code) => {
|
|
475
|
-
supervisor.release();
|
|
476
|
-
if (!supervisor.timedOut) {
|
|
477
|
-
resolve(code ?? 1);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
494
|
+
return runSupervisedSuite({
|
|
495
|
+
...opts,
|
|
496
|
+
cmd: 'npm',
|
|
497
|
+
args,
|
|
498
|
+
onTimeout: () =>
|
|
480
499
|
log(
|
|
481
|
-
`[coverage-capture] ⏱ npm run
|
|
482
|
-
)
|
|
483
|
-
resolve(COVERAGE_TIMEOUT_EXIT_CODE);
|
|
484
|
-
});
|
|
500
|
+
`[coverage-capture] ⏱ npm run ${script} exceeded ${opts.timeoutMs}ms — killed its process group. Returning exit ${COVERAGE_TIMEOUT_EXIT_CODE}.`,
|
|
501
|
+
),
|
|
485
502
|
});
|
|
486
503
|
}
|
|
487
504
|
|
|
505
|
+
/** @param {number} code */
|
|
506
|
+
function lockWaitExpiredMessage(code) {
|
|
507
|
+
return `[coverage-capture] ⏸ the full-suite lock wait expired with another suite still running, so this capture was deferred — no suite ran. Exiting ${code}; re-run it once that suite finishes.`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @param {number} code */
|
|
511
|
+
function timeoutMessage(code) {
|
|
512
|
+
return `[coverage-capture] ⏱ npm run test:coverage timed out and was killed (exit ${code}) — no test verdict exists. This is usually host contention, not a failing test; re-run once the host is quieter.`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** @param {number} code */
|
|
516
|
+
function failingSuiteMessage(code) {
|
|
517
|
+
return `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Exits that are not a failing suite, each with its own report: an expired
|
|
522
|
+
* lock wait ran nothing, and a timeout killed the suite before any verdict.
|
|
523
|
+
* Named functions: V8 coverage omitted an inline arrow here, so CRAP could
|
|
524
|
+
* not resolve it.
|
|
525
|
+
*/
|
|
526
|
+
const NON_FAILURE_CAPTURE_EXITS = Object.freeze({
|
|
527
|
+
[LOCK_WAIT_EXPIRED_EXIT_CODE]: ['info', lockWaitExpiredMessage],
|
|
528
|
+
[COVERAGE_TIMEOUT_EXIT_CODE]: ['error', timeoutMessage],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const FAILING_SUITE_REPORT = Object.freeze(['error', failingSuiteMessage]);
|
|
532
|
+
|
|
488
533
|
/**
|
|
489
|
-
* Report a non-zero capture; an expired lock wait is not a
|
|
534
|
+
* Report a non-zero capture; an expired lock wait or a timeout is not a
|
|
535
|
+
* failing suite, so neither prints the failing-tests line.
|
|
490
536
|
*
|
|
491
537
|
* @param {number} code
|
|
492
538
|
* @param {{ info: Function, error: Function }} logger
|
|
493
539
|
* @returns {number}
|
|
494
540
|
*/
|
|
495
541
|
export function reportCaptureFailure(code, logger) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
);
|
|
500
|
-
return code;
|
|
501
|
-
}
|
|
502
|
-
logger.error(
|
|
503
|
-
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
504
|
-
);
|
|
542
|
+
const [level, message] =
|
|
543
|
+
NON_FAILURE_CAPTURE_EXITS[code] ?? FAILING_SUITE_REPORT;
|
|
544
|
+
logger[level](message(code));
|
|
505
545
|
return code;
|
|
506
546
|
}
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
* worktrees (they contend for cores and a shared coverage artifact), over
|
|
4
4
|
* the `sweep-lock.js` primitive.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* heartbeat
|
|
10
|
-
*
|
|
11
|
-
* freshness checks before it.
|
|
6
|
+
* It queues, never overlaps: an expired wait behind a live holder spawns
|
|
7
|
+
* nothing ({@link LOCK_WAIT_EXPIRED_EXIT_CODE}). A dead holder is taken over
|
|
8
|
+
* and a lockfile I/O error proceeds unserialized. Waits are async (the
|
|
9
|
+
* holder's heartbeat keeps working) and FIFO via `full-suite-queue.js`; the
|
|
10
|
+
* lock covers only the spawn.
|
|
12
11
|
*/
|
|
13
12
|
import fs from 'node:fs';
|
|
14
13
|
import path from 'node:path';
|
|
15
14
|
|
|
15
|
+
import { getQuality } from './config/quality.js';
|
|
16
16
|
import { mainCheckoutRoot } from './config/temp-paths.js';
|
|
17
17
|
import { isFirstInLine, waitInLine } from './full-suite-queue.js';
|
|
18
18
|
import { acquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
@@ -20,20 +20,14 @@ import { acquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
|
20
20
|
/** Environment escape hatch: set to `0`/`false`/`off`/`no` to disable. */
|
|
21
21
|
export const FULL_SUITE_LOCK_ENV = 'MANDREL_FULL_SUITE_LOCK';
|
|
22
22
|
|
|
23
|
-
/** Close sets it to `defer` on its gate children; nothing else does. */
|
|
24
|
-
export const FULL_SUITE_LOCK_EXPIRY_ENV = 'MANDREL_FULL_SUITE_LOCK_ON_EXPIRY';
|
|
25
|
-
|
|
26
23
|
/** `EX_TEMPFAIL`. */
|
|
27
24
|
export const LOCK_WAIT_EXPIRED_EXIT_CODE = 75;
|
|
28
25
|
|
|
29
26
|
/** Under the main checkout's `.git`, so every worktree shares one file. */
|
|
30
27
|
const FULL_SUITE_LOCK_FILENAME = 'mandrel-full-suite.lock';
|
|
31
28
|
|
|
32
|
-
/**
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
/** Never above the wait budget. */
|
|
36
|
-
const DEFAULT_STALE_MS = 240_000;
|
|
29
|
+
/** The wait floor, and the budget when no kill bound is known. */
|
|
30
|
+
const MIN_WAIT_MS = 300_000;
|
|
37
31
|
|
|
38
32
|
const DEFAULT_POLL_MS = 2_000;
|
|
39
33
|
|
|
@@ -47,16 +41,32 @@ const LOCK_TAG = '[full-suite-lock]';
|
|
|
47
41
|
const LOCK_DEFAULTS = Object.freeze({
|
|
48
42
|
enabled: true,
|
|
49
43
|
log: () => {},
|
|
50
|
-
|
|
44
|
+
...resolveFullSuiteLockBudget(),
|
|
51
45
|
pollMs: DEFAULT_POLL_MS,
|
|
52
|
-
staleMs: DEFAULT_STALE_MS,
|
|
53
46
|
reportMs: DEFAULT_REPORT_MS,
|
|
54
47
|
fsImpl: fs,
|
|
55
48
|
nowFn: Date.now,
|
|
56
49
|
sleepFn: (ms) => defaultSleep(ms),
|
|
57
50
|
acquireOnceFn: (opts) => acquireSweepLock(opts),
|
|
51
|
+
onWaitExpired: () => LOCK_WAIT_EXPIRED_EXIT_CODE,
|
|
52
|
+
rerunCommand: 'the same command',
|
|
58
53
|
});
|
|
59
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The one wait budget every full-suite lock taker uses. A holder's suite is
|
|
57
|
+
* killed at its supervisor's `killBoundMs` (the coverage gate's `timeoutMs`),
|
|
58
|
+
* so waiting that long always observes a release or a death. The stale
|
|
59
|
+
* threshold is four fifths of the wait: never above the budget, and a live
|
|
60
|
+
* holder (heartbeating at a third of its own threshold) is never read stale.
|
|
61
|
+
*
|
|
62
|
+
* @param {number} [killBoundMs]
|
|
63
|
+
* @returns {{ waitMs: number, staleMs: number }}
|
|
64
|
+
*/
|
|
65
|
+
export function resolveFullSuiteLockBudget(killBoundMs) {
|
|
66
|
+
const waitMs = Math.max(MIN_WAIT_MS, Number(killBoundMs) || 0);
|
|
67
|
+
return { waitMs, staleMs: waitMs - waitMs / 5 };
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* Both hatches (env, then config) can only turn the lock off.
|
|
62
72
|
*
|
|
@@ -157,9 +167,10 @@ function consult(probe, applies) {
|
|
|
157
167
|
* acquireOnceFn?: typeof acquireSweepLock,
|
|
158
168
|
* lockPath?: string,
|
|
159
169
|
* skipIfSatisfied?: () => T|undefined,
|
|
160
|
-
* onWaitExpired?: () => T
|
|
161
|
-
*
|
|
162
|
-
*
|
|
170
|
+
* onWaitExpired?: (holder: import('./full-suite-queue.js').LockHolder) => T,
|
|
171
|
+
* rerunCommand?: string,
|
|
172
|
+
* }} opts A non-`undefined` `skipIfSatisfied` return stands in for the spawn.
|
|
173
|
+
* @param {(timing: { lockWaitMs: number }) => Promise<T>} spawn
|
|
163
174
|
* @returns {Promise<T>}
|
|
164
175
|
*/
|
|
165
176
|
export async function withFullSuiteLockAsync(options, spawn) {
|
|
@@ -167,8 +178,8 @@ export async function withFullSuiteLockAsync(options, spawn) {
|
|
|
167
178
|
const { lock, lockPath } = beginLock(opts);
|
|
168
179
|
const wait =
|
|
169
180
|
lock === null && lockPath !== null
|
|
170
|
-
? await waitInLine({ ...opts, lockPath
|
|
171
|
-
: { held: lock, expired: false, waited: false };
|
|
181
|
+
? await waitInLine({ ...opts, lockPath })
|
|
182
|
+
: { held: lock, expired: false, waited: false, waitedMs: 0 };
|
|
172
183
|
try {
|
|
173
184
|
return await spawnOrStandIn(opts, wait, spawn);
|
|
174
185
|
} finally {
|
|
@@ -184,12 +195,6 @@ function withDefaults(options) {
|
|
|
184
195
|
return opts;
|
|
185
196
|
}
|
|
186
197
|
|
|
187
|
-
function expiryNote({ onWaitExpired }) {
|
|
188
|
-
return typeof onWaitExpired === 'function'
|
|
189
|
-
? 'not spawning; the caller reports the wait instead'
|
|
190
|
-
: 'spawning anyway';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
198
|
async function spawnOrStandIn(opts, wait, spawn) {
|
|
194
199
|
const probe = consult(opts.skipIfSatisfied, wait.waited);
|
|
195
200
|
if (probe.satisfied) {
|
|
@@ -198,8 +203,8 @@ async function spawnOrStandIn(opts, wait, spawn) {
|
|
|
198
203
|
);
|
|
199
204
|
return probe.value;
|
|
200
205
|
}
|
|
201
|
-
|
|
202
|
-
return
|
|
206
|
+
if (wait.expired) return opts.onWaitExpired(wait.holder);
|
|
207
|
+
return await spawn({ lockWaitMs: wait.waitedMs ?? 0 });
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
/**
|
|
@@ -212,6 +217,18 @@ function defaultSleep(ms) {
|
|
|
212
217
|
});
|
|
213
218
|
}
|
|
214
219
|
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} [config]
|
|
222
|
+
* @param {Record<string, string|undefined>} [env]
|
|
223
|
+
*/
|
|
224
|
+
export function fullSuiteLockPolicy(config, env = process.env) {
|
|
225
|
+
return {
|
|
226
|
+
...resolveFullSuiteLockBudget(getQuality(config).coverage?.timeoutMs),
|
|
227
|
+
enabled: isFullSuiteLockEnabled({ config, env }),
|
|
228
|
+
onWaitExpired: () => LOCK_WAIT_EXPIRED_EXIT_CODE,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
215
232
|
/**
|
|
216
233
|
* Serialize a capture runner's spawn. Wrapped at the one call site below
|
|
217
234
|
* every skip/freshness decision, so a credited capture never waits.
|
|
@@ -219,7 +236,7 @@ function defaultSleep(ms) {
|
|
|
219
236
|
* @param {Function} runCaptureFn
|
|
220
237
|
* @param {object} [config]
|
|
221
238
|
* @param {Record<string, string|undefined>} [env]
|
|
222
|
-
* @param {object} [lockOptions]
|
|
239
|
+
* @param {object} [lockOptions]
|
|
223
240
|
* @returns {(opts?: object) => Promise<number>}
|
|
224
241
|
*/
|
|
225
242
|
export function lockedCapture(
|
|
@@ -228,11 +245,7 @@ export function lockedCapture(
|
|
|
228
245
|
env = process.env,
|
|
229
246
|
lockOptions = {},
|
|
230
247
|
) {
|
|
231
|
-
const policy = {
|
|
232
|
-
enabled: isFullSuiteLockEnabled({ config, env }),
|
|
233
|
-
onWaitExpired: deferredCaptureExit(env),
|
|
234
|
-
...lockOptions,
|
|
235
|
-
};
|
|
248
|
+
const policy = { ...fullSuiteLockPolicy(config, env), ...lockOptions };
|
|
236
249
|
return (captureOpts = {}) =>
|
|
237
250
|
withFullSuiteLockAsync(
|
|
238
251
|
{
|
|
@@ -241,20 +254,10 @@ export function lockedCapture(
|
|
|
241
254
|
log: captureOpts.log,
|
|
242
255
|
skipIfSatisfied: freshnessProbe(captureOpts),
|
|
243
256
|
},
|
|
244
|
-
() => runCaptureFn(captureOpts),
|
|
257
|
+
({ lockWaitMs }) => runCaptureFn({ ...captureOpts, lockWaitMs }),
|
|
245
258
|
);
|
|
246
259
|
}
|
|
247
260
|
|
|
248
|
-
/**
|
|
249
|
-
* @param {Record<string, string|undefined>} env
|
|
250
|
-
* @returns {(() => number)|undefined}
|
|
251
|
-
*/
|
|
252
|
-
function deferredCaptureExit(env) {
|
|
253
|
-
return env[FULL_SUITE_LOCK_EXPIRY_ENV] === 'defer'
|
|
254
|
-
? () => LOCK_WAIT_EXPIRED_EXIT_CODE
|
|
255
|
-
: undefined;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
261
|
/**
|
|
259
262
|
* A fresh recheck means exit 0 without spawning.
|
|
260
263
|
*
|