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.
Files changed (33) hide show
  1. package/.agents/agents/story-worker.md +24 -14
  2. package/.agents/docs/agentrc-reference.json +7 -2
  3. package/.agents/docs/configuration.md +5 -2
  4. package/.agents/schemas/agentrc.schema.json +17 -2
  5. package/.agents/schemas/validation-evidence.schema.json +3 -1
  6. package/.agents/scripts/acceptance-eval.js +68 -3
  7. package/.agents/scripts/coverage-capture.js +25 -8
  8. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  9. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  10. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  11. package/.agents/scripts/lib/close-validation/process.js +30 -1
  12. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  13. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  14. package/.agents/scripts/lib/config/quality.js +36 -21
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  16. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  17. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  18. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  19. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  20. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  21. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  22. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  23. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  26. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  27. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  28. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  29. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  30. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  31. package/.agents/workflows/helpers/deliver-story.md +23 -21
  32. package/docs/CHANGELOG.md +18 -0
  33. 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 — do not pre-run
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**) before
97
- it merges. Advisory pre-flight is fine; the close pipeline is the
98
- authoritative gate. The acceptance self-eval loop may share `lint` /
99
- `typecheck` evidence with close via `evidence-gate.js`; never stamp
100
- coverage / CRAP fresh that way.
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
- Return a short, literal hand-off your caller can act on: the Story id,
146
- `workCwd`, the branch name, the pushed head SHA, the self-eval verdict,
147
- and the `verify[]` evidence you gathered.
148
- Say plainly that the branch is pushed and unclosed. Never hand-compose a
149
- terminal envelope that document belongs to close, and inventing one
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` | — | Story #4981 opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today’s full-repo behaviour byte-for-byte. When `enabled: true`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065). |
214
- | `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | | Master switch for the capture skip and the baseline-resolved CRAP join. |
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": "Story #4981 opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today’s full-repo behaviour byte-for-byte. When `enabled: true`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).",
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": "Master switch for the capture skip and the baseline-resolved CRAP join."
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, capReached, totalCriteria,
61
- * metCount, unmetCriteria[], signalEmitted, replay, verdictFingerprint }
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`, write a fresh capture stamp
16
- * on success, and propagate the exit code.
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 #4981incremental mode, opt-in via
128
- // `delivery.quality.gates.crap.incrementalCoverage.enabled`. `null` means
129
- // "not applicable" (disabled, or a ref-resolution error) fall through to
130
- // the full-scope path below rather than silently skipping capture.
135
+ // Story #5173the 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?: { enabled?: boolean, baseRef?: string } },
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?.enabled !== true) return null;
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 (typeof ref !== 'string' || ref.length === 0) {
190
+ if (!isNonEmptyString(ref)) {
165
191
  throw new TypeError('readBaseFromGit: ref must be a non-empty string');
166
192
  }
167
- if (typeof file !== 'string' || file.length === 0) {
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 = spawnChild('git', ['show', spec], {
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 = typeof result.stdout === 'string' ? result.stdout : '';
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 commit subjects, within the range `<baseRef>..HEAD`, of commits
216
- * that touched `<file>` (Story #4731). Powers the maintainability
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 the maintainability baseline demotes that run's
239
+ * compared range that touches a kind's baseline acknowledges that run's
219
240
  * head-vs-base regressions.
220
241
  *
221
- * Restricting the log to `-- <file>` means the returned subjects already
222
- * satisfy the "commit whose diff touches the baseline file" half of the
223
- * predicate; the caller only has to match the tag substring against each
224
- * subject. Runs through the same shared `spawnChild` surface and injected
225
- * runner as `readBaseFromGit`, so it inherits the identical env scrubbing
226
- * (drop inherited `GIT_*`), the same stdout ceiling, and the spawn-not-exec
227
- * security posture (argv tokens, `shell: false`).
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
- * ref, git failure, empty range) — the acknowledgment path treats "no
231
- * matching commit" and "could not determine" identically: the ratchet stays
232
- * at full strength. Not cached: the range result depends on live HEAD, which
233
- * the `(ref, file)` LRU key does not capture.
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[]} commit subjects, newest first; `[]` on any failure.
271
+ * @returns {{ sha: string, subject: string }[]} newest first; `[]` on any failure.
239
272
  */
240
- export function readRangeSubjectsTouchingFile(baseRef, file, opts = {}) {
241
- if (typeof baseRef !== 'string' || baseRef.length === 0) return [];
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 = spawnChild(
248
- 'git',
249
- ['log', `${baseRef}..HEAD`, '--format=%s', '--', file],
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 (!result || result.status !== 0) return [];
257
- const out = typeof result.stdout === 'string' ? result.stdout : '';
258
- return out
286
+ if (result?.status !== 0) return [];
287
+ return stdoutOf(result)
259
288
  .split('\n')
260
- .map((line) => line.trim())
261
- .filter((line) => line.length > 0);
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
  /**