mandrel 2.40.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 (90) hide show
  1. package/.agents/README.md +2 -2
  2. package/.agents/agents/story-worker.md +24 -14
  3. package/.agents/docs/agentrc-reference.json +7 -2
  4. package/.agents/docs/configuration.md +5 -2
  5. package/.agents/instructions.md +5 -6
  6. package/.agents/rules/api-conventions.md +43 -7
  7. package/.agents/rules/ci-remediation.md +3 -14
  8. package/.agents/rules/gherkin-standards.md +21 -6
  9. package/.agents/rules/git-conventions.md +6 -5
  10. package/.agents/rules/security-baseline.md +6 -7
  11. package/.agents/rules/testing-standards.md +75 -198
  12. package/.agents/schemas/agentrc.schema.json +17 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +68 -3
  15. package/.agents/scripts/coverage-capture.js +25 -8
  16. package/.agents/scripts/install-matrix-assert.js +2 -2
  17. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  19. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  20. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  21. package/.agents/scripts/lib/close-validation/process.js +30 -1
  22. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  23. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  24. package/.agents/scripts/lib/config/quality.js +36 -21
  25. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  26. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  27. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  28. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  29. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  30. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  31. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  33. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  34. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  35. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  36. package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
  37. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  38. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  39. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  40. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
  41. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  46. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  47. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  48. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  49. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  50. package/.agents/scripts/plan-persist.js +60 -1
  51. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  52. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  53. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  54. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  55. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  56. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  57. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  58. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  59. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  60. package/.agents/skills/skills.index.json +10 -70
  61. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  62. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  63. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  64. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  65. package/.agents/workflows/git-cleanup.md +3 -2
  66. package/.agents/workflows/git-deliver.md +3 -2
  67. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  68. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  69. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  70. package/.agents/workflows/helpers/deliver-story.md +23 -21
  71. package/.agents/workflows/helpers/plan-reference.md +82 -2
  72. package/.agents/workflows/mandrel-plan.md +45 -45
  73. package/.agents/workflows/qa-assist.md +20 -17
  74. package/.agents/workflows/qa-explore.md +30 -29
  75. package/.agents/workflows/qa-run.md +2 -1
  76. package/docs/CHANGELOG.md +32 -0
  77. package/package.json +1 -1
  78. package/.agents/rules/changelog-style.md +0 -180
  79. package/.agents/rules/shell-conventions.md +0 -61
  80. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  81. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  82. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  83. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  84. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  85. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  86. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  87. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  88. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  89. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  90. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
@@ -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
  /**
@@ -117,28 +117,74 @@ function isCrapGateEnabled(config) {
117
117
  */
118
118
  function buildTestGateEntry(coverageCaptureActive) {
119
119
  if (coverageCaptureActive) return [];
120
- return [{ name: 'test', cmd: 'npm', args: ['test'] }];
120
+ // Story #5173 `fullSuiteLock` marks the one gate here that spawns a whole
121
+ // suite, so `defaultGateRunner` serializes it behind the host lock. It is
122
+ // set on this entry alone precisely because the two full-suite gates are
123
+ // mutually exclusive: when `coverage-capture` is registered instead, the
124
+ // lock is taken one level down, inside `runCapture`.
125
+ return [{ name: 'test', cmd: 'npm', args: ['test'], fullSuiteLock: true }];
121
126
  }
122
127
 
123
128
  const CHECK_BASELINES_HINT =
124
129
  'Unified baselines gate breached. Inspect the JSON report (`node .agents/scripts/check-baselines.js`) to see which kind/component/axis fell below floor; remediate the underlying file(s) or — when the regression is intentional — refresh the relevant baseline through its per-kind update script and commit with a `baseline-refresh:` tagged subject.';
125
130
 
131
+ /**
132
+ * The names the unified baselines gate can register under (Story #5172).
133
+ *
134
+ * `single` is the unsplit entry — the historical name, and the fail-closed
135
+ * fallback used whenever the enabled-kind set cannot be resolved into two
136
+ * buckets. `independent` and `coverage` are the split pair: the first reads no
137
+ * coverage artifact and therefore fails alongside `lint` / `format` /
138
+ * `typecheck` in the parallel partition, the second consumes the artifact
139
+ * `coverage-capture` writes and therefore stays serial behind it.
140
+ *
141
+ * Every name here MUST also be a member of the `gateName` enum in
142
+ * `.agents/schemas/validation-evidence.schema.json` — the close pipeline keys
143
+ * per-gate evidence on it. `tests/close-validation-gates-enum.test.js` pins
144
+ * that ⊆ invariant.
145
+ */
146
+ export const BASELINES_GATE_NAMES = Object.freeze({
147
+ single: 'check-baselines',
148
+ independent: 'check-baselines-independent',
149
+ coverage: 'check-baselines-coverage',
150
+ });
151
+
152
+ /**
153
+ * The baseline kinds whose evaluation reads the coverage artifact written by
154
+ * the `coverage-capture` gate (`coverage` scores it directly; `crap` divides
155
+ * complexity by it). They are the only kinds that have to wait for the
156
+ * capture — every other kind scores the source tree and can run as early as
157
+ * the cheapest gates do.
158
+ */
159
+ const COVERAGE_CONSUMING_KINDS = new Set(['coverage', 'crap']);
160
+
126
161
  /**
127
162
  * Baseline kinds the resolved config enables for the unified
128
163
  * `check-baselines` gate. Mirrors `selectEnabledGates` in the check-baselines
129
164
  * pipeline (a kind runs when its `gates.<kind>` block is present and not
130
165
  * explicitly disabled) so the registration probe's view of "what will run"
131
- * matches the gate's own view exactly.
166
+ * matches the gate's own view exactly — and so the Story #5172 partition is
167
+ * derived from the pipeline's own view of what runs rather than a hardcoded
168
+ * kind list that a consumer's config could silently contradict.
169
+ *
170
+ * Returns `null` when that view cannot be resolved at all (a config object
171
+ * whose `delivery.quality` access throws). Callers MUST read `null` as
172
+ * "unknown" and fall back to the single unsplit gate: a partition that cannot
173
+ * be computed must never silently drop enforcement.
132
174
  *
133
175
  * @param {object|undefined|null} config canonical resolved config
134
- * @returns {string[]}
176
+ * @returns {string[]|null}
135
177
  */
136
178
  function enabledBaselineKinds(config) {
137
- const gates = getQuality(config)?.gates ?? {};
138
- return KNOWN_KINDS.filter((kind) => {
139
- const block = gates[kind];
140
- return block && typeof block === 'object' && block.enabled !== false;
141
- });
179
+ try {
180
+ const gates = getQuality(config)?.gates ?? {};
181
+ return KNOWN_KINDS.filter((kind) => {
182
+ const block = gates[kind];
183
+ return block && typeof block === 'object' && block.enabled !== false;
184
+ });
185
+ } catch {
186
+ return null;
187
+ }
142
188
  }
143
189
 
144
190
  /**
@@ -182,13 +228,22 @@ function toKindSet(presentBaselines) {
182
228
  * (`requireBaselines: true`) but absent; keep the gate registered so it
183
229
  * fails, with a preflight hint naming the fix.
184
230
  *
185
- * @param {{ config?: object, cwd?: string, presentBaselines?: string[]|Set<string> }} opts
186
- * `presentBaselines` injects the set of kinds whose baseline artifact
187
- * exists (tests), short-circuiting the on-disk probe.
231
+ * @param {{ config?: object, cwd?: string, enabledKinds?: string[]|null, presentBaselines?: string[]|Set<string> }} opts
232
+ * `enabledKinds` is `enabledBaselineKinds(config)` computed once by the
233
+ * caller (so the probe and the partition below read the same view).
234
+ * A `null` — the unresolvable set — reads as "no enabled kinds", which is
235
+ * the fail-closed path: the gate stays registered under its single
236
+ * historical name. `presentBaselines` injects the set of kinds whose
237
+ * baseline artifact exists (tests), short-circuiting the on-disk probe.
188
238
  * @returns {{ register: boolean, reason?: string, hint?: string }}
189
239
  */
190
- function probeBaselinesGate({ config, cwd, presentBaselines } = {}) {
191
- const enabled = enabledBaselineKinds(config);
240
+ function probeBaselinesGate({
241
+ config,
242
+ cwd,
243
+ enabledKinds,
244
+ presentBaselines,
245
+ } = {}) {
246
+ const enabled = enabledKinds ?? [];
192
247
  if (enabled.length === 0) {
193
248
  // No enabled baseline kinds → `check-baselines.js` self-skips every kind
194
249
  // and exits clean (an empty PASS). There is no deterministic-failure risk
@@ -220,6 +275,66 @@ function probeBaselinesGate({ config, cwd, presentBaselines } = {}) {
220
275
  };
221
276
  }
222
277
 
278
+ /**
279
+ * Build the `check-baselines` gate entries for this run (Story #5172).
280
+ *
281
+ * One registration decision, one `BASELINE_REF` overlay, one remediation
282
+ * hint — fanned out across however many entries the enabled-kind set splits
283
+ * into. Keeping the fan-out here is what makes the #3890 (`BASELINE_REF`)
284
+ * and #4495 (`probeBaselinesGate`) invariants structurally impossible to
285
+ * apply to one entry and forget on the other.
286
+ *
287
+ * Three shapes:
288
+ * - decision says skip → no entries at all (#4495's greenfield skip).
289
+ * - `kinds` is null (unresolvable) or empty → ONE entry under the single
290
+ * historical name with no `--gate` filter, in its historical serial
291
+ * position. Fail closed: a partition that cannot be computed must never
292
+ * silently drop enforcement, and an empty set means the gate self-skips
293
+ * every kind and exits a clean empty PASS exactly as it did pre-split.
294
+ * - otherwise → the split pair, each pinned to its own `--gate` list.
295
+ * Neither bucket is ever registered with an empty kind set, so a consumer
296
+ * running only coverage-consuming kinds gets no parallel entry and one
297
+ * running none of them gets no serial entry.
298
+ *
299
+ * The independent entry is emitted first so a reader of the gate list sees
300
+ * the order the runner actually walks; `partitionGates` is what routes it
301
+ * into the parallel phase, and the coverage entry keeps its declared
302
+ * position after `coverage-capture`.
303
+ *
304
+ * @param {{ decision: { register: boolean, hint?: string }, kinds: string[]|null, env: { BASELINE_REF: string }|null }} args
305
+ * @returns {Gate[]}
306
+ */
307
+ function buildBaselinesGateEntries({ decision, kinds, env }) {
308
+ if (!decision.register) return [];
309
+ const entry = (name, gateKinds) => ({
310
+ name,
311
+ cmd: 'node',
312
+ args: [
313
+ '.agents/scripts/check-baselines.js',
314
+ ...(gateKinds ? ['--gate', gateKinds.join(',')] : []),
315
+ '--format',
316
+ 'text',
317
+ ],
318
+ hint: decision.hint ?? CHECK_BASELINES_HINT,
319
+ ...(env ? { env } : {}),
320
+ });
321
+ if (!Array.isArray(kinds) || kinds.length === 0) {
322
+ return [entry(BASELINES_GATE_NAMES.single, null)];
323
+ }
324
+ const independentKinds = kinds.filter(
325
+ (k) => !COVERAGE_CONSUMING_KINDS.has(k),
326
+ );
327
+ const coverageKinds = kinds.filter((k) => COVERAGE_CONSUMING_KINDS.has(k));
328
+ return [
329
+ ...(independentKinds.length > 0
330
+ ? [entry(BASELINES_GATE_NAMES.independent, independentKinds)]
331
+ : []),
332
+ ...(coverageKinds.length > 0
333
+ ? [entry(BASELINES_GATE_NAMES.coverage, coverageKinds)]
334
+ : []),
335
+ ];
336
+ }
337
+
223
338
  /**
224
339
  * Build the canonical close-validation gate list.
225
340
  *
@@ -308,9 +423,11 @@ export function buildDefaultGates({
308
423
  ? buildChangedFileScope(baseBranch)
309
424
  : null;
310
425
  const baselinesGateEnv = buildBaselinesGateEnv(baseBranch);
426
+ const baselineKinds = enabledBaselineKinds(config);
311
427
  const baselinesDecision = probeBaselinesGate({
312
428
  config,
313
429
  cwd,
430
+ enabledKinds: baselineKinds,
314
431
  presentBaselines,
315
432
  });
316
433
  if (!baselinesDecision.register && baselinesDecision.reason) {
@@ -362,17 +479,19 @@ export function buildDefaultGates({
362
479
  // gate fails deterministically on first try reading a non-existent
363
480
  // `baselines/<kind>.json` (`probeBaselinesGate`). When required-by-config
364
481
  // but absent, it stays registered with a preflight hint naming the fix.
365
- ...(baselinesDecision.register
366
- ? [
367
- {
368
- name: 'check-baselines',
369
- cmd: 'node',
370
- args: ['.agents/scripts/check-baselines.js', '--format', 'text'],
371
- hint: baselinesDecision.hint ?? CHECK_BASELINES_HINT,
372
- ...(baselinesGateEnv ? { env: baselinesGateEnv } : {}),
373
- },
374
- ]
375
- : []),
482
+ //
483
+ // Story #5172: the gate registers as up to TWO entries. The kinds that
484
+ // read no coverage artifact run in the parallel independent partition so
485
+ // a baseline breach fails beside `lint` / `format` / `typecheck` instead
486
+ // of minutes later behind `coverage-capture`; the coverage-consuming
487
+ // kinds keep the serial slot after it. `buildBaselinesGateEntries` owns
488
+ // that fan-out so both entries inherit ONE registration decision, ONE
489
+ // `BASELINE_REF` overlay and ONE hint.
490
+ ...buildBaselinesGateEntries({
491
+ decision: baselinesDecision,
492
+ kinds: baselineKinds,
493
+ env: baselinesGateEnv,
494
+ }),
376
495
  ];
377
496
  }
378
497
 
@@ -392,7 +511,16 @@ export const DEFAULT_GATES = buildDefaultGates();
392
511
  * state, no overlapping ports/sockets). Safe to run concurrently — see
393
512
  * `runCloseValidation` for the Promise.all + AbortController plumbing.
394
513
  */
395
- const INDEPENDENT_GATE_NAMES = new Set(['lint', 'format', 'typecheck']);
514
+ const INDEPENDENT_GATE_NAMES = new Set([
515
+ 'lint',
516
+ 'format',
517
+ 'typecheck',
518
+ // Story #5172 — the coverage-independent half of the baselines gate. It
519
+ // reads the committed `baselines/<kind>.json` files and scores the source
520
+ // tree in-process; it writes nothing and shares no port, so it satisfies
521
+ // the same read-only contract as the three gates above.
522
+ BASELINES_GATE_NAMES.independent,
523
+ ]);
396
524
 
397
525
  /**
398
526
  * Partition a gate list into the parallel-safe set and the order-sensitive
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { spawn } from 'node:child_process';
9
9
 
10
+ import { withFullSuiteLockAsync } from '../full-suite-lock.js';
11
+
10
12
  /**
11
13
  * Pipe a child stream's output line-by-line through `emit`, prepending
12
14
  * `prefix` to each line. Tail bytes without a trailing newline flush on
@@ -141,12 +143,39 @@ function isBiomeNoFilesProcessed(output) {
141
143
  * because that exit means every config-included path was already excluded,
142
144
  * not that formatting drifted.
143
145
  *
146
+ * When `opts.fullSuiteLock` is set — the standalone `test` gate, the one gate
147
+ * here that runs a whole suite (Story #5173) — the spawn is serialized behind
148
+ * the host-level advisory lock so two concurrent closes on one checkout do not
149
+ * run two suites against the same cores. Best-effort: a wait that expires
150
+ * spawns anyway. The async wrapper is used rather than the synchronous one
151
+ * precisely because this runner drives sibling gates on the same event loop,
152
+ * which a blocking wait would stall.
153
+ *
144
154
  * @param {string} cmd
145
155
  * @param {string[]} args
146
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean }} opts
156
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean }} opts
147
157
  * @returns {Promise<{ status: number }>}
148
158
  */
149
159
  export function defaultGateRunner(cmd, args, opts = {}) {
160
+ if (!opts.fullSuiteLock) return spawnGate(cmd, args, opts);
161
+ // `log` is passed through as-is: `withFullSuiteLockAsync` supplies its own
162
+ // no-op default, so a second fallback here would be an untestable branch.
163
+ return withFullSuiteLockAsync({ cwd: opts.cwd, log: opts.log }, () =>
164
+ spawnGate(cmd, args, opts),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * The bare gate spawn — child process, prefixed drain, abort wiring, exit-code
170
+ * normalisation. Split from {@link defaultGateRunner} so the full-suite lock
171
+ * composes over one named unit.
172
+ *
173
+ * @param {string} cmd
174
+ * @param {string[]} args
175
+ * @param {Parameters<typeof defaultGateRunner>[2]} opts
176
+ * @returns {Promise<{ status: number }>}
177
+ */
178
+ function spawnGate(cmd, args, opts) {
150
179
  const { cwd, signal, gateName, log, env, tolerateNoFilesProcessed } = opts;
151
180
  const child = spawn(cmd, args, {
152
181
  cwd,
@@ -226,6 +226,11 @@ export async function runCloseValidation({
226
226
  log,
227
227
  signal,
228
228
  ...(gate.env ? { env: gate.env } : {}),
229
+ // Story #5173 — forwarded unconditionally (never a conditional spread
230
+ // like the two below): `defaultGateRunner` already treats a falsy value
231
+ // as "no lock", so a branch here would only add a decision point to the
232
+ // hottest function in this file.
233
+ fullSuiteLock: gate.fullSuiteLock,
229
234
  ...(gate.tolerateNoFilesProcessed
230
235
  ? { tolerateNoFilesProcessed: true }
231
236
  : {}),
@@ -1,32 +1,53 @@
1
1
  /* node:coverage ignore file -- AJV schema declaration (data-as-code) */
2
2
 
3
3
  /**
4
- * `delivery.quality.gates.crap.incrementalCoverage` — opt-in incremental
5
- * coverage-capture + CRAP-join scoping (Story #4981).
4
+ * `delivery.quality.gates.crap.incrementalCoverage` — the two independent
5
+ * full-suite economies (Story #4981, split by Story #5173).
6
6
  *
7
7
  * Split into its own module (rather than an inline property literal on
8
8
  * `CRAP_GATE`) so the schema addition lands as a new file, not a same-file
9
9
  * expansion of `crap.schema.js` — the file this module's sole export is
10
10
  * spread into.
11
11
  *
12
- * Default (key absent) preserves today's full-repo behaviour byte-for-byte.
13
- * When `enabled: true`, the changed-file set against `baseRef` (default: the
14
- * gate's own `--ref` / `main`) decides **whether** to capture — no changed
15
- * file under `crap.targetDirs` means no capture at all — and the CRAP join
16
- * treats a method in a file the diff did not touch as resolved by its
17
- * committed baseline row instead of requiring fresh coverage for it. It does
18
- * not narrow the capture run itself: a capture that does happen is the
19
- * ordinary full `npm run test:coverage` (Story #5065).
12
+ * The two switches are deliberately independent because they are not equally
13
+ * safe:
14
+ *
15
+ * - **`skipWhenUnchanged`** (default `true`) decides *whether* to capture:
16
+ * no changed file under `crap.targetDirs` versus `baseRef` means no
17
+ * capture at all. It is a pure saving the gates score exactly what they
18
+ * scored before, because nothing they score moved.
19
+ * - **`baselineJoin`** (default `false`) loosens gate semantics: the CRAP
20
+ * join resolves a method in an untouched file from its committed baseline
21
+ * row instead of requiring fresh coverage for it.
22
+ *
23
+ * Bundling them under one `enabled` switch is what forced the earlier default
24
+ * flip to be reverted. `enabled` survives as a deprecated alias that sets
25
+ * both.
26
+ *
27
+ * Neither switch narrows the capture run itself: a capture that does happen is
28
+ * the ordinary full `npm run test:coverage` (Story #5065).
20
29
  */
21
30
  export const INCREMENTAL_COVERAGE_SCHEMA = {
22
31
  type: 'object',
23
32
  description:
24
- '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).',
33
+ '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).',
25
34
  properties: {
35
+ skipWhenUnchanged: {
36
+ type: 'boolean',
37
+ description:
38
+ '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.',
39
+ default: true,
40
+ },
41
+ baselineJoin: {
42
+ type: 'boolean',
43
+ description:
44
+ '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.',
45
+ default: false,
46
+ },
26
47
  enabled: {
27
48
  type: 'boolean',
28
49
  description:
29
- 'Master switch for the capture skip and the baseline-resolved CRAP join.',
50
+ '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.',
30
51
  },
31
52
  baseRef: {
32
53
  type: 'string',
@@ -72,22 +72,27 @@ const DEFAULT_MI_FLOORS = Object.freeze({
72
72
  });
73
73
 
74
74
  /**
75
- * Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping.
76
- * Disabled by default: `coverage-capture.js` and the CRAP join keep their
77
- * pre-#4981 full-repo behaviour byte-for-byte until a consumer sets
78
- * `enabled: true`. `baseRef: null` means "use the caller's own ref
79
- * resolution" (the gate's `--ref` flag / `main`) rather than a second,
80
- * possibly-conflicting default.
75
+ * Story #4981 / #5065 / #5173 the two independent full-suite economies.
81
76
  *
82
- * Story #5065 what `enabled: true` actually buys, measured: the capture is
83
- * **skipped entirely** when no changed file lives under `crap.targetDirs`,
84
- * and the CRAP join resolves methods in untouched files from the committed
85
- * baseline row instead of requiring fresh coverage. It does **not** shorten
86
- * the capture run — when a capture does happen it is the ordinary full
87
- * `npm run test:coverage`.
77
+ * `skipWhenUnchanged` decides *whether* to capture: no changed file under
78
+ * `crap.targetDirs` versus `baseRef` means no capture at all. It is on by
79
+ * default because it is gate-semantics-neutral the gates score exactly what
80
+ * they scored before, since nothing they score moved.
81
+ *
82
+ * `baselineJoin` lets the CRAP join resolve a method in an untouched file
83
+ * from its committed baseline row instead of requiring fresh coverage for it.
84
+ * That *loosens* the gate, so it stays off by default. Bundling the two under
85
+ * one `enabled` switch is precisely what forced the earlier default flip to
86
+ * be reverted (Story #5173).
87
+ *
88
+ * Neither switch shortens the capture run — a capture that does happen is the
89
+ * ordinary full `npm run test:coverage` (Story #5065). `baseRef: null` means
90
+ * "use the caller's own ref resolution" (the gate's `--ref` flag / `main`)
91
+ * rather than a second, possibly-conflicting default.
88
92
  */
89
93
  const DEFAULT_INCREMENTAL_COVERAGE = Object.freeze({
90
- enabled: false,
94
+ skipWhenUnchanged: true,
95
+ baselineJoin: false,
91
96
  baseRef: null,
92
97
  });
93
98
 
@@ -263,19 +268,29 @@ function resolveResolutionRate(value, fallback) {
263
268
  }
264
269
 
265
270
  /**
266
- * Resolve `gates.crap.incrementalCoverage` (Story #4981). A malformed or
267
- * absent user block resolves to the framework default (disabled), so a
268
- * consumer that never sets the key gets the exact pre-#4981 shape back.
271
+ * Resolve `gates.crap.incrementalCoverage` (Story #4981, split by #5173).
272
+ *
273
+ * Precedence, lowest to highest: the framework defaults
274
+ * (`skipWhenUnchanged: true`, `baselineJoin: false`), then the deprecated
275
+ * `enabled` alias which sets **both** switches to its value, then either
276
+ * explicit switch. A malformed or absent user block resolves to the defaults,
277
+ * so a consumer that never sets the key inherits the saving without the
278
+ * loosening.
269
279
  *
270
- * @param {{ enabled?: boolean, baseRef?: string } | undefined} user
271
- * @param {{ enabled: boolean, baseRef: string | null }} defaults
272
- * @returns {{ enabled: boolean, baseRef: string | null }}
280
+ * @param {{ skipWhenUnchanged?: boolean, baselineJoin?: boolean, enabled?: boolean, baseRef?: string } | undefined} user
281
+ * @param {{ skipWhenUnchanged: boolean, baselineJoin: boolean, baseRef: string | null }} defaults
282
+ * @returns {{ skipWhenUnchanged: boolean, baselineJoin: boolean, baseRef: string | null }}
273
283
  */
274
284
  function resolveIncrementalCoverage(user, defaults) {
275
285
  if (user == null || typeof user !== 'object') return { ...defaults };
286
+ const alias = typeof user.enabled === 'boolean' ? user.enabled : null;
287
+ const pick = (explicit, fallback) => {
288
+ if (typeof explicit === 'boolean') return explicit;
289
+ return alias === null ? fallback : alias;
290
+ };
276
291
  return {
277
- enabled:
278
- typeof user.enabled === 'boolean' ? user.enabled : defaults.enabled,
292
+ skipWhenUnchanged: pick(user.skipWhenUnchanged, defaults.skipWhenUnchanged),
293
+ baselineJoin: pick(user.baselineJoin, defaults.baselineJoin),
279
294
  baseRef:
280
295
  typeof user.baseRef === 'string' && user.baseRef.length > 0
281
296
  ? user.baseRef