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.
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/docs/quality-gates.md +30 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/audit-rules.json +44 -0
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
- package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
- package/.agents/scripts/lib/coverage-capture.js +96 -26
- package/.agents/scripts/lib/findings/route-finding.js +98 -35
- package/.agents/scripts/lib/maintainability-utils.js +6 -14
- package/.agents/scripts/lib/observability/source-classifier.js +0 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/lib/source-extensions.js +76 -0
- package/.agents/scripts/notify.js +4 -10
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
- package/.agents/workflows/audit-adrs.md +270 -0
- package/.agents/workflows/audit-documentation.md +22 -6
- package/docs/CHANGELOG.md +33 -0
- package/package.json +3 -3
- package/.agents/scripts/generate-lifecycle-docs.js +0 -237
|
@@ -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 (
|
|
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
|
},
|
|
@@ -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
|
|
398
|
-
if (!
|
|
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
|
-
*
|
|
677
|
-
*
|
|
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
|
|
159
|
-
*
|
|
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 {
|
|
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 {
|
|
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.
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
169
|
+
.filter((line) => SCORABLE_SOURCE_EXT_RE.test(line.trimEnd()));
|
|
124
170
|
hash.update(tracked.join('\n'));
|
|
125
171
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
*
|