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
|
@@ -87,14 +87,16 @@ names. A null digest path means no docs mandate.
|
|
|
87
87
|
|
|
88
88
|
`single-story-close.js` runs the canonical close-validation chain
|
|
89
89
|
(**typecheck, lint, test, format, maintainability, coverage, crap**) and is
|
|
90
|
-
the authoritative gate — do not pre-run it.
|
|
91
|
-
full suite: after the self-eval loop's last fix commit, run it once in
|
|
90
|
+
the authoritative gate — do not pre-run it. Two exceptions, both run in
|
|
92
91
|
`<workCwd>` exactly as
|
|
93
|
-
[`deliver-digest.md`](../workflows/helpers/deliver-digest.md) § 5 states
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
[`deliver-digest.md`](../workflows/helpers/deliver-digest.md) § 5 states them:
|
|
93
|
+
the **blocking** lint + quality-preview preflight, then the one credited run
|
|
94
|
+
(the coverage capture or the test depositor, as § 5 picks), once after the
|
|
95
|
+
self-eval loop's last fix commit, then the `--seat-missing` baseline seat
|
|
96
|
+
before push. That section is their only home, so read the invocations there
|
|
97
|
+
rather than from a copy here.
|
|
98
|
+
|
|
99
|
+
If the run outruns the host's sync Bash ceiling, dispatch it in the
|
|
98
100
|
**background**: its completion re-invokes you. Never spawn a task to poll or
|
|
99
101
|
`sleep`-loop against it; a waiter with a wrong condition outlives the agent.
|
|
100
102
|
An exit code is never evidence a gate did work — its **output** is. Redraft
|
|
@@ -142,11 +144,13 @@ the remote ref moved — then return: a turn that ends unpushed reads as
|
|
|
142
144
|
unfinished work. The orchestrator runs
|
|
143
145
|
`single-story-close.js` in its own session, serialized against your
|
|
144
146
|
siblings. Do not open the PR, flip `agent::done`, or spawn a child to
|
|
145
|
-
close for you. If the push fails, take the blocked path above.
|
|
147
|
+
close for you. If the push fails, take the blocked path above. After
|
|
148
|
+
the push, compute the held review and fix a CRITICAL before returning
|
|
149
|
+
([`deliver-reference.md`](../workflows/helpers/deliver-reference.md) § Held review).
|
|
146
150
|
|
|
147
151
|
## Return contract — the hand-off report
|
|
148
152
|
|
|
149
153
|
A short, literal hand-off your caller can act on: Story id, `workCwd`,
|
|
150
|
-
branch, pushed head SHA, self-eval verdict, `verify[]` evidence
|
|
151
|
-
branch is pushed and unclosed. Never hand-compose a
|
|
152
|
-
inventing one makes an unlanded Story look landed.
|
|
154
|
+
branch, pushed head SHA, self-eval verdict, `verify[]` evidence, review
|
|
155
|
+
tally. Say the branch is pushed and unclosed. Never hand-compose a
|
|
156
|
+
terminal envelope — inventing one makes an unlanded Story look landed.
|
|
@@ -136,7 +136,7 @@ Everything `/mandrel-deliver` and `single-story-close` consume: worktree isolati
|
|
|
136
136
|
| Key | Required | Type | Default | Description |
|
|
137
137
|
| --- | --- | --- | --- | --- |
|
|
138
138
|
| `execution` | No | `object` | — | Serialization of the full-suite spawns delivery drives. |
|
|
139
|
-
| `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.
|
|
139
|
+
| `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. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable. |
|
|
140
140
|
| `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. |
|
|
141
141
|
| `docsFreshness.paths` | No | `array<string>` | `["README.md"]` | Repo-relative documentation paths the audit-documentation lens adds to its target set. |
|
|
142
142
|
| `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 unrecognized files under tempRoot are reported with their size and left alone (`/clean-temp` is the operator path for them). signals.ndjson is never purged by any path. |
|
|
@@ -169,6 +169,8 @@ Everything `/mandrel-deliver` and `single-story-close` consume: worktree isolati
|
|
|
169
169
|
| `quality.gates.coverage.floors` | No | `object<map>` | `{"*":{"lines":90,"branches":85,"functions":90}}` | Workspace-keyed absolute floors: `{ "<workspace>": { "<metric>": number } }`. `"*"` is the project-wide catch-all; the metric keyset is open so per-rollup keys flow through without each gate enumerating them. Floors are absolute — unlike `tolerance`, they are enforced regardless of the baseline. |
|
|
170
170
|
| `quality.gates.coverage.components` | No | `object<map>` | — | Per-gate component map — component name to the glob list whose files roll up under it. Defaults to `{ "*": ["**"] }` at the resolver layer. |
|
|
171
171
|
| `quality.gates.coverage.coveragePath` | No | `string` | `"coverage/coverage-final.json"` | Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads. |
|
|
172
|
+
| `quality.gates.coverage.captureScope` | No | `"full"` \| `"affected"` | `"full"` | What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite. |
|
|
173
|
+
| `quality.gates.coverage.timeoutMs` | No | `integer` | `600000` | Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure. |
|
|
172
174
|
| `quality.gates.crap` | No | `object` | — | CRAP (Change Risk Anti-Pattern) ratchet — per-method cyclomatic complexity joined against per-method coverage. |
|
|
173
175
|
| `quality.gates.crap.enabled` | No | `boolean` | `true` | When false, the checker exits 0 with a skip line and the gate is reported as `skipped`, never omitted. |
|
|
174
176
|
| `quality.gates.crap.baselinePath` | No | `string` | `"baselines/crap.json"` | Repo-root-relative path to the gate's committed baseline artifact. |
|
|
@@ -365,6 +367,39 @@ A config still carrying the retired key is a hard validation failure; the
|
|
|
365
367
|
Extend the list-valued gate keys with the deep-merge extender form (see
|
|
366
368
|
[How to extend](#how-to-extend)).
|
|
367
369
|
|
|
370
|
+
#### `delivery.quality.gates.coverage.captureScope` — delta refresh
|
|
371
|
+
|
|
372
|
+
Under `captureScope: "affected"`, a capture stamp records the commit it
|
|
373
|
+
measured. When close's base-sync merges `main` into the Story branch, the
|
|
374
|
+
next capture re-measures only the merged commits' tests instead of the
|
|
375
|
+
Story's whole affected scope: it runs `npm run test:coverage:affected` with
|
|
376
|
+
`MANDREL_COVERAGE_BASE_REF` set to the **stamped commit**, drops the prior
|
|
377
|
+
rows of the delta's files, merges the new rows over the prior artifact and
|
|
378
|
+
stamps the merged tree fresh. It holds the full-suite lock like any capture,
|
|
379
|
+
and a red delta refresh fails the capture — it never falls back to a wider
|
|
380
|
+
run.
|
|
381
|
+
|
|
382
|
+
A delta refresh runs only when **every** condition holds; otherwise the
|
|
383
|
+
capture behaves exactly as before (the Story's affected scope, or the full
|
|
384
|
+
suite when the script is absent):
|
|
385
|
+
|
|
386
|
+
- the stamp is stale by content digest, and records a `commit` (a stamp
|
|
387
|
+
written before this field existed never qualifies) that is an ancestor of
|
|
388
|
+
`HEAD`;
|
|
389
|
+
- `captureScope` is `affected` and the `test:coverage:affected` script
|
|
390
|
+
exists;
|
|
391
|
+
- the worktree is clean, and the delta (`git diff --name-only <commit> HEAD`)
|
|
392
|
+
is non-empty and shares no path with the Story's change set;
|
|
393
|
+
- the delta touches no coverage-determining config: `package.json`, a
|
|
394
|
+
lockfile, `tsconfig*.json`, or a vitest / jest / c8 / nyc config;
|
|
395
|
+
- a prior artifact exists to merge over.
|
|
396
|
+
|
|
397
|
+
Any git error or unresolvable input fails closed to the ordinary capture.
|
|
398
|
+
Every freshness log line names the stamp's scope, the required scope and the
|
|
399
|
+
verdict (`fresh`, `stale`, `scope-mismatch`, `missing`, or `delta-refresh`
|
|
400
|
+
with the delta's file count), so an unexpected full re-run is explainable
|
|
401
|
+
from the log alone. `captureScope: "full"` is unaffected.
|
|
402
|
+
|
|
368
403
|
#### `delivery.worktreeIsolation` — node_modules strategies
|
|
369
404
|
|
|
370
405
|
When `enabled: true`, each Story runs in its own worktree under
|
|
@@ -359,7 +359,7 @@
|
|
|
359
359
|
"properties": {
|
|
360
360
|
"fullSuiteLock": {
|
|
361
361
|
"type": "boolean",
|
|
362
|
-
"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.
|
|
362
|
+
"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. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.",
|
|
363
363
|
"default": true
|
|
364
364
|
}
|
|
365
365
|
},
|
|
@@ -600,6 +600,19 @@
|
|
|
600
600
|
"minLength": 1,
|
|
601
601
|
"description": "Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads.",
|
|
602
602
|
"default": "coverage/coverage-final.json"
|
|
603
|
+
},
|
|
604
|
+
"captureScope": {
|
|
605
|
+
"type": "string",
|
|
606
|
+
"enum": ["full", "affected"],
|
|
607
|
+
"description": "What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite.",
|
|
608
|
+
"default": "full"
|
|
609
|
+
},
|
|
610
|
+
"timeoutMs": {
|
|
611
|
+
"type": "integer",
|
|
612
|
+
"minimum": 60000,
|
|
613
|
+
"maximum": 7200000,
|
|
614
|
+
"description": "Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure.",
|
|
615
|
+
"default": 600000
|
|
603
616
|
}
|
|
604
617
|
},
|
|
605
618
|
"additionalProperties": false
|
|
@@ -200,7 +200,29 @@
|
|
|
200
200
|
"required": ["waitedSeconds", "expired"],
|
|
201
201
|
"properties": {
|
|
202
202
|
"waitedSeconds": { "type": "number", "minimum": 0 },
|
|
203
|
-
"expired": { "type": "boolean" }
|
|
203
|
+
"expired": { "type": "boolean" },
|
|
204
|
+
"holder": {
|
|
205
|
+
"type": "object",
|
|
206
|
+
"description": "Story #5485 — the live holder an expired wait gave up on, read from the lockfile: its owner id, pid and lock age. Each field is null when the lockfile did not say.",
|
|
207
|
+
"required": ["ownerId", "pid", "ageSeconds"],
|
|
208
|
+
"properties": {
|
|
209
|
+
"ownerId": { "type": ["string", "null"] },
|
|
210
|
+
"pid": { "type": ["integer", "null"], "minimum": 1 },
|
|
211
|
+
"ageSeconds": { "type": ["number", "null"], "minimum": 0 }
|
|
212
|
+
},
|
|
213
|
+
"additionalProperties": false
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"additionalProperties": false
|
|
217
|
+
},
|
|
218
|
+
"suiteTimings": {
|
|
219
|
+
"type": ["object", "null"],
|
|
220
|
+
"description": "Story #5485 — the close's one full-suite run split into three separate figures: lockWaitMs (queued on the host full-suite lock), hostWaitMs (spawn until the suite signalled MANDREL_SUITE_READY_FILE; null when it did not use the handshake) and testRunMs (the test phase the coverage.timeoutMs kill bound applies to). null when no full suite ran in this close.",
|
|
221
|
+
"required": ["lockWaitMs", "hostWaitMs", "testRunMs"],
|
|
222
|
+
"properties": {
|
|
223
|
+
"lockWaitMs": { "type": "number", "minimum": 0 },
|
|
224
|
+
"hostWaitMs": { "type": ["number", "null"], "minimum": 0 },
|
|
225
|
+
"testRunMs": { "type": "number", "minimum": 0 }
|
|
204
226
|
},
|
|
205
227
|
"additionalProperties": false
|
|
206
228
|
},
|
|
@@ -41,10 +41,12 @@
|
|
|
41
41
|
"check-baselines-independent",
|
|
42
42
|
"check-baselines-coverage",
|
|
43
43
|
"quality-preview",
|
|
44
|
+
"quality-preview-mi",
|
|
45
|
+
"quality-preview-crap",
|
|
44
46
|
"check-maintainability",
|
|
45
47
|
"check-crap"
|
|
46
48
|
],
|
|
47
|
-
"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; `quality-preview`
|
|
49
|
+
"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; `quality-preview-mi` / `quality-preview-crap` replay the pre-push quality preview at close as its two halves — the MI half in the parallel partition, the CRAP half serial behind `coverage-capture` (Story #5471) — with `quality-preview` kept as the historical name of the unsplit gate (Story #5378); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
|
|
48
50
|
},
|
|
49
51
|
"commitSha": {
|
|
50
52
|
"type": "string",
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Ensures `coverage/coverage-final.json` is fresh before the CRAP gate: skip
|
|
4
4
|
* when the gate is off, no changed file is under `crap.targetDirs`, or the
|
|
5
|
-
* content-digest stamp matches; otherwise run `test:coverage`
|
|
6
|
-
*
|
|
5
|
+
* content-digest stamp matches; otherwise run `test:coverage` (or, under
|
|
6
|
+
* `coverage.captureScope: "affected"`, the consumer's `test:coverage:affected`)
|
|
7
|
+
* behind the host-level full-suite lock and stamp on success.
|
|
7
8
|
*
|
|
8
9
|
* `--require-credited` must stay an argument, never a config read: as policy
|
|
9
10
|
* it also refused the depositing run, leaving no path that could deposit.
|
|
10
11
|
*
|
|
11
12
|
* Exit codes: 0 fresh/skipped/captured; 1 capture failed or refused (callers
|
|
12
|
-
* MUST surface it); 75 lock wait expired
|
|
13
|
-
* `MANDREL_FULL_SUITE_LOCK_ON_EXPIRY=defer`); 124 suite timed out.
|
|
13
|
+
* MUST surface it); 75 lock wait expired, nothing spawned; 124 suite timed out.
|
|
14
14
|
*/
|
|
15
15
|
import { getChangedFiles } from './lib/changed-files.js';
|
|
16
16
|
import { isDirectInvocation } from './lib/cli-utils.js';
|
|
@@ -23,13 +23,13 @@ import {
|
|
|
23
23
|
runCapture,
|
|
24
24
|
writeCaptureStamp,
|
|
25
25
|
} from './lib/coverage-capture.js';
|
|
26
|
+
import { tryScopedCapture } from './lib/coverage-capture-affected.js';
|
|
26
27
|
import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
|
|
27
|
-
import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
|
|
28
28
|
import { handleCoverageCaptureHelp } from './lib/coverage-capture-usage.js';
|
|
29
29
|
import { lockedCapture } from './lib/full-suite-lock.js';
|
|
30
|
-
|
|
31
30
|
import { Logger } from './lib/Logger.js';
|
|
32
31
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
32
|
+
import { formatSuiteTimings } from './lib/supervised-suite.js';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* A `null` ref defers the fallback to `resolveChangedFilesRef`.
|
|
@@ -67,6 +67,7 @@ export function parseArgs(argv) {
|
|
|
67
67
|
* writeCaptureStampImpl?: typeof writeCaptureStamp,
|
|
68
68
|
* filterFilesUnderTargetsImpl?: typeof filterFilesUnderTargets,
|
|
69
69
|
* logger?: { info: Function, warn: Function, error: Function },
|
|
70
|
+
* lockOptions?: object,
|
|
70
71
|
* }} [deps]
|
|
71
72
|
* @returns {Promise<number>}
|
|
72
73
|
*/
|
|
@@ -83,6 +84,7 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
83
84
|
writeCaptureStampImpl = writeCaptureStamp,
|
|
84
85
|
filterFilesUnderTargetsImpl = filterFilesUnderTargets,
|
|
85
86
|
logger = Logger,
|
|
87
|
+
lockOptions = {},
|
|
86
88
|
} = deps;
|
|
87
89
|
const args = parseArgs(argv);
|
|
88
90
|
const config = resolveConfigImpl({ cwd: args.cwd });
|
|
@@ -105,13 +107,65 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
105
107
|
|
|
106
108
|
// Outermost first: the credit probe announces or refuses the run, and only
|
|
107
109
|
// a surviving run reaches the host-level full-suite lock.
|
|
108
|
-
const
|
|
110
|
+
const locked = lockedCapture(runCaptureImpl, config, process.env, {
|
|
111
|
+
rerunCommand: rerunCommandFor(argv),
|
|
112
|
+
...lockOptions,
|
|
113
|
+
});
|
|
114
|
+
const credited = creditedCapture(locked, {
|
|
109
115
|
requireCredited: args.requireCredited,
|
|
110
116
|
logger,
|
|
111
117
|
});
|
|
118
|
+
let timings = null;
|
|
119
|
+
const capture = (captureOpts) =>
|
|
120
|
+
credited({
|
|
121
|
+
...captureOpts,
|
|
122
|
+
onTimings: (t) => {
|
|
123
|
+
timings = t;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const code = await runCaptureScopes({
|
|
127
|
+
crap,
|
|
128
|
+
coverage,
|
|
129
|
+
args,
|
|
130
|
+
capture,
|
|
131
|
+
deps: {
|
|
132
|
+
getChangedFilesImpl,
|
|
133
|
+
isCoverageFreshImpl,
|
|
134
|
+
computeContentDigestImpl,
|
|
135
|
+
writeCaptureStampImpl,
|
|
136
|
+
filterFilesUnderTargetsImpl,
|
|
137
|
+
readPackageScriptsImpl,
|
|
138
|
+
hasNpmScriptImpl,
|
|
139
|
+
logger,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (timings) logger.info(`[coverage-capture] ${formatSuiteTimings(timings)}`);
|
|
143
|
+
return code;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @param {string[]} argv */
|
|
147
|
+
function rerunCommandFor(argv) {
|
|
148
|
+
return ['node', ...argv.slice(1)].join(' ');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {{ crap: object, coverage: object, args: object, capture: Function, deps: object }} opts
|
|
153
|
+
* @returns {Promise<number>}
|
|
154
|
+
*/
|
|
155
|
+
async function runCaptureScopes({ crap, coverage, args, capture, deps }) {
|
|
156
|
+
const {
|
|
157
|
+
getChangedFilesImpl,
|
|
158
|
+
isCoverageFreshImpl,
|
|
159
|
+
computeContentDigestImpl,
|
|
160
|
+
writeCaptureStampImpl,
|
|
161
|
+
filterFilesUnderTargetsImpl,
|
|
162
|
+
readPackageScriptsImpl,
|
|
163
|
+
hasNpmScriptImpl,
|
|
164
|
+
logger,
|
|
165
|
+
} = deps;
|
|
112
166
|
|
|
113
167
|
// Shared so a new seam cannot reach one capture path and miss the other.
|
|
114
|
-
//
|
|
168
|
+
// A scoped `null` means not applicable: fall through to full scope.
|
|
115
169
|
const shared = {
|
|
116
170
|
crap,
|
|
117
171
|
coverage,
|
|
@@ -124,9 +178,11 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
124
178
|
logger,
|
|
125
179
|
};
|
|
126
180
|
|
|
127
|
-
const incrementalResult = await
|
|
181
|
+
const incrementalResult = await tryScopedCapture({
|
|
128
182
|
...shared,
|
|
129
183
|
filterFilesUnderTargetsImpl,
|
|
184
|
+
readPackageScriptsImpl,
|
|
185
|
+
hasNpmScriptImpl,
|
|
130
186
|
});
|
|
131
187
|
if (incrementalResult !== null) return incrementalResult;
|
|
132
188
|
|
|
@@ -5,14 +5,25 @@
|
|
|
5
5
|
* passed for the current HEAD and tree, and recording a pass for the next
|
|
6
6
|
* caller. `--standalone` is required: it keys evidence by Story id, the same
|
|
7
7
|
* keyspace close consults, so worker-side verify[] runs credit the close.
|
|
8
|
+
* `--gate test` takes the host full-suite lock.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import { parseArgs } from 'node:util';
|
|
12
13
|
import { runAsCli } from './lib/cli-utils.js';
|
|
14
|
+
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
15
|
+
import {
|
|
16
|
+
fullSuiteLockPolicy,
|
|
17
|
+
LOCK_WAIT_EXPIRED_EXIT_CODE,
|
|
18
|
+
withFullSuiteLockAsync,
|
|
19
|
+
} from './lib/full-suite-lock.js';
|
|
13
20
|
import { gitSpawn } from './lib/git-utils.js';
|
|
14
21
|
import { Logger } from './lib/Logger.js';
|
|
15
22
|
import { PROJECT_ROOT } from './lib/project-root.js';
|
|
23
|
+
import {
|
|
24
|
+
formatSuiteTimings,
|
|
25
|
+
runSupervisedSuite,
|
|
26
|
+
} from './lib/supervised-suite.js';
|
|
16
27
|
import {
|
|
17
28
|
hashCommandConfig,
|
|
18
29
|
recordPass,
|
|
@@ -20,6 +31,8 @@ import {
|
|
|
20
31
|
treeFingerprint,
|
|
21
32
|
} from './lib/validation-evidence.js';
|
|
22
33
|
|
|
34
|
+
const FULL_SUITE_GATE = 'test';
|
|
35
|
+
|
|
23
36
|
export function splitOnDashDash(argv) {
|
|
24
37
|
const idx = argv.indexOf('--');
|
|
25
38
|
if (idx === -1) return { wrapperArgs: argv, runnerArgs: [] };
|
|
@@ -90,6 +103,7 @@ function resolveEvidenceKeys({ spawnCwd, gitSpawnFn, useEvidence }) {
|
|
|
90
103
|
* @param {Function} [deps.shouldSkipFn]
|
|
91
104
|
* @param {Function} [deps.recordPassFn]
|
|
92
105
|
* @param {object} [deps.logger]
|
|
106
|
+
* @param {Function} [deps.runSuiteFn]
|
|
93
107
|
* @returns {{ status: number, skipped: boolean }}
|
|
94
108
|
*/
|
|
95
109
|
export async function runEvidenceGate(params, deps = {}) {
|
|
@@ -99,6 +113,7 @@ export async function runEvidenceGate(params, deps = {}) {
|
|
|
99
113
|
shouldSkipFn = shouldSkip,
|
|
100
114
|
recordPassFn = recordPass,
|
|
101
115
|
logger = Logger,
|
|
116
|
+
runSuiteFn = runLockedSuite,
|
|
102
117
|
} = deps;
|
|
103
118
|
const {
|
|
104
119
|
scopeId,
|
|
@@ -159,17 +174,18 @@ export async function runEvidenceGate(params, deps = {}) {
|
|
|
159
174
|
logger.info(
|
|
160
175
|
`[evidence-gate] ▶ ${gate} → ${cmd} ${cmdArgs.join(' ')} (cwd=${spawnCwd})`,
|
|
161
176
|
);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
const status = await runGateCommand({
|
|
178
|
+
gate,
|
|
179
|
+
cmd,
|
|
180
|
+
cmdArgs,
|
|
181
|
+
spawnCwd,
|
|
182
|
+
logger,
|
|
183
|
+
spawnFn,
|
|
184
|
+
runSuiteFn,
|
|
166
185
|
});
|
|
167
|
-
const status = result.status ?? 1;
|
|
168
186
|
if (status !== 0) {
|
|
169
187
|
process.exitCode = status;
|
|
170
|
-
logger
|
|
171
|
-
`[evidence-gate] ✖ ${gate} failed (exit ${status}) in ${spawnCwd}`,
|
|
172
|
-
);
|
|
188
|
+
reportGateExit({ gate, status, spawnCwd, logger });
|
|
173
189
|
return { status, skipped: false };
|
|
174
190
|
}
|
|
175
191
|
|
|
@@ -197,6 +213,88 @@ export async function runEvidenceGate(params, deps = {}) {
|
|
|
197
213
|
return { status: 0, skipped: false };
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
/**
|
|
217
|
+
* @param {{ cmd: string, args: string[], cwd: string, log: (m: string) => void }} run
|
|
218
|
+
* @param {{ config?: object|null, env?: object, lockOptions?: object, runSuiteImpl?: typeof runSupervisedSuite }} [seams]
|
|
219
|
+
* @returns {Promise<number>} 75 when the lock wait expired.
|
|
220
|
+
*/
|
|
221
|
+
export function runLockedSuite(
|
|
222
|
+
{ cmd, args, cwd, log },
|
|
223
|
+
{
|
|
224
|
+
config = loadConfig(cwd),
|
|
225
|
+
env = process.env,
|
|
226
|
+
lockOptions = {},
|
|
227
|
+
runSuiteImpl = runSupervisedSuite,
|
|
228
|
+
} = {},
|
|
229
|
+
) {
|
|
230
|
+
const timeoutMs = getQuality(config).coverage?.timeoutMs;
|
|
231
|
+
return withFullSuiteLockAsync(
|
|
232
|
+
{
|
|
233
|
+
...fullSuiteLockPolicy(config, env),
|
|
234
|
+
cwd,
|
|
235
|
+
log,
|
|
236
|
+
rerunCommand: ['node', ...process.argv.slice(1)].join(' '),
|
|
237
|
+
...lockOptions,
|
|
238
|
+
},
|
|
239
|
+
({ lockWaitMs }) =>
|
|
240
|
+
runSuiteImpl({
|
|
241
|
+
cmd,
|
|
242
|
+
args,
|
|
243
|
+
cwd,
|
|
244
|
+
timeoutMs,
|
|
245
|
+
lockWaitMs,
|
|
246
|
+
onTimings: (t) => log(`[evidence-gate] ${formatSuiteTimings(t)}`),
|
|
247
|
+
onTimeout: () =>
|
|
248
|
+
log(
|
|
249
|
+
`[evidence-gate] ⏱ ${cmd} ${args.join(' ')} exceeded ${timeoutMs}ms — killed its process group.`,
|
|
250
|
+
),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The `test` gate runs locked and supervised; any other gate plainly. */
|
|
256
|
+
async function runGateCommand({
|
|
257
|
+
gate,
|
|
258
|
+
cmd,
|
|
259
|
+
cmdArgs,
|
|
260
|
+
spawnCwd,
|
|
261
|
+
logger,
|
|
262
|
+
spawnFn,
|
|
263
|
+
runSuiteFn,
|
|
264
|
+
}) {
|
|
265
|
+
if (gate === FULL_SUITE_GATE) {
|
|
266
|
+
const log = (m) => logger.info(m);
|
|
267
|
+
return await runSuiteFn({ cmd, args: cmdArgs, cwd: spawnCwd, log });
|
|
268
|
+
}
|
|
269
|
+
const result = spawnFn(cmd, cmdArgs, {
|
|
270
|
+
cwd: spawnCwd,
|
|
271
|
+
stdio: 'inherit',
|
|
272
|
+
shell: process.platform === 'win32',
|
|
273
|
+
});
|
|
274
|
+
return result.status ?? 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function reportGateExit({ gate, status, spawnCwd, logger }) {
|
|
278
|
+
if (status === LOCK_WAIT_EXPIRED_EXIT_CODE) {
|
|
279
|
+
logger.info(
|
|
280
|
+
`[evidence-gate] ⏸ ${gate} deferred (exit ${status}) — another full suite still holds the host lock, so nothing ran and no evidence was recorded.`,
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
logger.error(
|
|
285
|
+
`[evidence-gate] ✖ ${gate} failed (exit ${status}) in ${spawnCwd}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** An unloadable config falls back to the defaults. */
|
|
290
|
+
function loadConfig(cwd) {
|
|
291
|
+
try {
|
|
292
|
+
return resolveConfig({ cwd });
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
200
298
|
async function main() {
|
|
201
299
|
const { wrapperArgs, runnerArgs } = splitOnDashDash(process.argv.slice(2));
|
|
202
300
|
const args = parseWrapperArgs(wrapperArgs);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Wires `resolveCoverageRefreshScope` to the on-disk artifact and c8 scope. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
buildScopePredicate,
|
|
6
|
+
readArtifactCaptureScope,
|
|
7
|
+
readCoverageFinal,
|
|
8
|
+
resolveCoverageRefreshScope,
|
|
9
|
+
scoreCoverageFinal,
|
|
10
|
+
} from '../coverage-baseline.js';
|
|
11
|
+
import { deriveScopeFromDiff } from './refresh-service.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `refreshBaseline` scope options for `update-coverage-baseline.js`.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} cwd
|
|
17
|
+
* @param {{
|
|
18
|
+
* fullScope: boolean,
|
|
19
|
+
* diffScopeRef: string | null,
|
|
20
|
+
* loadScope: (cwd: string) => { include?: string[], exclude?: string[] },
|
|
21
|
+
* readCaptureScope?: typeof readArtifactCaptureScope,
|
|
22
|
+
* readCoverage?: typeof readCoverageFinal,
|
|
23
|
+
* existsSync?: typeof fs.existsSync,
|
|
24
|
+
* deriveDiff?: typeof deriveScopeFromDiff,
|
|
25
|
+
* }} opts
|
|
26
|
+
*/
|
|
27
|
+
export function resolveUpdaterRefreshScope(
|
|
28
|
+
cwd,
|
|
29
|
+
{
|
|
30
|
+
fullScope,
|
|
31
|
+
diffScopeRef,
|
|
32
|
+
loadScope,
|
|
33
|
+
readCaptureScope = readArtifactCaptureScope,
|
|
34
|
+
readCoverage = readCoverageFinal,
|
|
35
|
+
existsSync = fs.existsSync,
|
|
36
|
+
deriveDiff = deriveScopeFromDiff,
|
|
37
|
+
},
|
|
38
|
+
) {
|
|
39
|
+
const c8 = () => {
|
|
40
|
+
const config = loadScope(cwd);
|
|
41
|
+
return buildScopePredicate({
|
|
42
|
+
include: config.include ?? [],
|
|
43
|
+
exclude: config.exclude ?? [],
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
return resolveCoverageRefreshScope({
|
|
47
|
+
cwd,
|
|
48
|
+
fullScope,
|
|
49
|
+
diffScopeRef,
|
|
50
|
+
readCaptureScope,
|
|
51
|
+
listMeasured: () =>
|
|
52
|
+
Object.keys(
|
|
53
|
+
scoreCoverageFinal({ raw: readCoverage(cwd), cwd, scope: c8() }),
|
|
54
|
+
),
|
|
55
|
+
inCoverageScope: (file) =>
|
|
56
|
+
c8()(file) && existsSync(path.resolve(cwd, file)),
|
|
57
|
+
deriveDiffFiles: (baseRef) =>
|
|
58
|
+
deriveDiff({ baseRef, headRef: 'HEAD', predicate: () => true, cwd }),
|
|
59
|
+
});
|
|
60
|
+
}
|