mandrel 2.34.0 → 2.36.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 (37) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/docs/quality-gates.md +30 -0
  4. package/.agents/docs/workflows.md +2 -1
  5. package/.agents/schemas/agentrc.schema.json +11 -0
  6. package/.agents/schemas/audit-rules.json +44 -0
  7. package/.agents/schemas/audit-rules.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/git-cleanup.js +2 -0
  12. package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
  13. package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
  14. package/.agents/scripts/lib/config/ci.js +18 -0
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  16. package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
  17. package/.agents/scripts/lib/coverage-capture.js +96 -26
  18. package/.agents/scripts/lib/findings/route-finding.js +98 -35
  19. package/.agents/scripts/lib/maintainability-utils.js +6 -14
  20. package/.agents/scripts/lib/observability/source-classifier.js +0 -1
  21. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  22. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  23. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  24. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  25. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  26. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  27. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  28. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  29. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  30. package/.agents/scripts/lib/source-extensions.js +76 -0
  31. package/.agents/scripts/notify.js +4 -10
  32. package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
  33. package/.agents/workflows/audit-adrs.md +270 -0
  34. package/.agents/workflows/audit-documentation.md +22 -6
  35. package/docs/CHANGELOG.md +33 -0
  36. package/package.json +3 -3
  37. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
@@ -340,7 +340,9 @@
340
340
  "maxResumes": 3,
341
341
  "attachWindowMs": 1200000
342
342
  },
343
- "autoMerge": "trust-ci"
343
+ "autoMerge": "trust-ci",
344
+ "blockOnAdvisoryFailure": true,
345
+ "advisoryAllowlist": []
344
346
  },
345
347
  "routing": {
346
348
  "roleScopedAgents": true,
@@ -320,6 +320,8 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
320
320
  | `ci.watch.maxResumes` | No | `integer` | `3` | How many times the watcher may resume after a transient stall. 0 disables resuming. |
321
321
  | `ci.watch.attachWindowMs` | No | `integer` | `1200000` | Story #4890. How long (ms) the watch keeps re-resolving an EMPTY `gh pr checks --required` set before it stops waiting for a required context to attach. A ruleset attaches its contexts asynchronously and the arrival latency is set by the slowest one, so a required context that is an aggregator job gated on every other tier is the last to appear — measured at 16m52s on this repository. Default 1200000 (20 minutes). Raise it for a repository whose contexts arrive later still; exhausting the window is never reported as a red check (the watch exits 2, not-yet-started). |
322
322
  | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | `"trust-ci"` | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
323
+ | `ci.blockOnAdvisoryFailure` | No | `boolean` | `true` | Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim. |
324
+ | `ci.advisoryAllowlist` | No | `array<string>` | `[]` | Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks. |
323
325
  | `routing` | No | `object` | — | v2 delivery-spawn routing: role-scoped boot contexts and maker-checker sampling. The v1 singleDelivery epic-route kill-switch was removed in Stage 6. |
324
326
  | `routing.roleScopedAgents` | No | `boolean` | `true` | Epic #4478 (M7-B). Kill-switch for the role-scoped boot contexts. When true (default), a converted delivery spawn (`story-worker`, `acceptance-critic`) boots on its own `.claude/agents/<role>.md` system prompt instead of re-paying the full CLAUDE.md @-import closure. When false, every converted spawn falls back to `subagent_type: general-purpose` — the instant, code-rollback-free per-consumer revert, and the universal escape for hosts that ignore `.claude/agents/`. The fallback is the full-closure agent that ran before M7-B, so flipping it off never drops a gate. |
325
327
  | `routing.freshCriticSampleRate` | No | `number` | `0.2` | Epic #4478 (M7-B, Part 2). Maker-checker sampling floor. Under the standard profile, a change set touching no sensitive path routes its acceptance clusters down the contract-identical inline critic path, but this fraction of them is still forced through a fresh-context critic so a low derived level never means zero independent checking. Clamped to [0, 1]; 0 disables the floor, 1 forces every cluster fresh. Consumed by resolveCeremonyForRisk (lib/orchestration/ceremony-routing.js). |
@@ -424,6 +424,36 @@ refresh-guardrail accepts it on the next PR.
424
424
  If your test runner doesn't produce per-method coverage, see "Disabling the
425
425
  gate" below.
426
426
 
427
+ ### Coverage freshness — what triggers a capture
428
+
429
+ The CRAP scorer treats "no coverage" as "skip the method", so a missing or
430
+ stale `coverage/coverage-final.json` silently weakens the gate.
431
+ `coverage-capture.js` closes that hole by capturing coverage in-band, and
432
+ decides whether it needs to by two rules (Story #5076):
433
+
434
+ - **The source set is derived, not configured.** Freshness is measured over
435
+ exactly the extensions the CRAP scanner walks — `.js`, `.mjs`, `.cjs`,
436
+ `.ts`, `.tsx`, `.mts`, `.cts` — defined once in
437
+ `.agents/scripts/lib/source-extensions.js`. There is deliberately no
438
+ `.agentrc.json` key for this: a consumer-settable list would be a second
439
+ way to mis-scope the same gate. Formats the engines cannot parse
440
+ (`.astro`, `.vue`, `.svelte`) are not part of it — a project written in
441
+ those still has its `.ts`/`.tsx` measured.
442
+ - **Both freshness paths fail closed on an empty source set.** Finding no
443
+ scorable source under `crap.targetDirs` means the check learned nothing,
444
+ so it captures rather than assuming coverage is current, and warns naming
445
+ the configured dirs. If you see that warning, `targetDirs` almost
446
+ certainly does not point at your sources — fix it rather than living with
447
+ a full capture on every run.
448
+
449
+ **Upgrading from a version before this fix:** a TypeScript project's sources
450
+ matched neither path, so the capture was skipped on every run and
451
+ `crap:check` compared the committed baseline against itself. The first run
452
+ after upgrading captures for real and measures your committed floors for the
453
+ first time, which may surface breaches that were always there. That is a
454
+ one-off re-baseline (`npm run crap:update`, committed with a
455
+ `baseline-refresh:` subject), not a regression.
456
+
427
457
  ### Disabling the gate (single-flag opt-out)
428
458
 
429
459
  If your repo doesn't run coverage, set `enabled: false` in your
@@ -32,11 +32,12 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
32
32
  fails when it drifts from the on-disk workflow set. To change a command’s
33
33
  description, edit the workflow file’s front-matter and regenerate.
34
34
 
35
- ## Commands (27)
35
+ ## Commands (28)
36
36
 
37
37
  | Command | Description |
38
38
  | --- | --- |
39
39
  | `/audit-accessibility` | Audit WCAG accessibility conformance (static-first) with an optional runtime verification pass, and produce a structured findings report |
40
+ | `/audit-adrs` | Audit the decisions log as a live contract — Accepted ADRs whose claims the tree has outgrown, broken supersede chains, structural gaps, and directional changes that landed with no decision recorded. A deliberately-global lens over whichever decisions-log layout the consumer ships. |
40
41
  | `/audit-architecture` | Audit architectural boundaries, module coupling, layering violations, and shipped-but-uncalled seams; emit a structured findings report keyed to the canonical severity scale. |
41
42
  | `/audit-baselines` | Audit the committed baseline surface — dead instruments, stale baselines, cross-gate hotspot clusters, trend drift, and floor-tightening headroom — and emit findings whose remediation burns the measured debt down and tightens the ratchet behind it. |
42
43
  | `/audit-clean-code` | Audit code smells, dead code, complexity hotspots, and maintainability-index outliers; emit a structured findings report. |
@@ -1913,6 +1913,17 @@
1913
1913
  "enum": ["trust-ci", "strict"],
1914
1914
  "description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
1915
1915
  "default": "trust-ci"
1916
+ },
1917
+ "blockOnAdvisoryFailure": {
1918
+ "type": "boolean",
1919
+ "description": "Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.",
1920
+ "default": true
1921
+ },
1922
+ "advisoryAllowlist": {
1923
+ "type": "array",
1924
+ "items": { "type": "string" },
1925
+ "description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
1926
+ "default": []
1916
1927
  }
1917
1928
  },
1918
1929
  "additionalProperties": false
@@ -312,6 +312,23 @@
312
312
  "target": "web",
313
313
  "scope": "global",
314
314
  "substitutionKeys": []
315
+ },
316
+ "audit-adrs": {
317
+ "triggers": {
318
+ "gates": ["gate1", "gate3"],
319
+ "keywords": [
320
+ "adr",
321
+ "adrs",
322
+ "decision record",
323
+ "architecture decision",
324
+ "decisions log",
325
+ "supersede",
326
+ "superseded"
327
+ ],
328
+ "filePatterns": ["**/decisions.md", "**/decisions/**"]
329
+ },
330
+ "scope": "global",
331
+ "substitutionKeys": []
315
332
  }
316
333
  },
317
334
  "sensitivePaths": {
@@ -363,6 +380,33 @@
363
380
  "**/openapi/**",
364
381
  "**/graphql/**"
365
382
  ]
383
+ },
384
+ "deploy-runtime": {
385
+ "description": "Deployment and runtime topology: the CI/CD pipeline, infrastructure-as-code, container images, and the server/serverless entrypoint with its platform and build config. A change here alters how the product is built, bound, and run in production without necessarily touching application logic. Build configs are enumerated by name rather than matched with a blanket `**/*.config.*` glob, which would swallow the test and lint configs and flip nearly every change set to high.",
386
+ "filePatterns": [
387
+ ".github/workflows/**",
388
+ ".github/actions/**",
389
+ "**/Dockerfile",
390
+ "**/docker-compose*.yml",
391
+ "**/docker-compose*.yaml",
392
+ "infra/**",
393
+ "**/*.tf",
394
+ "**/*.tfvars",
395
+ "**/wrangler.json",
396
+ "**/wrangler.jsonc",
397
+ "**/wrangler.toml",
398
+ "**/worker-entry.ts",
399
+ "**/worker-entry.js",
400
+ "**/astro.config.*",
401
+ "**/next.config.*",
402
+ "**/nuxt.config.*",
403
+ "**/svelte.config.*",
404
+ "**/fly.toml",
405
+ "**/vercel.json",
406
+ "**/netlify.toml",
407
+ "**/serverless.yml",
408
+ "**/serverless.yaml"
409
+ ]
366
410
  }
367
411
  }
368
412
  }
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "sensitivePaths": {
24
24
  "type": "object",
25
- "description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
25
+ "description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. Those lens triggers are a SEPARATE block with a separate job — they select which lens runs, never the change level — so a path registered there is not sensitive here unless it is also registered in this block. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
26
26
  "patternProperties": {
27
27
  "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/sensitivePathClass" }
28
28
  },
@@ -29,7 +29,8 @@
29
29
  "branch-protection-human-required",
30
30
  "arm-failure",
31
31
  "api-race-other",
32
- "predicate-refused"
32
+ "predicate-refused",
33
+ "advisory-gate-red"
33
34
  ]
34
35
  },
35
36
  "reason": { "type": "string", "minLength": 1 },
@@ -125,6 +125,7 @@
125
125
  "arm-failure",
126
126
  "api-race-other",
127
127
  "predicate-refused",
128
+ "advisory-gate-red",
128
129
  "merged-flip-failed"
129
130
  ]
130
131
  },
@@ -325,6 +325,22 @@ function stripAnchorAndQuery(target) {
325
325
  return t;
326
326
  }
327
327
 
328
+ // Percent-decode a link target's path portion (Story #5090). A correctly
329
+ // encoded Markdown destination escapes URL-reserved characters — a
330
+ // file-based router's `[token]` segment is written `%5Btoken%5D`, the form
331
+ // CommonMark renderers require — but the filesystem knows only the decoded
332
+ // name. The decode is one-way and total: a malformed escape (`%zz`) degrades
333
+ // to the raw string instead of throwing `URIError`, so an undecodable target
334
+ // is resolved exactly as it was before.
335
+ function decodeLinkPath(pathOnly) {
336
+ if (!pathOnly.includes('%')) return pathOnly;
337
+ try {
338
+ return decodeURIComponent(pathOnly);
339
+ } catch {
340
+ return pathOnly;
341
+ }
342
+ }
343
+
328
344
  // --- Slash-token extraction ------------------------------------------------
329
345
 
330
346
  // Tokens look like `/<lowercase-alphanum-with-hyphens>`. We exclude tokens
@@ -394,8 +410,13 @@ export function checkFile(absPath, repoRoot) {
394
410
  // 2. Relative-link resolution.
395
411
  for (const { target, line } of extractLinks(masked)) {
396
412
  if (isExternalOrInternalAnchor(target)) continue;
397
- const pathOnly = stripAnchorAndQuery(target);
398
- if (!pathOnly) continue; // pure anchor that survived earlier check
413
+ const rawPathOnly = stripAnchorAndQuery(target);
414
+ if (!rawPathOnly) continue; // pure anchor that survived earlier check
415
+ // Decode AFTER anchor/query stripping — so an escaped `%23` cannot
416
+ // collapse into an anchor delimiter and truncate the target — and BEFORE
417
+ // resolution, so the payload-boundary branch below reports the decoded
418
+ // path rather than the escaped one.
419
+ const pathOnly = decodeLinkPath(rawPathOnly);
399
420
  let resolved;
400
421
  if (pathOnly.startsWith('/')) {
401
422
  // Treat root-absolute paths as repo-root relative.
@@ -64,6 +64,7 @@ import {
64
64
  probeMergedPr,
65
65
  refExists,
66
66
  } from './lib/orchestration/git-cleanup/phases/git-probes.js';
67
+ import { probeAncestry } from './lib/orchestration/git-cleanup/phases/merged-tip.js';
67
68
  import { parseCleanupArgs } from './lib/orchestration/git-cleanup/phases/parse-args.js';
68
69
  import {
69
70
  executePrune,
@@ -110,6 +111,7 @@ export {
110
111
  planFastForward,
111
112
  planStashes,
112
113
  probeAllPrs,
114
+ probeAncestry,
113
115
  probeContentEquivalent,
114
116
  probeLatestPr,
115
117
  probeMergedPr,
@@ -673,8 +673,13 @@ export function assessComparisonBasis(compareResult, opts = {}) {
673
673
  *
674
674
  * Story #791 retired the transitional `bootstrap` exit-0 path: a missing
675
675
  * baseline still fails closed. Story #829 (5.29.0) softened `kernelVersion`
676
- * and `tsTranspilerVersion` drift to **warn**, not fail; `escomplexVersion`
677
- * mismatch continues to fail closed.
676
+ * drift to **warn**, not fail, and did the same for `tsTranspilerVersion`
677
+ * but that second half was re-escalated to **fatal** once Story #4866 made a
678
+ * TS row's `startLine` an original-source coordinate resolved through the
679
+ * transpiler's sourcemap. `startLine` is half the row identity key, so a
680
+ * transpiler change makes the rows incomparable rather than merely stale; see
681
+ * the `ts-transpiler-drift` axis below for the two exemptions that bound it.
682
+ * `escomplexVersion` mismatch has always failed closed.
678
683
  */
679
684
  /**
680
685
  * The one re-seed recipe every coordinate-invalidating axis ends on. Three
@@ -36,6 +36,7 @@ import { loadCoverage } from '../../coverage-utils.js';
36
36
  import { scanAndScore } from '../../crap-utils.js';
37
37
  import { cachedGitFetchSync } from '../../git/cached-fetch.js';
38
38
  import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
39
+ import { SCORABLE_SOURCE_EXT_RE } from '../../source-extensions.js';
39
40
  import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
40
41
 
41
42
  /**
@@ -48,9 +49,6 @@ export const DEFAULT_CRAP_TOLERANCE = 0.001;
48
49
  /** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
49
50
  export const DEFAULT_NEW_METHOD_CEILING = 30;
50
51
 
51
- /** Extensions the CRAP scanner can score. */
52
- const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
53
-
54
52
  /**
55
53
  * Map the shared predicate's fine-grained `missing-*` reason onto the
56
54
  * `missing-args` skipped-reason the sibling MI projection reports, so both
@@ -155,8 +153,9 @@ function refreshBaseRef(cwd, baseBranch, git) {
155
153
  }
156
154
 
157
155
  /**
158
- * Enumerate the Story branch's changed files, narrowed to the extensions
159
- * the CRAP scanner can score.
156
+ * Enumerate the Story branch's changed files, narrowed by the shared
157
+ * scorable-source extension set (`source-extensions.js`) so the projection
158
+ * selects exactly the files the CRAP scanner walks.
160
159
  *
161
160
  * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
162
161
  * @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
@@ -168,7 +167,10 @@ function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
168
167
  cwd,
169
168
  gitSpawn: git.gitSpawn,
170
169
  });
171
- return { ok: true, files: files.filter((f) => SCORABLE.test(f)) };
170
+ return {
171
+ ok: true,
172
+ files: files.filter((f) => SCORABLE_SOURCE_EXT_RE.test(f)),
173
+ };
172
174
  } catch (err) {
173
175
  return { ok: false, detail: err.message };
174
176
  }
@@ -5,6 +5,13 @@
5
5
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
6
6
  * required checks pass, `"strict"` additionally requires a clean review gate.
7
7
  *
8
+ * Story #5096 added `blockOnAdvisoryFailure` (default `true`) and
9
+ * `advisoryAllowlist` (default `[]`). GitHub native auto-merge waits on
10
+ * REQUIRED contexts only, so a red ADVISORY gate would otherwise be merged
11
+ * straight past; these knobs own mandrel's side of that decision. Set
12
+ * `blockOnAdvisoryFailure: false` to restore the pre-#5096 behaviour verbatim,
13
+ * or list a job name in `advisoryAllowlist` to exempt just that one.
14
+ *
8
15
  * Retired (no production readers on v2 Story-only delivery): `earlyPr`
9
16
  * (Epic early-PR warmup) and `requireChecks` (AutomergePredicate escape hatch
10
17
  * whose listener was never landed).
@@ -12,6 +19,8 @@
12
19
 
13
20
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
14
21
  autoMerge: 'trust-ci',
22
+ blockOnAdvisoryFailure: true,
23
+ advisoryAllowlist: Object.freeze([]),
15
24
  });
16
25
 
17
26
  /**
@@ -31,6 +40,15 @@ export function getCiDelivery(config) {
31
40
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
32
41
  ? ci.autoMerge
33
42
  : CI_DELIVERY_DEFAULTS.autoMerge,
43
+ blockOnAdvisoryFailure:
44
+ typeof ci.blockOnAdvisoryFailure === 'boolean'
45
+ ? ci.blockOnAdvisoryFailure
46
+ : CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
47
+ advisoryAllowlist: Array.isArray(ci.advisoryAllowlist)
48
+ ? ci.advisoryAllowlist.filter(
49
+ (entry) => typeof entry === 'string' && entry,
50
+ )
51
+ : [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
34
52
  watch:
35
53
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
36
54
  };
@@ -389,6 +389,19 @@ const CI_DELIVERY_SCHEMA = {
389
389
  "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
390
390
  default: CI_DELIVERY_DEFAULTS.autoMerge,
391
391
  },
392
+ blockOnAdvisoryFailure: {
393
+ type: 'boolean',
394
+ description:
395
+ 'Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.',
396
+ default: CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
397
+ },
398
+ advisoryAllowlist: {
399
+ type: 'array',
400
+ items: { type: 'string' },
401
+ description:
402
+ 'Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.',
403
+ default: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
404
+ },
392
405
  },
393
406
  additionalProperties: false,
394
407
  };
@@ -8,7 +8,10 @@
8
8
  * logic; behaviour is byte-for-byte the pre-#4981 body.
9
9
  */
10
10
  import path from 'node:path';
11
- import { anyChangedUnderTargets } from './coverage-capture.js';
11
+ import {
12
+ anyChangedUnderTargets,
13
+ describeFreshness,
14
+ } from './coverage-capture.js';
12
15
 
13
16
  /**
14
17
  * Run the `--skip-when-no-crap-files` check (when requested), the
@@ -72,7 +75,7 @@ export function runFullScopeCapture({
72
75
  }
73
76
 
74
77
  logger.info(
75
- `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
78
+ `[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage…`,
76
79
  );
77
80
  const code = runCaptureImpl({
78
81
  cwd: args.cwd,
@@ -14,12 +14,20 @@ import { spawnSync } from 'node:child_process';
14
14
  import crypto from 'node:crypto';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
+ import {
18
+ isScorableSourceFile,
19
+ SCORABLE_SOURCE_EXT_RE,
20
+ } from './source-extensions.js';
17
21
 
18
22
  /**
19
23
  * Walk a directory tree and return the newest mtime (ms since epoch) seen
20
- * across `.js` and `.mjs` files. Symlinks, missing dirs, and unreadable nodes
21
- * resolve to 0 so the caller treats "no sources" the same as "ancient sources"
22
- * both mean "any existing coverage is fresh enough".
24
+ * across the scorable source files (`source-extensions.js`) the same set
25
+ * the CRAP scanner walks, so freshness tracks exactly what the gate scores.
26
+ * Symlinks, missing dirs, and unreadable nodes resolve to 0.
27
+ *
28
+ * A 0 return means "discovery found nothing", which {@link isCoverageFresh}
29
+ * treats as an absence of evidence rather than as freshness — see its
30
+ * `no-sources` contract.
23
31
  *
24
32
  * Exported for unit testing.
25
33
  *
@@ -48,7 +56,7 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
48
56
  continue;
49
57
  }
50
58
  if (!entry.isFile()) continue;
51
- if (!entry.name.endsWith('.js') && !entry.name.endsWith('.mjs')) continue;
59
+ if (!isScorableSourceFile(entry.name)) continue;
52
60
  try {
53
61
  const m = statSync(childAbs).mtimeMs;
54
62
  if (m > newest) newest = m;
@@ -83,17 +91,55 @@ export function captureStampPath(cwd, coveragePath) {
83
91
  );
84
92
  }
85
93
 
86
- const SOURCE_EXT_RE = /\.(?:js|mjs)$/;
94
+ /**
95
+ * Fold every dirty scorable working-tree file under the scanned dirs into
96
+ * `hash`, and report how many there were.
97
+ *
98
+ * Dirty files are not represented by their index blob SHA, so their on-disk
99
+ * bytes (or absence) go in explicitly. Split out of
100
+ * {@link computeContentDigest} to keep that function's complexity at its
101
+ * committed CRAP floor.
102
+ *
103
+ * @param {{
104
+ * hash: import('node:crypto').Hash,
105
+ * cwd: string,
106
+ * readFileSync: typeof fs.readFileSync,
107
+ * porcelain: string,
108
+ * }} opts `porcelain` is raw `git status --porcelain` output.
109
+ * @returns {number} Count of scorable dirty files folded in.
110
+ */
111
+ function foldDirtySources({ hash, cwd, readFileSync, porcelain }) {
112
+ let count = 0;
113
+ for (const line of porcelain.split('\n').filter((l) => l.length > 3)) {
114
+ let file = line.slice(3).trim();
115
+ if (file.includes(' -> ')) file = file.split(' -> ').pop();
116
+ file = file.replace(/^"|"$/g, '');
117
+ if (!SCORABLE_SOURCE_EXT_RE.test(file)) continue;
118
+ count += 1;
119
+ hash.update(`\0${file}\0`);
120
+ try {
121
+ hash.update(readFileSync(path.resolve(cwd, file)));
122
+ } catch {
123
+ hash.update('<absent>');
124
+ }
125
+ }
126
+ return count;
127
+ }
87
128
 
88
129
  /**
89
- * Compute a stable content digest of the `.js`/`.mjs` sources under
90
- * `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
130
+ * Compute a stable content digest of the scorable sources
131
+ * (`source-extensions.js`) under `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
91
132
  * tracked content, plus the on-disk bytes of any dirty working-tree files.
92
133
  * Checkout/branch churn leaves blob SHAs untouched, so the digest only moves
93
134
  * when content actually changes.
94
135
  *
95
136
  * Returns `null` when the digest cannot be computed (git unavailable, not a
96
137
  * repo, empty target list) so callers can fall back to the mtime heuristic.
138
+ * A target list that matches **no** scorable file is the same "unavailable"
139
+ * case, not a digest over zero files: this path is the primary freshness
140
+ * test, so returning a real hash of empty input would pin the artifact
141
+ * permanently fresh and make the mtime path's fail-closed verdict
142
+ * unreachable (Story #5076).
97
143
  *
98
144
  * @param {string} cwd Absolute repo root.
99
145
  * @param {string[]} targetDirs Repo-relative directories to digest.
@@ -120,26 +166,19 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
120
166
  const hash = crypto.createHash('sha256');
121
167
  const tracked = git('ls-files', '-s', '--', ...dirs)
122
168
  .split('\n')
123
- .filter((line) => SOURCE_EXT_RE.test(line.trimEnd()));
169
+ .filter((line) => SCORABLE_SOURCE_EXT_RE.test(line.trimEnd()));
124
170
  hash.update(tracked.join('\n'));
125
171
 
126
- // Dirty working-tree files are not represented by their index blob SHA,
127
- // so fold in their on-disk bytes (or absence) explicitly.
128
- const dirty = git('status', '--porcelain', '--', ...dirs)
129
- .split('\n')
130
- .filter((line) => line.length > 3);
131
- for (const line of dirty) {
132
- let file = line.slice(3).trim();
133
- if (file.includes(' -> ')) file = file.split(' -> ').pop();
134
- file = file.replace(/^"|"$/g, '');
135
- if (!SOURCE_EXT_RE.test(file)) continue;
136
- hash.update(`\0${file}\0`);
137
- try {
138
- hash.update(readFileSync(path.resolve(cwd, file)));
139
- } catch {
140
- hash.update('<absent>');
141
- }
142
- }
172
+ const scorableDirty = foldDirtySources({
173
+ hash,
174
+ cwd,
175
+ readFileSync,
176
+ porcelain: git('status', '--porcelain', '--', ...dirs),
177
+ });
178
+ // Discovery found nothing to digest: hashing the empty input would yield
179
+ // a constant that can never go stale, so report "unavailable" instead and
180
+ // let the caller's fail-closed mtime path decide.
181
+ if (tracked.length + scorableDirty === 0) return null;
143
182
  return hash.digest('hex');
144
183
  } catch {
145
184
  return null;
@@ -231,6 +270,13 @@ function readStampForScope(stamp, requireScope) {
231
270
  * under `targetDirs`. Missing files, missing target dirs, or any IO error
232
271
  * resolve to `false` so the caller captures rather than trusting stale data.
233
272
  *
273
+ * **Both paths fail closed on an empty source set (Story #5076).** Finding no
274
+ * scorable source under `targetDirs` means the check learned nothing, so it
275
+ * reports `{ fresh: false, reason: 'no-sources' }` and the caller captures.
276
+ * The alternative — treating "found nothing" as "nothing changed" — is how a
277
+ * `js|mjs`-only selector left the CRAP gate green while measuring nothing in
278
+ * every TypeScript consumer.
279
+ *
234
280
  * **Scope asymmetry (Story #4981, AC-4).** A stamp written by an incremental
235
281
  * capture (`scope: 'incremental'`) only covers the files the diff touched —
236
282
  * it must never satisfy a caller that requires the full-scope guarantee
@@ -299,12 +345,36 @@ export function isCoverageFresh({
299
345
  statSync,
300
346
  readdirSync,
301
347
  });
302
- if (newestSrc === 0) return { fresh: true, reason: 'no-sources' };
348
+ // Source discovery found nothing. That is an absence of evidence, never a
349
+ // freshness guarantee — trusting it silently disables the capture (and with
350
+ // it the CRAP gate) for any tree the walk cannot see (Story #5076).
351
+ if (newestSrc === 0) return { fresh: false, reason: 'no-sources' };
303
352
  return coverageMtime >= newestSrc
304
353
  ? { fresh: true, reason: 'fresh' }
305
354
  : { fresh: false, reason: 'stale' };
306
355
  }
307
356
 
357
+ /**
358
+ * Render a freshness verdict for the operator-facing capture log.
359
+ *
360
+ * Every reason but `no-sources` speaks for itself. That one does not: failing
361
+ * closed on an empty source walk is correct, but bare it reads as an
362
+ * unexplained full capture on every run, and the cause is far more often a
363
+ * `targetDirs` that does not name the project's sources than a genuine
364
+ * recapture — so the walked dirs and the key to fix are named inline
365
+ * (Story #5076).
366
+ *
367
+ * @param {{ reason?: string }} freshness Verdict from {@link isCoverageFresh}.
368
+ * @param {string[]} targetDirs The CRAP scan scope that was walked.
369
+ * @returns {string} The reason, annotated when it needs explaining.
370
+ */
371
+ export function describeFreshness(freshness, targetDirs) {
372
+ const reason = freshness?.reason;
373
+ if (reason !== 'no-sources') return String(reason);
374
+ const dirs = (targetDirs ?? []).join(', ');
375
+ return `${reason} — no scorable source file found under [${dirs}]; if that does not name this project's sources, fix quality.gates.crap.targetDirs`;
376
+ }
377
+
308
378
  /**
309
379
  * Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
310
380
  *