mandrel 2.52.0 → 2.53.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.
@@ -101,17 +101,16 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
101
101
  --scope-id <storyId> --gate test --worktree <workCwd> -- npm test
102
102
  ```
103
103
 
104
- Dispatch it in the **background**: it routinely outruns the host's
105
- synchronous Bash ceiling, and its completion re-invokes you — that
106
- notification is the signal. Never spawn a task to poll or `sleep`-loop
104
+ Dispatch it in the **background**: it routinely outruns the host's sync
105
+ Bash ceiling, and its completion re-invokes you — that is the signal. Never spawn a task to poll or `sleep`-loop
107
106
  against it; a waiter whose condition is wrong outlives the agent. Share
108
107
  `lint` / `typecheck` evidence with close via `evidence-gate.js`; never
109
108
  stamp coverage / CRAP fresh any other way.
110
109
 
111
110
  **It can legitimately run nothing.** With nothing changed under the CRAP
112
111
  `targetDirs` it skips capture and exits 0. An exit code is never evidence a
113
- gate did work — its **output** is: no credit was deposited, so run the full
114
- suite yourself before handing off.
112
+ gate did work — its **output** is: no credit was deposited. Run the scoped
113
+ projects for the roots you changed plus `verify[]`, not the whole suite.
115
114
 
116
115
  Gate output that lies: [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md).
117
116
  Waiter traps: [`parallel-tooling.md`](../workflows/helpers/parallel-tooling.md) Rule 2.
@@ -16,6 +16,7 @@
16
16
  "commands": {
17
17
  "test": "npm test",
18
18
  "typecheck": null,
19
+ "lint": null,
19
20
  "formatCheck": "npx biome format .",
20
21
  "formatWrite": "npx biome format --write ."
21
22
  }
@@ -94,7 +95,8 @@
94
95
  "delivery": {
95
96
  "execution": {
96
97
  "timeoutMs": 600000,
97
- "fullSuiteLock": true
98
+ "fullSuiteLock": true,
99
+ "requireCreditedCapture": false
98
100
  },
99
101
  "docsFreshness": {
100
102
  "paths": ["README.md"]
@@ -87,6 +87,7 @@ Project identity, filesystem roots, planner docs context, and the commands the c
87
87
  | `commands` | No | `object` | — | Shell commands the close-validation chain spawns. Each is run from the repo root. |
88
88
  | `commands.test` | No | `string` | `"npm test"` | Full test-suite command run by the close-validation chain. |
89
89
  | `commands.typecheck` | No | `string` \| `null` | `null` | Static type-check command. `null` disables the gate for projects with no type layer; the empty string is rejected so a typo cannot silently disable it. |
90
+ | `commands.lint` | No | `string` \| `null` | `null` | Lint command run as a close-validation gate. `null` (the default) uses `npm run lint`; the gate is mandatory, so unlike `typecheck` this key cannot disable it. Point it at the scoped command your hooks already run to stop paying for a third whole-repo lint at close — the gate still lints the diff, and CI still owns whole-repo drift. Like every command here it must be a single argv (no `;`, `&&`, pipes or substitution), so wrap a multi-linter pair in one npm script and name that. |
90
91
  | `commands.formatCheck` | No | `string` | `"npx biome format ."` | Non-mutating format verification run as a close-validation gate. |
91
92
  | `commands.formatWrite` | No | `string` | `"npx biome format --write ."` | Mutating format command the close-time format-autofix step spawns. |
92
93
 
@@ -148,6 +149,7 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
148
149
  | `execution` | No | `object` | — | Wall-clock bounds on the subprocesses delivery spawns. |
149
150
  | `execution.timeoutMs` | No | `integer` | `600000` | Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs. |
150
151
  | `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. |
152
+ | `execution.requireCreditedCapture` | No | `boolean` | `false` | Refuse a full-suite coverage capture that no committed stamp covers, instead of paying for it. Default false — the run is announced (a warning naming the crediting invocation, emitted before the spawn) and then executed, which is the pre-existing behaviour. Set true when the whole-suite cost is large enough that a close should stop at zero seconds rather than absorb it silently. |
151
153
  | `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. |
152
154
  | `docsFreshness.paths` | No | `array<string>` | `["README.md"]` | Repo-relative documentation paths the audit-documentation lens adds to its target set. |
153
155
  | `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. |
@@ -94,6 +94,16 @@
94
94
  "description": "Static type-check command. `null` disables the gate for projects with no type layer; the empty string is rejected so a typo cannot silently disable it.",
95
95
  "default": null
96
96
  },
97
+ "lint": {
98
+ "type": ["string", "null"],
99
+ "minLength": 1,
100
+ "not": {
101
+ "type": "string",
102
+ "pattern": "([;&|`]|\\$\\()"
103
+ },
104
+ "description": "Lint command run as a close-validation gate. `null` (the default) uses `npm run lint`; the gate is mandatory, so unlike `typecheck` this key cannot disable it. Point it at the scoped command your hooks already run to stop paying for a third whole-repo lint at close — the gate still lints the diff, and CI still owns whole-repo drift. Like every command here it must be a single argv (no `;`, `&&`, pipes or substitution), so wrap a multi-linter pair in one npm script and name that.",
105
+ "default": null
106
+ },
97
107
  "formatCheck": {
98
108
  "type": "string",
99
109
  "not": {
@@ -475,6 +485,11 @@
475
485
  "type": "boolean",
476
486
  "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.",
477
487
  "default": true
488
+ },
489
+ "requireCreditedCapture": {
490
+ "type": "boolean",
491
+ "description": "Refuse a full-suite coverage capture that no committed stamp covers, instead of paying for it. Default false — the run is announced (a warning naming the crediting invocation, emitted before the spawn) and then executed, which is the pre-existing behaviour. Set true when the whole-suite cost is large enough that a close should stop at zero seconds rather than absorb it silently.",
492
+ "default": false
478
493
  }
479
494
  },
480
495
  "additionalProperties": false
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI: attribute a red `npm audit` result to the diff or to the merge base.
4
+ *
5
+ * Runs from `ci.yml` immediately after the required "Dependency Vulnerability
6
+ * Audit (SCA)" step fails, and only on a pull request. It re-audits the merge
7
+ * base's committed lockfile and says, as the first line a reader sees,
8
+ * whether this branch caused the failure.
9
+ *
10
+ * The verdict never changes whether the branch may land — see
11
+ * `attributionExitCode` in `lib/audit-attribution.js`. It changes how long it
12
+ * takes to understand why it may not.
13
+ *
14
+ * Exit codes:
15
+ * 0 — the probe could not reach a verdict (`unknown`); the SCA step's own
16
+ * failure stands unmodified.
17
+ * 1 — a verdict was reached: `pre-existing` or `introduced-by-this-diff`.
18
+ * Both fail, because the advisory is real either way.
19
+ */
20
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import path from 'node:path';
23
+ import process from 'node:process';
24
+
25
+ import {
26
+ attributionExitCode,
27
+ deriveVerdict,
28
+ renderAttribution,
29
+ UNKNOWN,
30
+ } from './lib/audit-attribution.js';
31
+ import { execFileCapture, spawnCapture } from './lib/child-exec.js';
32
+ import { runAsCli } from './lib/cli-utils.js';
33
+ import { Logger } from './lib/Logger.js';
34
+
35
+ /** The label the nightly sweep keys its reusable tracking issue on. */
36
+ const TRACKING_LABEL = 'meta::dependency-advisory';
37
+
38
+ const HELP = {
39
+ invocation:
40
+ 'node .agents/scripts/check-audit-attribution.js --base <ref> [--cwd <dir>]',
41
+ summary:
42
+ 'Say whether a red high-severity npm advisory came from this pull request or was already on its merge base.',
43
+ flags: [
44
+ ['--base <ref>', 'Base commit or ref to attribute against. Required.'],
45
+ ['--cwd <dir>', 'Repository root. Default: process.cwd().'],
46
+ ],
47
+ notes: [
48
+ 'Run it only after the required SCA step has already failed — it re-audits\nthe base to attribute that failure, and reports `unknown` when the head\naudits clean.',
49
+ "Exit codes:\n 0 unknown — the probe degraded; the SCA step's own failure stands\n 1 a verdict was reached (pre-existing or introduced-by-this-diff)",
50
+ 'Both real verdicts exit 1. A check that passed on `pre-existing` would let\nadvisories accumulate on `main` unnoticed, which is what the nightly sweep\n(.github/workflows/dependency-audit-cron.yml) exists to prevent.',
51
+ ],
52
+ };
53
+
54
+ export function parseArgs(argv) {
55
+ const out = { base: null, cwd: process.cwd() };
56
+ for (let i = 2; i < argv.length; i += 1) {
57
+ const a = argv[i];
58
+ if (a === '--base') out.base = argv[++i] ?? null;
59
+ else if (a === '--cwd') out.cwd = argv[++i] ?? out.cwd;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Run `npm audit --audit-level=high` over a dependency manifest pair.
66
+ *
67
+ * `--package-lock-only` audits the committed lockfile without installing, so
68
+ * the probe never touches the job's own `node_modules`: an attribution
69
+ * mechanism that could disturb the tree it is reporting on would be a worse
70
+ * defect than the one it explains.
71
+ *
72
+ * @param {string} dir directory holding package.json + package-lock.json
73
+ * @returns {{ failed: boolean }}
74
+ */
75
+ function auditDir(dir) {
76
+ const result = spawnCapture(
77
+ 'npm',
78
+ ['audit', '--audit-level=high', '--package-lock-only'],
79
+ { cwd: dir },
80
+ );
81
+ if (result.status === 0) return { failed: false };
82
+ // npm exits non-zero for "advisories found" and for "could not audit"
83
+ // alike. Only a real audit verdict carries a report on stdout; anything
84
+ // else is a probe failure the caller must read as `unknown`.
85
+ if (/vulnerabilit/i.test(String(result.stdout ?? '')))
86
+ return { failed: true };
87
+ throw new Error(
88
+ `npm audit could not evaluate the base tree: ${String(result.stderr ?? '').slice(0, 200)}`,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Materialize the base commit's manifest pair into a scratch directory.
94
+ *
95
+ * Read out of git rather than from a checkout — the job's working tree is the
96
+ * head, and swapping files in it to run a probe is exactly the kind of side
97
+ * effect this must not have.
98
+ *
99
+ * @returns {string} the scratch directory (caller removes it)
100
+ */
101
+ function materializeBase({ cwd, base, git }) {
102
+ const dir = mkdtempSync(path.join(tmpdir(), 'audit-attribution-'));
103
+ for (const file of ['package.json', 'package-lock.json']) {
104
+ writeFileSync(path.join(dir, file), git(cwd, 'show', `${base}:${file}`));
105
+ }
106
+ return dir;
107
+ }
108
+
109
+ // `execFileCapture` owns the stdout ceiling, `shell: false` and error
110
+ // normalisation for the whole tree — a lockfile is large enough that a
111
+ // re-forked local ceiling would be a real defect, not a style point.
112
+ const defaultGit = (cwd, ...args) => execFileCapture('git', args, { cwd });
113
+
114
+ /**
115
+ * The decision core, with every I/O collaborator injectable
116
+ * (`.agents/rules/test-seams.md` rules 1-2, 4) so the whole verdict table is
117
+ * reachable without a git history, a network, or an npm spawn.
118
+ *
119
+ * @returns {{ verdict: string, exitCode: number, lines: string[] }}
120
+ */
121
+ export function runAttribution(argv = process.argv, deps = {}) {
122
+ const {
123
+ git = defaultGit,
124
+ auditHead = auditDir,
125
+ auditBase = auditDir,
126
+ materialize = materializeBase,
127
+ lookupTrackingIssue = defaultLookupTrackingIssue,
128
+ cleanup = (dir) => rmSync(dir, { recursive: true, force: true }),
129
+ logger = Logger,
130
+ } = deps;
131
+ const args = parseArgs(argv);
132
+
133
+ if (!args.base) {
134
+ const lines = renderAttribution({
135
+ verdict: UNKNOWN,
136
+ reason: 'no --base ref was supplied',
137
+ });
138
+ for (const line of lines) logger.info(line);
139
+ return { verdict: UNKNOWN, exitCode: 0, lines };
140
+ }
141
+
142
+ let headFailed;
143
+ try {
144
+ headFailed = auditHead(args.cwd).failed;
145
+ } catch (err) {
146
+ return report({ verdict: UNKNOWN, args, logger, reason: msg(err) });
147
+ }
148
+ if (!headFailed) {
149
+ return report({
150
+ verdict: UNKNOWN,
151
+ args,
152
+ logger,
153
+ reason: 'the head tree audits clean, so there is nothing to attribute',
154
+ });
155
+ }
156
+
157
+ let dir = null;
158
+ let baseAudit = null;
159
+ let reason = null;
160
+ try {
161
+ dir = materialize({ cwd: args.cwd, base: args.base, git });
162
+ baseAudit = auditBase(dir);
163
+ } catch (err) {
164
+ reason = msg(err);
165
+ } finally {
166
+ if (dir) {
167
+ try {
168
+ cleanup(dir);
169
+ } catch {
170
+ // A leaked scratch dir under the OS temp root is not worth failing a
171
+ // report over.
172
+ }
173
+ }
174
+ }
175
+
176
+ const verdict = deriveVerdict({ headFailed, baseAudit });
177
+ const trackingIssue =
178
+ verdict === UNKNOWN ? null : safeLookup(lookupTrackingIssue, args.cwd);
179
+ return report({ verdict, args, logger, reason, trackingIssue });
180
+ }
181
+
182
+ function msg(err) {
183
+ return String(err?.message ?? err).slice(0, 200);
184
+ }
185
+
186
+ function report({
187
+ verdict,
188
+ args,
189
+ logger,
190
+ reason = null,
191
+ trackingIssue = null,
192
+ }) {
193
+ const lines = renderAttribution({
194
+ verdict,
195
+ baseRef: args.base,
196
+ trackingIssue,
197
+ reason,
198
+ });
199
+ for (const line of lines) logger.info(line);
200
+ return { verdict, exitCode: attributionExitCode(verdict), lines };
201
+ }
202
+
203
+ /**
204
+ * Look up the open tracking issue by LABEL, not a title search: GitHub's
205
+ * issue search index lags, and the nightly sweep keys the same issue the same
206
+ * way for the same reason.
207
+ */
208
+ function defaultLookupTrackingIssue(cwd) {
209
+ const out = execFileCapture(
210
+ 'gh',
211
+ [
212
+ 'issue',
213
+ 'list',
214
+ '--label',
215
+ TRACKING_LABEL,
216
+ '--state',
217
+ 'open',
218
+ '--limit',
219
+ '1',
220
+ '--json',
221
+ 'number',
222
+ '--jq',
223
+ '.[0].number // empty',
224
+ ],
225
+ { cwd },
226
+ ).trim();
227
+ return out ? Number(out) : null;
228
+ }
229
+
230
+ function safeLookup(lookup, cwd) {
231
+ try {
232
+ const n = lookup(cwd);
233
+ return Number.isInteger(n) ? n : null;
234
+ } catch {
235
+ // No token, no network, no `gh` — the verdict is still worth printing.
236
+ return null;
237
+ }
238
+ }
239
+
240
+ runAsCli(import.meta.url, async () => runAttribution().exitCode, {
241
+ source: 'audit-attribution',
242
+ propagateExitCode: true,
243
+ errorPrefix: '[audit-attribution] ❌ Fatal error',
244
+ usage: HELP,
245
+ });
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI: assert `package.json`'s pinned-override safety notes still describe
4
+ * the pins they document.
5
+ *
6
+ * The `"//"` notes on a pinned `overrides` entry are consulted at exactly one
7
+ * moment — when someone is deciding whether bumping it is safe. A note that
8
+ * quotes a version the pin has since moved past answers that question wrong,
9
+ * and nothing was checking. This gate makes the note unable to fall behind.
10
+ *
11
+ * Exit codes:
12
+ * 0 — every documented override matches its note and its direct range.
13
+ * 1 — drift: a stale note, a broken lockstep, or a note for a pin that is
14
+ * gone.
15
+ * 2 — the check could not run (no readable `package.json`).
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+
21
+ import { runAsCli } from './lib/cli-utils.js';
22
+ import { Logger } from './lib/Logger.js';
23
+ import { auditPinnedOverrideNotes } from './lib/pinned-override-notes.js';
24
+
25
+ const EXIT_PASS = 0;
26
+ const EXIT_DRIFT = 1;
27
+ const EXIT_CANNOT_RUN = 2;
28
+
29
+ const HELP = {
30
+ invocation:
31
+ 'node .agents/scripts/check-pinned-override-notes.js [--cwd <dir>] [--json]',
32
+ summary:
33
+ 'Assert every "//" note documenting a pinned npm override states the range that override actually carries, and that its direct dependency range matches.',
34
+ flags: [
35
+ ['--cwd <dir>', 'Repository root to check. Default: process.cwd().'],
36
+ ['--json', 'Emit the findings as JSON instead of text.'],
37
+ ],
38
+ notes: [
39
+ 'Checks are derived from the "//" keys themselves — any `overrides.<name>`\nnote is covered, so a second pinned override is guarded by writing its note.',
40
+ 'A note quoting no semver range at all is not scored: prose that names no\nversion cannot go stale.',
41
+ 'Exit codes:\n 0 notes match their pins\n 1 drift (stale note, broken lockstep, or a note for a removed pin)\n 2 the check could not run',
42
+ ],
43
+ };
44
+
45
+ export function parseArgs(argv) {
46
+ const out = { cwd: process.cwd(), json: false };
47
+ for (let i = 2; i < argv.length; i += 1) {
48
+ if (argv[i] === '--cwd') out.cwd = argv[++i] ?? out.cwd;
49
+ else if (argv[i] === '--json') out.json = true;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * @param {string[]} [argv]
56
+ * @param {{ readPackage?: (cwd: string) => object, logger?: object }} [deps]
57
+ * @returns {number} process exit code
58
+ */
59
+ export function runCheck(argv = process.argv, deps = {}) {
60
+ const { readPackage = defaultReadPackage, logger = Logger } = deps;
61
+ const args = parseArgs(argv);
62
+
63
+ let pkg;
64
+ try {
65
+ pkg = readPackage(args.cwd);
66
+ } catch (err) {
67
+ logger.error(
68
+ `[pinned-override-notes] ✖ could not read package.json: ${String(err?.message ?? err)}`,
69
+ );
70
+ return EXIT_CANNOT_RUN;
71
+ }
72
+
73
+ const report = auditPinnedOverrideNotes(pkg);
74
+ if (args.json) {
75
+ logger.info(JSON.stringify(report, null, 2));
76
+ return report.findings.length > 0 ? EXIT_DRIFT : EXIT_PASS;
77
+ }
78
+
79
+ if (report.findings.length === 0) {
80
+ logger.info(
81
+ `[pinned-override-notes] ✅ ${report.checked.length} documented override pin(s) match their notes.`,
82
+ );
83
+ return EXIT_PASS;
84
+ }
85
+ for (const finding of report.findings) {
86
+ logger.error(
87
+ `[pinned-override-notes] ✖ ${finding.kind}: ${finding.detail}`,
88
+ );
89
+ }
90
+ return EXIT_DRIFT;
91
+ }
92
+
93
+ function defaultReadPackage(cwd) {
94
+ return JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
95
+ }
96
+
97
+ runAsCli(import.meta.url, async () => runCheck(), {
98
+ source: 'pinned-override-notes',
99
+ propagateExitCode: true,
100
+ errorPrefix: '[pinned-override-notes] ❌ Fatal error',
101
+ usage: HELP,
102
+ });
@@ -12,10 +12,13 @@
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` — 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.
15
+ * 4. Otherwise announce the uncredited full-suite run — naming the
16
+ * invocation that would have deposited credit — and then spawn
17
+ * `npm run test:coverage`, serialized behind the host-level full-suite
18
+ * lock (Story #5173) so two concurrent runs on one checkout do not race;
19
+ * write a fresh capture stamp on success and propagate the exit code.
20
+ * With `delivery.execution.requireCreditedCapture` set, step 4 refuses
21
+ * instead of spawning, so the cost is never paid unannounced.
19
22
  *
20
23
  * Step 3 is preceded by the changed-file skip when
21
24
  * `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is on
@@ -24,15 +27,18 @@
24
27
  *
25
28
  * Exit codes:
26
29
  * 0 — coverage is fresh (or capture skipped/succeeded).
27
- * 1 — capture run failed (broken tests or coverage-threshold breach). The
28
- * caller MUST surface this — silently passing here would defeat the
29
- * CRAP gate's `requireCoverage: true` policy.
30
+ * 1 — capture run failed (broken tests or coverage-threshold breach), or
31
+ * the run was refused because it carried no credit and
32
+ * `delivery.execution.requireCreditedCapture` is set. The caller MUST
33
+ * surface this — silently passing here would defeat the CRAP gate's
34
+ * `requireCoverage: true` policy.
30
35
  */
31
36
  import { getChangedFiles } from './lib/changed-files.js';
32
37
  import { isDirectInvocation } from './lib/cli-utils.js';
33
38
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
34
39
  import {
35
40
  computeContentDigest,
41
+ creditedCapture,
36
42
  filterFilesUnderTargets,
37
43
  isCoverageFresh,
38
44
  runCapture,
@@ -110,6 +116,11 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
110
116
  const args = parseArgs(argv);
111
117
  const config = resolveConfigImpl({ cwd: args.cwd });
112
118
  const { crap, coverage } = getQualityImpl(config);
119
+ // Read once here, where the config is already in scope, and thread it into
120
+ // whichever capture path reaches a spawn. Default false — an unconfigured
121
+ // consumer gets the announcement and the run, exactly as before.
122
+ const requireCreditedCapture =
123
+ config?.delivery?.execution?.requireCreditedCapture === true;
113
124
 
114
125
  if (crap.enabled === false) {
115
126
  logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
@@ -138,38 +149,42 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
138
149
  // knowing about it. `delivery.execution.fullSuiteLock: false` and
139
150
  // `MANDREL_FULL_SUITE_LOCK=0` each disable it; both hatches live in
140
151
  // `isFullSuiteLockEnabled`.
141
- const capture = lockedCapture(runCaptureImpl, config);
152
+ // Two wrappers, composed outermost-first: the credit probe announces (or
153
+ // refuses) the run, and only a run that survives it reaches the host lock.
154
+ // Whichever capture path gets here spawns through both without knowing
155
+ // about either.
156
+ const capture = creditedCapture(lockedCapture(runCaptureImpl, config), {
157
+ requireCredited: requireCreditedCapture,
158
+ logger,
159
+ });
142
160
 
143
161
  // Story #4981/#5173 — the capture skip, gated by
144
162
  // `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` (on
145
163
  // by default). `null` means "not applicable" (switched off, or a
146
164
  // ref-resolution error) — fall through to the full-scope path below rather
147
165
  // than silently skipping capture.
148
- const incrementalResult = tryIncrementalCapture({
166
+ // The two capture paths take the same collaborators bar one; naming that
167
+ // set once keeps a new seam from being threaded into one and forgotten on
168
+ // the other.
169
+ const shared = {
149
170
  crap,
150
171
  coverage,
151
172
  args,
152
173
  getChangedFilesImpl,
153
- filterFilesUnderTargetsImpl,
154
174
  isCoverageFreshImpl,
155
175
  runCaptureImpl: capture,
156
176
  computeContentDigestImpl,
157
177
  writeCaptureStampImpl,
158
178
  logger,
179
+ };
180
+
181
+ const incrementalResult = tryIncrementalCapture({
182
+ ...shared,
183
+ filterFilesUnderTargetsImpl,
159
184
  });
160
185
  if (incrementalResult !== null) return incrementalResult;
161
186
 
162
- return runFullScopeCapture({
163
- crap,
164
- coverage,
165
- args,
166
- getChangedFilesImpl,
167
- isCoverageFreshImpl,
168
- runCaptureImpl: capture,
169
- computeContentDigestImpl,
170
- writeCaptureStampImpl,
171
- logger,
172
- });
187
+ return runFullScopeCapture(shared);
173
188
  }
174
189
 
175
190
  // cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * audit-attribution.js — decide whose defect a red advisory check is.
3
+ *
4
+ * `npm audit` reports the advisories present in a dependency tree; it says
5
+ * nothing about who introduced them. Advisories are published against
6
+ * packages that are ALREADY installed, so `main` goes red between pull
7
+ * requests without anybody's diff causing it, and the first PR opened
8
+ * afterwards becomes the discovery mechanism — its author debugging a
9
+ * supply-chain advisory from inside a failing log that has nothing to do
10
+ * with their change.
11
+ *
12
+ * The nightly sweep (`.github/workflows/dependency-audit-cron.yml`) shrinks
13
+ * how often that happens; it cannot make the failure legible when it does.
14
+ * An advisory published after one night's sweep and before the next lands on
15
+ * whoever opens a PR in between. This module supplies the missing sentence:
16
+ * the same audit, evaluated against the pull request's merge base.
17
+ *
18
+ * It buys legibility, never permission — see `attributionExitCode`.
19
+ */
20
+
21
+ // The verdict vocabulary. Only `UNKNOWN` is exported: the CLI branches on it
22
+ // to decide whether to audit the base at all, while the other two are read by
23
+ // callers out of the rendered report rather than compared as symbols. Their
24
+ // STRING values are the contract — they appear verbatim in the CI log — so
25
+ // the tests assert those literals rather than re-importing the constants,
26
+ // which would let a rename silently change what an operator reads.
27
+ /** The pull request's own diff introduced the advisory. */
28
+ const INTRODUCED = 'introduced-by-this-diff';
29
+ /** The merge base carries it too; the diff is innocent. */
30
+ const PRE_EXISTING = 'pre-existing';
31
+ /** The probe could not reach a verdict. Never an accusation. */
32
+ export const UNKNOWN = 'unknown';
33
+
34
+ /**
35
+ * Decide the verdict from the two audit outcomes.
36
+ *
37
+ * Pure — the caller owns running the audits. `baseAudit` is `null` when the
38
+ * base could not be audited at all, which is the `unknown` path: an
39
+ * attribution mechanism must never guess, because the guess it would make
40
+ * (`introduced-by-this-diff`) is an accusation against the author reading it.
41
+ *
42
+ * @param {{ headFailed: boolean, baseAudit: { failed: boolean } | null }} input
43
+ * @returns {string} one of INTRODUCED / PRE_EXISTING / UNKNOWN
44
+ */
45
+ export function deriveVerdict({ headFailed, baseAudit }) {
46
+ if (!headFailed) return UNKNOWN;
47
+ if (!baseAudit || typeof baseAudit.failed !== 'boolean') return UNKNOWN;
48
+ return baseAudit.failed ? PRE_EXISTING : INTRODUCED;
49
+ }
50
+
51
+ /**
52
+ * The exit code a verdict earns.
53
+ *
54
+ * **Both real verdicts fail.** The advisory is genuine either way: a check
55
+ * that passed on `pre-existing` would let advisories accumulate on `main`
56
+ * unnoticed, which is the exact failure the nightly sweep exists to prevent.
57
+ * Attribution changes what the log says, never whether the branch may land.
58
+ *
59
+ * `unknown` exits 0 — the probe degraded, and the required `npm audit` step
60
+ * has already failed on its own account. A reporter that can turn a red into
61
+ * a second, unrelated red is worse than no reporter.
62
+ *
63
+ * @param {string} verdict
64
+ * @returns {number}
65
+ */
66
+ export function attributionExitCode(verdict) {
67
+ return verdict === UNKNOWN ? 0 : 1;
68
+ }
69
+
70
+ /**
71
+ * Render the operator-facing report for a verdict.
72
+ *
73
+ * `trackingIssue` is the open `meta::dependency-advisory` issue number when
74
+ * the nightly sweep has already filed one, or `null`. Naming it is the point
75
+ * of the `pre-existing` branch: it turns "why is my unrelated PR red" into a
76
+ * link to the issue that already owns the fix.
77
+ *
78
+ * @param {{ verdict: string, baseRef?: string, trackingIssue?: number|null, reason?: string|null }} input
79
+ * @returns {string[]} lines, in emission order
80
+ */
81
+ export function renderAttribution({
82
+ verdict,
83
+ baseRef = null,
84
+ trackingIssue = null,
85
+ reason = null,
86
+ }) {
87
+ const at = baseRef ? ` (merge base ${baseRef})` : '';
88
+ if (verdict === PRE_EXISTING) {
89
+ const lines = [
90
+ `::warning::Advisory attribution: PRE-EXISTING${at}. The same high-severity advisory is already present on the merge base, so this pull request's diff did not introduce it.`,
91
+ 'This check still fails, and deliberately: the advisory is real, and letting it pass here would let advisories accumulate on `main` unnoticed.',
92
+ "Fix it as a standalone `fix(deps)` PR against `main` rather than spending this Story's scope on it. Check the `overrides` block in package.json first — a pinned floor one patch below the patched release is the usual cause.",
93
+ ];
94
+ if (trackingIssue != null) {
95
+ lines.splice(
96
+ 1,
97
+ 0,
98
+ `The nightly sweep already filed the tracking issue: #${trackingIssue}.`,
99
+ );
100
+ }
101
+ return lines;
102
+ }
103
+ if (verdict === INTRODUCED) {
104
+ return [
105
+ `::error::Advisory attribution: INTRODUCED BY THIS DIFF${at}. The merge base audits clean at the high-severity threshold, so a dependency change on this branch brought the advisory in.`,
106
+ "Read the advisory's own version range before accepting a fix: `npm audit fix --force` often proposes a needless semver-major when the parent package's declared range already admits a patched version.",
107
+ ];
108
+ }
109
+ return [
110
+ `::warning::Advisory attribution: UNKNOWN${at}${reason ? ` — ${reason}` : ''}. The audit result above stands on its own; this probe adds nothing to it.`,
111
+ ];
112
+ }