mandrel 2.41.0 → 2.42.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 +24 -14
- package/.agents/docs/agentrc-reference.json +7 -2
- package/.agents/docs/configuration.md +5 -2
- package/.agents/schemas/agentrc.schema.json +17 -2
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/docs/CHANGELOG.md +18 -0
- package/package.json +1 -1
|
@@ -90,14 +90,25 @@ Do **not** re-read every file in `project.docsContextFiles`. Read the
|
|
|
90
90
|
at the line numbers it names. A null `docsDigestPath` means no docs
|
|
91
91
|
mandate — read a full doc only when the Story's context points at one.
|
|
92
92
|
|
|
93
|
-
## Close gates —
|
|
93
|
+
## Close gates — one credited run, no ad-hoc stamping
|
|
94
94
|
|
|
95
95
|
`single-story-close.js` runs the canonical close-validation chain
|
|
96
|
-
(**typecheck, lint, test, format, maintainability, coverage, crap**)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
(**typecheck, lint, test, format, maintainability, coverage, crap**) and is
|
|
97
|
+
the authoritative gate — do not pre-run the chain. The **one** exception is
|
|
98
|
+
the full suite: run it exactly once, after the self-eval loop's last fix
|
|
99
|
+
commit and immediately before the push, in the shape close credits. A bare
|
|
100
|
+
`npm test` / `pnpm run test` deposits **no** credit:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# CRAP gate on (default) + a `test:coverage` script:
|
|
104
|
+
node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
|
|
105
|
+
# otherwise — <workCwd> ABSOLUTE, runner exactly `npm test`:
|
|
106
|
+
node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
107
|
+
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Sharing `lint` / `typecheck` evidence with close via `evidence-gate.js` is
|
|
111
|
+
fine; never stamp coverage / CRAP fresh any other way.
|
|
101
112
|
|
|
102
113
|
Before trusting a gate's output — or diagnosing a red one — read
|
|
103
114
|
[`known-tooling-behavior.md`](../rules/known-tooling-behavior.md): measured
|
|
@@ -138,14 +149,13 @@ You do **not** run close. Push `story-<storyId>` to `origin` — confirming
|
|
|
138
149
|
the remote ref moved — and return. The dispatching orchestrator runs
|
|
139
150
|
`single-story-close.js` in its own session, serialized against your
|
|
140
151
|
siblings. Do not open the PR, do not flip `agent::done`, and do not spawn
|
|
141
|
-
a child to close on your behalf.
|
|
152
|
+
a child to close on your behalf. If the push itself fails, take the blocked
|
|
153
|
+
path above rather than returning a hand-off you cannot back.
|
|
142
154
|
|
|
143
155
|
## Return contract — the hand-off report
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
makes an unlanded Story look landed. If the push itself fails, take the
|
|
151
|
-
blocked path above rather than returning a hand-off you cannot back.
|
|
157
|
+
A short, literal hand-off your caller can act on: Story id, `workCwd`,
|
|
158
|
+
branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
|
|
159
|
+
that the branch is pushed and unclosed. Never hand-compose a terminal
|
|
160
|
+
envelope — that document belongs to close, and inventing one makes an
|
|
161
|
+
unlanded Story look landed.
|
|
@@ -89,7 +89,8 @@
|
|
|
89
89
|
},
|
|
90
90
|
"delivery": {
|
|
91
91
|
"execution": {
|
|
92
|
-
"timeoutMs": 600000
|
|
92
|
+
"timeoutMs": 600000,
|
|
93
|
+
"fullSuiteLock": true
|
|
93
94
|
},
|
|
94
95
|
"docsFreshness": {
|
|
95
96
|
"paths": ["README.md"]
|
|
@@ -188,7 +189,11 @@
|
|
|
188
189
|
},
|
|
189
190
|
"refreshTag": "baseline-refresh:",
|
|
190
191
|
"refreshTimeoutMs": 60000,
|
|
191
|
-
"ignoreGlobs": []
|
|
192
|
+
"ignoreGlobs": [],
|
|
193
|
+
"incrementalCoverage": {
|
|
194
|
+
"skipWhenUnchanged": true,
|
|
195
|
+
"baselineJoin": false
|
|
196
|
+
}
|
|
192
197
|
},
|
|
193
198
|
"maintainability": {
|
|
194
199
|
"enabled": true,
|
|
@@ -144,6 +144,7 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
|
|
|
144
144
|
| --- | --- | --- | --- | --- |
|
|
145
145
|
| `execution` | No | `object` | — | Wall-clock bounds on the subprocesses delivery spawns. |
|
|
146
146
|
| `execution.timeoutMs` | No | `integer` | `600000` | Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs. |
|
|
147
|
+
| `execution.fullSuiteLock` | No | `boolean` | `true` | 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. |
|
|
147
148
|
| `docsFreshness` | No | `object` | — | Documentation-freshness scope: the files a change of consequence is expected to touch. Read by the audit-documentation lens to seed its target set; no delivery gate enforces it. |
|
|
148
149
|
| `docsFreshness.paths` | No | `array<string>` | `["README.md"]` | Repo-relative documentation paths the audit-documentation lens adds to its target set. |
|
|
149
150
|
| `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path. |
|
|
@@ -210,8 +211,10 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
|
|
|
210
211
|
| `quality.gates.crap.refreshTag` | No | `string` | `"baseline-refresh:"` | Commit-subject substring that acknowledges a deliberate CRAP baseline refresh in the compared range. A range commit carrying it that also touches the baseline file demotes head-vs-base regressions; floors stay enforced. |
|
|
211
212
|
| `quality.gates.crap.refreshTimeoutMs` | No | `integer` | `60000` | Bounded timeout (ms) for `npm run crap:update` spawned by the baseline-attribution refresh path. Mirrors `coverage.timeoutMs`: a SIGKILL fired at the budget boundary maps to exit 124 so the close orchestrator can flip the Story to `agent::blocked`. Default 60000 (Story #2165). |
|
|
212
213
|
| `quality.gates.crap.ignoreGlobs` | No | `array<string>` | `[]` | Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217). |
|
|
213
|
-
| `quality.gates.crap.incrementalCoverage` | No | `object` | — |
|
|
214
|
-
| `quality.gates.crap.incrementalCoverage.
|
|
214
|
+
| `quality.gates.crap.incrementalCoverage` | No | `object` | — | The two independent full-suite economies (Story #4981, split by Story #5173). `skipWhenUnchanged` (default true) decides WHETHER to capture — no changed file under `crap.targetDirs` versus `baseRef` means no capture at all — and is a pure saving. `baselineJoin` (default false) loosens gate semantics: the CRAP join resolves a method in an untouched file from its committed baseline row instead of requiring fresh coverage for it. Neither narrows the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065). |
|
|
215
|
+
| `quality.gates.crap.incrementalCoverage.skipWhenUnchanged` | No | `boolean` | `true` | Skip the capture entirely when no changed file under `crap.targetDirs` versus `baseRef` was touched. The only measured saving, and gate-semantics-neutral. Defaults to true. |
|
|
216
|
+
| `quality.gates.crap.incrementalCoverage.baselineJoin` | No | `boolean` | `false` | Let the CRAP join resolve a method in a file the diff did not touch from its committed baseline row instead of requiring fresh coverage for it. A gate loosening, not a saving — defaults to false. |
|
|
217
|
+
| `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | DEPRECATED alias for setting both `skipWhenUnchanged` and `baselineJoin`. Prefer the two switches: they are not equally safe, and bundling them is why the earlier default flip was reverted. Either explicit switch overrides this alias. |
|
|
215
218
|
| `quality.gates.crap.incrementalCoverage.baseRef` | No | `string` | — | Git ref the changed-file set is computed against. Omitted falls back to the gate’s own `--ref` (`main`). |
|
|
216
219
|
| `quality.gates.maintainability` | No | `object` | — | Maintainability-index ratchet. Scores per file as the average over its methods, so deleting a small high-MI method can legitimately lower a file’s score. |
|
|
217
220
|
| `quality.gates.maintainability.enabled` | No | `boolean` | `true` | When false, the checker exits 0 with a skip line and the gate is reported as `skipped`, never omitted. |
|
|
@@ -451,6 +451,11 @@
|
|
|
451
451
|
"minimum": 1,
|
|
452
452
|
"description": "Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs.",
|
|
453
453
|
"default": 600000
|
|
454
|
+
},
|
|
455
|
+
"fullSuiteLock": {
|
|
456
|
+
"type": "boolean",
|
|
457
|
+
"description": "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.",
|
|
458
|
+
"default": true
|
|
454
459
|
}
|
|
455
460
|
},
|
|
456
461
|
"additionalProperties": false
|
|
@@ -978,11 +983,21 @@
|
|
|
978
983
|
},
|
|
979
984
|
"incrementalCoverage": {
|
|
980
985
|
"type": "object",
|
|
981
|
-
"description": "
|
|
986
|
+
"description": "The two independent full-suite economies (Story #4981, split by Story #5173). `skipWhenUnchanged` (default true) decides WHETHER to capture — no changed file under `crap.targetDirs` versus `baseRef` means no capture at all — and is a pure saving. `baselineJoin` (default false) loosens gate semantics: the CRAP join resolves a method in an untouched file from its committed baseline row instead of requiring fresh coverage for it. Neither narrows the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).",
|
|
982
987
|
"properties": {
|
|
988
|
+
"skipWhenUnchanged": {
|
|
989
|
+
"type": "boolean",
|
|
990
|
+
"description": "Skip the capture entirely when no changed file under `crap.targetDirs` versus `baseRef` was touched. The only measured saving, and gate-semantics-neutral. Defaults to true.",
|
|
991
|
+
"default": true
|
|
992
|
+
},
|
|
993
|
+
"baselineJoin": {
|
|
994
|
+
"type": "boolean",
|
|
995
|
+
"description": "Let the CRAP join resolve a method in a file the diff did not touch from its committed baseline row instead of requiring fresh coverage for it. A gate loosening, not a saving — defaults to false.",
|
|
996
|
+
"default": false
|
|
997
|
+
},
|
|
983
998
|
"enabled": {
|
|
984
999
|
"type": "boolean",
|
|
985
|
-
"description": "
|
|
1000
|
+
"description": "DEPRECATED alias for setting both `skipWhenUnchanged` and `baselineJoin`. Prefer the two switches: they are not equally safe, and bundling them is why the earlier default flip was reverted. Either explicit switch overrides this alias."
|
|
986
1001
|
},
|
|
987
1002
|
"baseRef": {
|
|
988
1003
|
"type": "string",
|
|
@@ -38,10 +38,12 @@
|
|
|
38
38
|
"format",
|
|
39
39
|
"coverage-capture",
|
|
40
40
|
"check-baselines",
|
|
41
|
+
"check-baselines-independent",
|
|
42
|
+
"check-baselines-coverage",
|
|
41
43
|
"check-maintainability",
|
|
42
44
|
"check-crap"
|
|
43
45
|
],
|
|
44
|
-
"description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
|
|
46
|
+
"description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-baselines-independent` / `check-baselines-coverage` are the split pair the gate registers as when its enabled-kind set resolves (Story #5172), with `check-baselines` kept as both the unsplit fail-closed fallback and the historical name; `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
|
|
45
47
|
},
|
|
46
48
|
"commitSha": {
|
|
47
49
|
"type": "string",
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
* --no-signal Suppress the signal emit (tests).
|
|
58
58
|
*
|
|
59
59
|
* Stdout: a single JSON envelope
|
|
60
|
-
* { storyId, epicId, decision, round, cap,
|
|
61
|
-
* metCount, unmetCriteria[], signalEmitted,
|
|
60
|
+
* { storyId, epicId, fullSuiteVerifyCommands[], decision, round, cap,
|
|
61
|
+
* capReached, totalCriteria, metCount, unmetCriteria[], signalEmitted,
|
|
62
|
+
* replay, verdictFingerprint }
|
|
62
63
|
* (`epicId` is retained as a always-null field for envelope stability.)
|
|
63
64
|
*
|
|
64
65
|
* Reading is free (Story #4874): re-invoking the gate over a verdict the
|
|
@@ -88,6 +89,10 @@ import {
|
|
|
88
89
|
decideAcceptanceEval,
|
|
89
90
|
resolveAcceptanceEvalRound,
|
|
90
91
|
} from './lib/orchestration/acceptance-eval-decision.js';
|
|
92
|
+
import {
|
|
93
|
+
FULL_SUITE_SHAPE_WARNING,
|
|
94
|
+
isFullSuiteCommand,
|
|
95
|
+
} from './lib/orchestration/verify-credit.js';
|
|
91
96
|
|
|
92
97
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
93
98
|
|
|
@@ -225,6 +230,58 @@ export function assertCriteriaCoverage(verdict, expectedCriteria) {
|
|
|
225
230
|
);
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Collect the distinct `verify[]` commands a verdict recorded that are
|
|
235
|
+
* themselves full-suite runs (Story #5174).
|
|
236
|
+
*
|
|
237
|
+
* The intended `verify[]` shape is scoped entries **plus** the one credited
|
|
238
|
+
* full-suite run the worker makes before the hand-off push. A full-suite
|
|
239
|
+
* command sitting in `verify[]` is the misshapen case: it either re-pays for
|
|
240
|
+
* the credited run or, worse, gets skipped as "already covered" without
|
|
241
|
+
* anyone saying so. The gate is where every round's evidence passes through,
|
|
242
|
+
* so it is where the shape is called out.
|
|
243
|
+
*
|
|
244
|
+
* Exported for tests.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} verdict — schema-validated verdict.
|
|
247
|
+
* @returns {string[]} distinct offending commands, in first-seen order.
|
|
248
|
+
*/
|
|
249
|
+
export function collectFullSuiteVerifyCommands(verdict) {
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (const criterion of verdict?.criteria ?? []) {
|
|
252
|
+
for (const evidence of criterion?.verifyEvidence ?? []) {
|
|
253
|
+
const command = evidence?.command;
|
|
254
|
+
if (typeof command === 'string' && isFullSuiteCommand(command)) {
|
|
255
|
+
seen.add(command.trim());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return [...seen];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Surface the misshapen-`verify[]` warning, if there is one.
|
|
264
|
+
*
|
|
265
|
+
* Extracted from `runAcceptanceEvalCli` rather than inlined: the CLI shell is
|
|
266
|
+
* already the file's worst-CRAP method, and a branch added there costs more
|
|
267
|
+
* than the same branch in a small, fully-covered helper. Module-private: it is
|
|
268
|
+
* reached through the CLI shell, which is where the tests drive it.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} verdict — schema-validated verdict.
|
|
271
|
+
* @param {{ warn?: Function }} logger
|
|
272
|
+
* @returns {string[]} the offending commands (empty when the shape is fine).
|
|
273
|
+
*/
|
|
274
|
+
function warnOnFullSuiteVerify(verdict, logger) {
|
|
275
|
+
const commands = collectFullSuiteVerifyCommands(verdict);
|
|
276
|
+
if (commands.length > 0) {
|
|
277
|
+
logger?.warn?.(
|
|
278
|
+
`acceptance-eval: verify[] carries full-suite command(s) ${commands.join(', ')}. ` +
|
|
279
|
+
FULL_SUITE_SHAPE_WARNING,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return commands;
|
|
283
|
+
}
|
|
284
|
+
|
|
228
285
|
/**
|
|
229
286
|
* Compose the operator-facing envelope and emit the per-criterion signal.
|
|
230
287
|
*
|
|
@@ -304,9 +361,12 @@ export async function runAcceptanceEval(
|
|
|
304
361
|
}
|
|
305
362
|
}
|
|
306
363
|
|
|
364
|
+
const fullSuiteVerifyCommands = collectFullSuiteVerifyCommands(verdict);
|
|
365
|
+
|
|
307
366
|
const envelope = {
|
|
308
367
|
storyId: storyId ?? null,
|
|
309
368
|
epicId: null,
|
|
369
|
+
fullSuiteVerifyCommands,
|
|
310
370
|
decision: outcome.decision,
|
|
311
371
|
round: outcome.round,
|
|
312
372
|
cap: outcome.cap,
|
|
@@ -349,7 +409,7 @@ export async function runAcceptanceEval(
|
|
|
349
409
|
* resolveConfigImpl?: typeof resolveConfig,
|
|
350
410
|
* validateVerdictImpl?: typeof validateVerdict,
|
|
351
411
|
* runAcceptanceEvalImpl?: typeof runAcceptanceEval,
|
|
352
|
-
* logger?: { info: Function },
|
|
412
|
+
* logger?: { info: Function, warn?: Function },
|
|
353
413
|
* }} [deps]
|
|
354
414
|
* @returns {Promise<object>} the emitted envelope.
|
|
355
415
|
*/
|
|
@@ -414,6 +474,11 @@ export async function runAcceptanceEvalCli(
|
|
|
414
474
|
);
|
|
415
475
|
}
|
|
416
476
|
|
|
477
|
+
// Story #5174 — the shape warning is derived from the VALIDATED verdict, not
|
|
478
|
+
// from the envelope, so it fires for the real decision path and for every
|
|
479
|
+
// caller that injects its own scorer.
|
|
480
|
+
warnOnFullSuiteVerify(verdict, logger);
|
|
481
|
+
|
|
417
482
|
const config = resolveConfigImpl();
|
|
418
483
|
const { envelope, exitCode } = await runAcceptanceEvalImpl({
|
|
419
484
|
storyId,
|
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
* 3. Test freshness: content digest of `crap.targetDirs` vs. the persisted
|
|
13
13
|
* capture stamp (`coverage/.capture-stamp.json`), falling back to the
|
|
14
14
|
* artifact-mtime heuristic when no stamp exists. Exit 0 when fresh.
|
|
15
|
-
* 4. Otherwise spawn `npm run test:coverage
|
|
16
|
-
*
|
|
15
|
+
* 4. Otherwise spawn `npm run test:coverage` — serialized behind the
|
|
16
|
+
* host-level full-suite lock (Story #5173) so two concurrent runs on one
|
|
17
|
+
* checkout do not race — write a fresh capture stamp on success, and
|
|
18
|
+
* propagate the exit code.
|
|
19
|
+
*
|
|
20
|
+
* Step 3 is preceded by the changed-file skip when
|
|
21
|
+
* `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is on
|
|
22
|
+
* (the default): no changed file under `crap.targetDirs` versus `baseRef`
|
|
23
|
+
* means no capture at all.
|
|
17
24
|
*
|
|
18
25
|
* Exit codes:
|
|
19
26
|
* 0 — coverage is fresh (or capture skipped/succeeded).
|
|
@@ -34,6 +41,7 @@ import {
|
|
|
34
41
|
import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
|
|
35
42
|
import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
|
|
36
43
|
import { handleCoverageCaptureHelp } from './lib/coverage-capture-usage.js';
|
|
44
|
+
import { lockedCapture } from './lib/full-suite-lock.js';
|
|
37
45
|
|
|
38
46
|
import { Logger } from './lib/Logger.js';
|
|
39
47
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
@@ -124,10 +132,19 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
124
132
|
return 1;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
// Story #
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
135
|
+
// Story #5173 — the host-level full-suite lock. Resolved once here, where
|
|
136
|
+
// the config is already in scope, and composed over the capture runner so
|
|
137
|
+
// whichever path reaches the spawn is serialized without either of them
|
|
138
|
+
// knowing about it. `delivery.execution.fullSuiteLock: false` and
|
|
139
|
+
// `MANDREL_FULL_SUITE_LOCK=0` each disable it; both hatches live in
|
|
140
|
+
// `isFullSuiteLockEnabled`.
|
|
141
|
+
const capture = lockedCapture(runCaptureImpl, config);
|
|
142
|
+
|
|
143
|
+
// Story #4981/#5173 — the capture skip, gated by
|
|
144
|
+
// `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` (on
|
|
145
|
+
// by default). `null` means "not applicable" (switched off, or a
|
|
146
|
+
// ref-resolution error) — fall through to the full-scope path below rather
|
|
147
|
+
// than silently skipping capture.
|
|
131
148
|
const incrementalResult = tryIncrementalCapture({
|
|
132
149
|
crap,
|
|
133
150
|
coverage,
|
|
@@ -135,7 +152,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
135
152
|
getChangedFilesImpl,
|
|
136
153
|
filterFilesUnderTargetsImpl,
|
|
137
154
|
isCoverageFreshImpl,
|
|
138
|
-
runCaptureImpl,
|
|
155
|
+
runCaptureImpl: capture,
|
|
139
156
|
computeContentDigestImpl,
|
|
140
157
|
writeCaptureStampImpl,
|
|
141
158
|
logger,
|
|
@@ -148,7 +165,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
148
165
|
args,
|
|
149
166
|
getChangedFilesImpl,
|
|
150
167
|
isCoverageFreshImpl,
|
|
151
|
-
runCaptureImpl,
|
|
168
|
+
runCaptureImpl: capture,
|
|
152
169
|
computeContentDigestImpl,
|
|
153
170
|
writeCaptureStampImpl,
|
|
154
171
|
logger,
|
|
@@ -14,8 +14,13 @@ import { getChangedFiles } from '../changed-files.js';
|
|
|
14
14
|
* not be resolved — a resolution failure falls back to full-scope rather
|
|
15
15
|
* than silently relaxing the gate.
|
|
16
16
|
*
|
|
17
|
+
* Gated by `incrementalCoverage.baselineJoin` alone (Story #5173). It MUST
|
|
18
|
+
* NOT consult `skipWhenUnchanged`: the join loosens what the gate demands,
|
|
19
|
+
* while the skip only decides whether a capture runs, so a consumer that took
|
|
20
|
+
* the saving has not thereby asked for the loosening.
|
|
21
|
+
*
|
|
17
22
|
* @param {{
|
|
18
|
-
* crap: { incrementalCoverage?: {
|
|
23
|
+
* crap: { incrementalCoverage?: { baselineJoin?: boolean, baseRef?: string } },
|
|
19
24
|
* diffRef: string | null,
|
|
20
25
|
* cwd: string,
|
|
21
26
|
* baselineRows: Array<object>,
|
|
@@ -30,7 +35,7 @@ export function resolveCrapPreviewIncremental({
|
|
|
30
35
|
baselineRows,
|
|
31
36
|
getChangedFilesImpl = getChangedFiles,
|
|
32
37
|
}) {
|
|
33
|
-
if (crap.incrementalCoverage?.
|
|
38
|
+
if (crap.incrementalCoverage?.baselineJoin !== true) return null;
|
|
34
39
|
const baseRef = crap.incrementalCoverage.baseRef || diffRef || 'main';
|
|
35
40
|
try {
|
|
36
41
|
const touchedFiles = new Set(getChangedFilesImpl({ ref: baseRef, cwd }));
|
|
@@ -145,6 +145,32 @@ function cleanGitEnv() {
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Both reads reject the same way on a missing or non-string argument. */
|
|
149
|
+
function isNonEmptyString(value) {
|
|
150
|
+
return typeof value === 'string' && value.length > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Both reads coerce a missing stdout to the empty string rather than throwing. */
|
|
154
|
+
function stdoutOf(result) {
|
|
155
|
+
return typeof result?.stdout === 'string' ? result.stdout : '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run a git subprocess through the shared child surface. Both reads below need
|
|
160
|
+
* the identical shape — injected runner, resolved cwd, `GIT_*`-scrubbed env,
|
|
161
|
+
* argv tokens rather than a shell string — so it lives here once.
|
|
162
|
+
*
|
|
163
|
+
* @param {string[]} args
|
|
164
|
+
* @param {{ cwd?: string }} opts
|
|
165
|
+
*/
|
|
166
|
+
function runGit(args, opts) {
|
|
167
|
+
return spawnChild('git', args, {
|
|
168
|
+
run: _spawnRunner,
|
|
169
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
170
|
+
env: cleanGitEnv(),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
/**
|
|
149
175
|
* Read `<file>` at the given git ref. Returns the file contents as a
|
|
150
176
|
* UTF-8 string when the path exists, or `null` when git reports the
|
|
@@ -161,10 +187,10 @@ function cleanGitEnv() {
|
|
|
161
187
|
* @returns {string | null}
|
|
162
188
|
*/
|
|
163
189
|
export function readBaseFromGit(ref, file, opts = {}) {
|
|
164
|
-
if (
|
|
190
|
+
if (!isNonEmptyString(ref)) {
|
|
165
191
|
throw new TypeError('readBaseFromGit: ref must be a non-empty string');
|
|
166
192
|
}
|
|
167
|
-
if (
|
|
193
|
+
if (!isNonEmptyString(file)) {
|
|
168
194
|
throw new TypeError('readBaseFromGit: file must be a non-empty string');
|
|
169
195
|
}
|
|
170
196
|
|
|
@@ -176,19 +202,14 @@ export function readBaseFromGit(ref, file, opts = {}) {
|
|
|
176
202
|
return cached;
|
|
177
203
|
}
|
|
178
204
|
|
|
179
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
180
205
|
const spec = `${ref}:${file}`;
|
|
181
|
-
const result =
|
|
182
|
-
run: _spawnRunner,
|
|
183
|
-
cwd,
|
|
184
|
-
env: cleanGitEnv(),
|
|
185
|
-
});
|
|
206
|
+
const result = runGit(['show', spec], opts);
|
|
186
207
|
|
|
187
208
|
// `child_process.spawnSync` returns `status: null` when the child died
|
|
188
209
|
// by signal. Treat that as a hard failure rather than "no file".
|
|
189
210
|
const status = result.status;
|
|
190
211
|
if (status === 0) {
|
|
191
|
-
const out =
|
|
212
|
+
const out = stdoutOf(result);
|
|
192
213
|
touch(key, out);
|
|
193
214
|
return out;
|
|
194
215
|
}
|
|
@@ -212,53 +233,68 @@ export function readBaseFromGit(ref, file, opts = {}) {
|
|
|
212
233
|
}
|
|
213
234
|
|
|
214
235
|
/**
|
|
215
|
-
* Read the
|
|
216
|
-
*
|
|
236
|
+
* Read the commits, within the range `<baseRef>..HEAD`, that touched `<file>`
|
|
237
|
+
* (Story #4731; widened to carry SHAs by Story #5179). Powers the baseline
|
|
217
238
|
* refresh-acknowledgment trigger: a `baseline-refresh:`-tagged commit in the
|
|
218
|
-
* compared range that touches
|
|
239
|
+
* compared range that touches a kind's baseline acknowledges that run's
|
|
219
240
|
* head-vs-base regressions.
|
|
220
241
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
242
|
+
* Story #5179 — this returned SUBJECTS ONLY, and that omission was load-bearing
|
|
243
|
+
* in the wrong direction. With no commit handle, the acknowledgment could not
|
|
244
|
+
* ask what the tagged commit actually rewrote, so it could only be a whole-run
|
|
245
|
+
* blanket: every regression in the range demoted, including rows that commit
|
|
246
|
+
* never touched and drift that landed after it. Carrying `sha` alongside
|
|
247
|
+
* `subject` is what lets the caller read the baseline blob AT the refresh
|
|
248
|
+
* commit and scope the acknowledgment to it.
|
|
249
|
+
*
|
|
250
|
+
* Restricting the log to `-- <file>` means the returned commits already satisfy
|
|
251
|
+
* the "commit whose diff touches the baseline file" half of the predicate; the
|
|
252
|
+
* caller only has to match the tag substring against each subject. Runs through
|
|
253
|
+
* the same shared `spawnChild` surface and injected runner as
|
|
254
|
+
* `readBaseFromGit`, so it inherits the identical env scrubbing (drop inherited
|
|
255
|
+
* `GIT_*`), the same stdout ceiling, and the spawn-not-exec security posture
|
|
256
|
+
* (argv tokens, `shell: false`).
|
|
257
|
+
*
|
|
258
|
+
* A NUL separator between the two fields is what makes this parse safely: a
|
|
259
|
+
* commit subject may contain anything except a newline, so any printable
|
|
260
|
+
* delimiter could appear inside one, but neither field may contain NUL.
|
|
228
261
|
*
|
|
229
|
-
* Returns an empty array whenever the range cannot be walked (missing base
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
262
|
+
* Returns an empty array whenever the range cannot be walked (missing base ref,
|
|
263
|
+
* git failure, empty range) — the acknowledgment path treats "no matching
|
|
264
|
+
* commit" and "could not determine" identically: the ratchet stays at full
|
|
265
|
+
* strength. Not cached: the range result depends on live HEAD, which the
|
|
266
|
+
* `(ref, file)` LRU key does not capture.
|
|
234
267
|
*
|
|
235
268
|
* @param {string} baseRef - Range base (e.g. `main`, `origin/main`, a SHA).
|
|
236
269
|
* @param {string} file - Repo-relative path to the baseline file.
|
|
237
270
|
* @param {{ cwd?: string }} [opts]
|
|
238
|
-
* @returns {string[]}
|
|
271
|
+
* @returns {{ sha: string, subject: string }[]} newest first; `[]` on any failure.
|
|
239
272
|
*/
|
|
240
|
-
export function
|
|
241
|
-
if (
|
|
242
|
-
if (typeof file !== 'string' || file.length === 0) return [];
|
|
273
|
+
export function readRangeCommitsTouchingFile(baseRef, file, opts = {}) {
|
|
274
|
+
if (!isNonEmptyString(baseRef) || !isNonEmptyString(file)) return [];
|
|
243
275
|
|
|
244
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
245
276
|
let result;
|
|
246
277
|
try {
|
|
247
|
-
result =
|
|
248
|
-
'
|
|
249
|
-
|
|
250
|
-
{ run: _spawnRunner, cwd, env: cleanGitEnv() },
|
|
278
|
+
result = runGit(
|
|
279
|
+
['log', `${baseRef}..HEAD`, '--format=%H%x00%s', '--', file],
|
|
280
|
+
opts,
|
|
251
281
|
);
|
|
252
282
|
} catch {
|
|
253
283
|
return [];
|
|
254
284
|
}
|
|
255
285
|
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
return out
|
|
286
|
+
if (result?.status !== 0) return [];
|
|
287
|
+
return stdoutOf(result)
|
|
259
288
|
.split('\n')
|
|
260
|
-
.map((line) =>
|
|
261
|
-
|
|
289
|
+
.map((line) => {
|
|
290
|
+
const sep = line.indexOf('\u0000');
|
|
291
|
+
if (sep === -1) return null;
|
|
292
|
+
return {
|
|
293
|
+
sha: line.slice(0, sep).trim(),
|
|
294
|
+
subject: line.slice(sep + 1).trim(),
|
|
295
|
+
};
|
|
296
|
+
})
|
|
297
|
+
.filter((commit) => commit !== null && commit.sha.length > 0);
|
|
262
298
|
}
|
|
263
299
|
|
|
264
300
|
/**
|