mandrel 2.41.0 → 2.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +11 -2
- package/.agents/docs/configuration.md +9 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +37 -3
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +25 -0
- package/package.json +1 -1
|
@@ -117,28 +117,74 @@ function isCrapGateEnabled(config) {
|
|
|
117
117
|
*/
|
|
118
118
|
function buildTestGateEntry(coverageCaptureActive) {
|
|
119
119
|
if (coverageCaptureActive) return [];
|
|
120
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
* `
|
|
187
|
-
*
|
|
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({
|
|
191
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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([
|
|
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` —
|
|
5
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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 —
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* `
|
|
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
|
-
|
|
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
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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 {{
|
|
272
|
-
* @returns {{
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
@@ -39,6 +39,12 @@ const EXECUTION_SCHEMA = {
|
|
|
39
39
|
'Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs.',
|
|
40
40
|
default: LIMITS_DEFAULTS.executionTimeoutMs,
|
|
41
41
|
},
|
|
42
|
+
fullSuiteLock: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description:
|
|
45
|
+
'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.',
|
|
46
|
+
default: true,
|
|
47
|
+
},
|
|
42
48
|
},
|
|
43
49
|
additionalProperties: false,
|
|
44
50
|
};
|
|
@@ -456,7 +456,7 @@ const GITHUB_SCHEMA = {
|
|
|
456
456
|
const PLANNING_SCHEMA = {
|
|
457
457
|
type: 'object',
|
|
458
458
|
description:
|
|
459
|
-
'Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, and the cross-Story conflict-finding severity gates.',
|
|
459
|
+
'Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, the memory-hygiene advisory thresholds, and the cross-Story conflict-finding severity gates.',
|
|
460
460
|
properties: {
|
|
461
461
|
riskHeuristics: {
|
|
462
462
|
...LIST_OR_EXTENDER_OF_STRINGS,
|
|
@@ -498,6 +498,34 @@ const PLANNING_SCHEMA = {
|
|
|
498
498
|
},
|
|
499
499
|
additionalProperties: false,
|
|
500
500
|
},
|
|
501
|
+
// Story #5182 — the `/mandrel-plan` Phase 0 memory-hygiene advisory's two
|
|
502
|
+
// arms. The count arm this replaced was an absolute ceiling, which no
|
|
503
|
+
// consolidation pass could ever bring a pool back under; `growthDelta`
|
|
504
|
+
// measures entries written since the last pass instead, which a pass
|
|
505
|
+
// does reset. Both are advisory thresholds — nothing here gates a plan.
|
|
506
|
+
memoryPool: {
|
|
507
|
+
type: 'object',
|
|
508
|
+
description:
|
|
509
|
+
'Thresholds for the memory-hygiene advisory `/mandrel-plan` surfaces at Gate #1. Advisory only: it recommends `/memory-consolidate` and never gates, reroutes, or mutates the memory pool.',
|
|
510
|
+
properties: {
|
|
511
|
+
staleAfterDays: {
|
|
512
|
+
type: 'integer',
|
|
513
|
+
minimum: 1,
|
|
514
|
+
description:
|
|
515
|
+
"Recommend a consolidation pass once the pool's stamp is older than this many days. Default 30.",
|
|
516
|
+
default: 30,
|
|
517
|
+
},
|
|
518
|
+
growthDelta: {
|
|
519
|
+
type: 'integer',
|
|
520
|
+
minimum: 1,
|
|
521
|
+
description:
|
|
522
|
+
'Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25.',
|
|
523
|
+
default: 25,
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
additionalProperties: false,
|
|
527
|
+
},
|
|
528
|
+
|
|
501
529
|
// Cross-Story conflict-finding severity gates. Off by default so
|
|
502
530
|
// existing repos keep advisory-only behaviour; flipping either to
|
|
503
531
|
// `true` upgrades the matching finding class to `'hard'`, which routes
|
|
@@ -11,16 +11,22 @@
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Run the
|
|
15
|
-
* `delivery.quality.gates.crap.incrementalCoverage.
|
|
14
|
+
* Run the skip-aware capture path when
|
|
15
|
+
* `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is true
|
|
16
|
+
* (the default since Story #5173).
|
|
16
17
|
*
|
|
17
18
|
* **This does not shorten the capture run.** The changed-file set decides
|
|
18
19
|
* *whether* to capture, never *what* the capture executes: when nothing under
|
|
19
20
|
* `crap.targetDirs` changed there is no capture at all, and otherwise the
|
|
20
21
|
* ordinary full `npm run test:coverage` runs. The saving that makes the mode
|
|
21
|
-
* worth having is the skip
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* worth having is the skip.
|
|
23
|
+
*
|
|
24
|
+
* Gated by `skipWhenUnchanged` alone (Story #5173). It MUST NOT consult
|
|
25
|
+
* `baselineJoin`: that switch governs the CRAP join
|
|
26
|
+
* (`crap-baseline-join.js`), which resolves methods in untouched files from
|
|
27
|
+
* the committed baseline row instead of demanding fresh coverage for them —
|
|
28
|
+
* a gate loosening, where the skip is a pure saving. The two are defaulted
|
|
29
|
+
* differently for exactly that reason, so neither may read the other.
|
|
24
30
|
*
|
|
25
31
|
* Returns the process exit code when incremental mode handled the run
|
|
26
32
|
* (skip, capture, or a capture failure), or `null` when the caller should
|
|
@@ -54,7 +60,7 @@ export function tryIncrementalCapture({
|
|
|
54
60
|
writeCaptureStampImpl,
|
|
55
61
|
logger,
|
|
56
62
|
}) {
|
|
57
|
-
if (crap.incrementalCoverage?.
|
|
63
|
+
if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
|
|
58
64
|
|
|
59
65
|
const ref = crap.incrementalCoverage.baseRef || args.ref;
|
|
60
66
|
let changed = null;
|
|
@@ -176,14 +176,18 @@ export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* Baseline-join mode (Story #4981; gated by
|
|
180
|
+
* `incrementalCoverage.baselineJoin` since Story #5173): resolve a file's raw
|
|
181
|
+
* method rows against its committed CRAP-baseline rows instead of requiring
|
|
182
|
+
* fresh coverage, for a file the diff did NOT touch.
|
|
182
183
|
*
|
|
183
|
-
* Rationale:
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
184
|
+
* Rationale: when the capture was skipped because nothing under
|
|
185
|
+
* `crap.targetDirs` changed, the coverage artifact on disk is whatever the
|
|
186
|
+
* last run left — so an untouched file's coverage entry may legitimately be
|
|
187
|
+
* absent even though nothing about that file's methods changed. (The capture
|
|
188
|
+
* run itself is never narrowed: a capture that *does* happen is the ordinary
|
|
189
|
+
* full suite — Story #5065.) Requiring a fresh join for it would either
|
|
190
|
+
* (a) skip-and-count
|
|
187
191
|
* every one of its methods under `requireCoverage: true`, weakening the
|
|
188
192
|
* gate's signal for the vast majority of the tree on every run, or (b) score
|
|
189
193
|
* them at an invented 0% under `requireCoverage: false`, manufacturing a
|