mandrel 2.67.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.agents/agents/story-worker.md +15 -11
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +36 -1
  4. package/.agents/schemas/agentrc.schema.json +14 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/coverage-capture.js +65 -9
  8. package/.agents/scripts/evidence-gate.js +106 -8
  9. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  10. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  11. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  12. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  13. package/.agents/scripts/lib/child-exec.js +39 -1
  14. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  15. package/.agents/scripts/lib/close-validation/process.js +23 -24
  16. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  17. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  18. package/.agents/scripts/lib/config/quality.js +7 -1
  19. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  20. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  21. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  22. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  23. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  24. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  25. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  26. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  27. package/.agents/scripts/lib/coverage-capture.js +121 -81
  28. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  29. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  30. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  31. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  32. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  34. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  36. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  40. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  41. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  43. package/.agents/scripts/lib/process-group.js +1 -1
  44. package/.agents/scripts/lib/supervised-suite.js +247 -0
  45. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  46. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  47. package/.agents/scripts/quality-preview.js +112 -14
  48. package/.agents/scripts/stories-wave-tick.js +47 -0
  49. package/.agents/scripts/story-review-compute.js +207 -0
  50. package/.agents/scripts/update-coverage-baseline.js +15 -10
  51. package/.agents/scripts/update-crap-baseline.js +12 -2
  52. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  53. package/.agents/workflows/audit-security.md +0 -1
  54. package/.agents/workflows/helpers/code-review.md +7 -5
  55. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  56. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  57. package/.agents/workflows/helpers/deliver-story.md +2 -1
  58. package/docs/CHANGELOG.md +33 -0
  59. package/lib/cli/registry.js +125 -18
  60. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  61. package/package.json +1 -1
@@ -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. The **one** exception is the
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 it —
94
- that section is the rule's only home, so read the invocation there rather
95
- than from a copy here.
96
-
97
- If the suite outruns the host's sync Bash ceiling, dispatch it in the
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. Say the
151
- branch is pushed and unclosed. Never hand-compose a terminal envelope —
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.
@@ -125,7 +125,9 @@
125
125
  "functions": 90
126
126
  }
127
127
  },
128
- "coveragePath": "coverage/coverage-final.json"
128
+ "coveragePath": "coverage/coverage-final.json",
129
+ "captureScope": "full",
130
+ "timeoutMs": 600000
129
131
  },
130
132
  "crap": {
131
133
  "enabled": true,
@@ -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. 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. |
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. 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.",
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` replays the pre-push CRAP-scope preview at close, registered beside `coverage-capture` (Story #5378); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
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` behind the
6
- * host-level full-suite lock and stamp on success.
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 and deferred (only under
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 capture = creditedCapture(lockedCapture(runCaptureImpl, config), {
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
- // An incremental `null` means not applicable: fall through to full scope.
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 tryIncrementalCapture({
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 result = spawnFn(cmd, cmdArgs, {
163
- cwd: spawnCwd,
164
- stdio: 'inherit',
165
- shell: process.platform === 'win32',
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.error(
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
+ }