@codyswann/lisa 4.33.5 → 4.33.8

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 (106) hide show
  1. package/dist/cli/apply.d.ts.map +1 -1
  2. package/dist/cli/apply.js +8 -1
  3. package/dist/cli/apply.js.map +1 -1
  4. package/dist/core/apply-receipt.d.ts +18 -0
  5. package/dist/core/apply-receipt.d.ts.map +1 -1
  6. package/dist/core/apply-receipt.js +7 -5
  7. package/dist/core/apply-receipt.js.map +1 -1
  8. package/dist/core/config.d.ts +34 -0
  9. package/dist/core/config.d.ts.map +1 -1
  10. package/dist/core/config.js.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  12. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  13. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  14. package/dist/core/lisa.d.ts +75 -0
  15. package/dist/core/lisa.d.ts.map +1 -1
  16. package/dist/core/lisa.js +125 -6
  17. package/dist/core/lisa.js.map +1 -1
  18. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  19. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  20. package/dist/core/upstream-evidence-manifest.js +22 -7
  21. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  22. package/dist/core/workflow-deletion-ownership.d.ts +69 -0
  23. package/dist/core/workflow-deletion-ownership.d.ts.map +1 -0
  24. package/dist/core/workflow-deletion-ownership.js +156 -0
  25. package/dist/core/workflow-deletion-ownership.js.map +1 -0
  26. package/package.json +4 -4
  27. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa/.codex-plugin/skills/lisa-intake-explain/SKILL.md +1 -1
  30. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +32 -20
  31. package/plugins/lisa/.codex-plugin/skills/lisa-ticket-triage/SKILL.md +1 -1
  32. package/plugins/lisa/rules/eager/blocker-containment.md +44 -0
  33. package/plugins/lisa/rules/reference/blocker-containment.md +92 -0
  34. package/plugins/lisa/skills/lisa-intake-explain/SKILL.md +1 -1
  35. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +32 -20
  36. package/plugins/lisa/skills/lisa-ticket-triage/SKILL.md +1 -1
  37. package/plugins/lisa-agy/plugin.json +1 -1
  38. package/plugins/lisa-agy/skills/lisa-intake-explain/SKILL.md +1 -1
  39. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +32 -20
  40. package/plugins/lisa-agy/skills/lisa-ticket-triage/SKILL.md +1 -1
  41. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/rules/eager/blocker-containment.md +44 -0
  48. package/plugins/lisa-copilot/rules/reference/blocker-containment.md +92 -0
  49. package/plugins/lisa-copilot/skills/lisa-intake-explain/SKILL.md +1 -1
  50. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +32 -20
  51. package/plugins/lisa-copilot/skills/lisa-ticket-triage/SKILL.md +1 -1
  52. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cursor/rules/blocker-containment-reference.mdc +97 -0
  54. package/plugins/lisa-cursor/rules/blocker-containment.mdc +49 -0
  55. package/plugins/lisa-cursor/skills/lisa-intake-explain/SKILL.md +1 -1
  56. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +32 -20
  57. package/plugins/lisa-cursor/skills/lisa-ticket-triage/SKILL.md +1 -1
  58. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-expo-agy/plugin.json +1 -1
  61. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  71. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  76. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  81. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails-agy/plugin.json +1 -1
  86. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  91. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  96. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/src/base/rules/eager/blocker-containment.md +44 -0
  99. package/plugins/src/base/rules/reference/blocker-containment.md +92 -0
  100. package/plugins/src/base/skills/lisa-intake-explain/SKILL.md +1 -1
  101. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +32 -20
  102. package/plugins/src/base/skills/lisa-ticket-triage/SKILL.md +1 -1
  103. package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +2 -2
  104. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +414 -6
  105. package/typescript/create-only/.github/workflows/review-evidence.yml +78 -3
  106. package/typescript/deletions.json +8 -1
@@ -1005,28 +1005,40 @@ is no normalized `is blocked by` field. Read the bundle, then extract blockers p
1005
1005
  - **Linear**: inspect the native issue **relations** from Linear MCP `get_issue` and select
1006
1006
  blocker relations.
1007
1007
 
1008
- Then classify each blocker:
1009
-
1010
- - **Closed, or shipped to any environment** → **cleared**. A blocker is cleared at **any**
1011
- env-staged `done` role — not only the production terminal. The `done` role is configured
1012
- per-env as a `{dev, staging, production}` map (`config-resolution`), so `On Dev`, `On Stg`,
1013
- **and** production `Done` all mean the blocker's code is merged and deployed to ≥1 environment.
1014
- A post-build `review` role (e.g. `Code Review`) is likewise cleared — the change exists and is
1015
- in flight. An `is blocked by` link is a **development** dependency: it is satisfied once the
1016
- blocker's code is in trunk; it must NOT wait for the blocker to reach production. (This matches
1017
- the intake-path dependency-hold gate in `lisa-intake-explain`, which already treats
1018
- `code-review` / `on-dev` / `on-stg` / `done` as cleared — repair-intake must not be stricter.
1019
- In an env-staged workflow where `Done` means "in production", a strict production-terminal
1020
- check strands every dependent forever behind a blocker that is already merged and sitting at
1021
- `On Stg` / `On Dev`.)
1022
- - **Open** in a pre-merge role (`ready` / `claimed` / unknown — code not yet in trunk) → **still
1023
- blocking**.
1024
- - **Inaccessible** (deleted, cross-org, permission denied) → **still blocking**, unless the item
1025
- body or a newer human comment explicitly states the dependency is resolved.
1008
+ Then classify each blocker **by containment, per the `blocker-containment` rule** — cite that
1009
+ slug for the full contract; do not restate its resolution steps or its fail-closed table here.
1010
+
1011
+ The one question is whether the blocker's code is on the branch **this item** will be built from:
1012
+ resolve this item's base branch from its `## Target Backend Environment` through
1013
+ `.lisa.config.json` `deploy.branches` (`derived-branch-plan`, `config-resolution` — never by
1014
+ reading a rendered `Branch Plan`), resolve the blocker's merge commit through the vendor access
1015
+ layer, and test ancestry against the remote.
1016
+
1017
+ - **Contained** — every merged PR on the blocker is an ancestor of this item's base branch →
1018
+ **cleared**.
1019
+ - **Not contained**, or containment **not computable** for any reason the rule enumerates (no
1020
+ linked PR, PR not merged, blocker inaccessible, base branch underivable or absent from the
1021
+ remote, ancestry query errored) → **still blocking**, with the rule's reason key in the run
1022
+ record.
1023
+ - **Ships no code** — the blocker is closed as completed, positively declares
1024
+ `Target Backend Environment: None — no runtime behavior change`, and has no merged PR →
1025
+ **cleared** under the rule's carve-out. This is a positive determination, never an absence:
1026
+ "no PR found" is `no-pr` and stays blocking.
1027
+ - **Human override** — an explicit human statement on this item that the dependency is satisfied
1028
+ clears it and outranks a failed containment check. Name it in the run record.
1029
+
1030
+ **A lifecycle status name is never the test.** `done`, `on-stg`, `on-dev` and `code-review` are
1031
+ labels that correlate with containment in a single-trunk workflow and come apart from it in a
1032
+ promotion workflow, where `main` is the trailing branch — a blocker at `On Stg` is on `staging`
1033
+ and absent from `main`, and a blocker in `Code Review` is merged to nothing. Containment gets
1034
+ both the loose and the strict failure right at once: a blocker merged into the branch this item
1035
+ builds from clears **immediately**, with no wait for production, so a dependent is never stranded
1036
+ behind work already merged where it needs it.
1026
1037
 
1027
1038
  Only re-dispatch when **every** parsed blocker is cleared. When in doubt, stay blocked — a
1028
- false-negative (left blocked) is cheap; a false-positive (re-dispatched into a real blocker)
1029
- wastes a build cycle.
1039
+ false-negative (left blocked) costs one cycle and is visible; a false-positive is **invisible**,
1040
+ because the item moves to `ready`, which looks exactly like a repair that worked, and the failure
1041
+ surfaces days later as work begun before its dependency existed.
1030
1042
 
1031
1043
  ### Class B — validation / quality-gate self-block
1032
1044
 
@@ -46,7 +46,7 @@ If relevant code IS found, proceed to Phase 2.
46
46
 
47
47
  From the context bundle, evaluate relationships before analyzing this ticket in isolation:
48
48
 
49
- - **Open blockers (`is blocked by`)**: if any blocker is not `Done` or its linked PR is not merged, raise an ambiguity: "Blocker {KEY} is not shipped — work cannot meaningfully start." This is an automatic `BLOCKED` verdict unless the human confirms the blocker state is acceptable.
49
+ - **Open blockers (`is blocked by`)**: clear-check each blocker **by containment, per the `blocker-containment` rule** — cite that slug for the full contract rather than restating it. A blocker is satisfied when its merged PRs are ancestors of the branch this ticket will be built from (resolved from this ticket's `## Target Backend Environment` through `.lisa.config.json` `deploy.branches`, never from a rendered `Branch Plan`), or when the rule's narrow no-code carve-out or human override applies. **`Done` is not the test** — a state name holds in a single-trunk workflow and comes apart in a promotion workflow, and requiring the production terminal strands every dependent behind work already merged where this ticket needs it. This is the same predicate the `DUPLICATE_ALREADY_FIXED` bullet below already requires ("present on the relevant base branch… never from a name/label match alone"), applied here too. For a blocker that is not contained, or where containment is not computable, raise an ambiguity naming the rule's reason key: "Blocker {KEY} is not on {BRANCH} ({REASON}) — work cannot meaningfully start." That is an automatic `BLOCKED` verdict unless the human confirms the blocker state is acceptable.
50
50
  - **Epic siblings in progress**: if a sibling under the same epic is `In Progress` / `In Review` with a different assignee and overlapping scope, raise it as an edge case in Phase 4 ("Duplicate-work risk with {KEY}").
51
51
  - **`duplicates` / `is duplicated by` links**:
52
52
  - If this ticket is a duplicate of an open canonical ticket whose fix is not yet merged into the base branch, verdict is `BLOCKED` with the recommendation to close as duplicate manually rather than implement.
@@ -45,6 +45,8 @@ For full functionality, configure the following secrets:
45
45
  |--------|---------|--------------|
46
46
  | `EXPO_TOKEN` | EAS builds and updates | Mobile deployment |
47
47
  | `SENTRY_AUTH_TOKEN` | Error tracking | Release monitoring |
48
+ | `SENTRY_ORG` | Sentry organization slug | Release monitoring |
49
+ | `SENTRY_PROJECT` | Sentry project slug | Release monitoring |
48
50
  | `SONAR_TOKEN` | Code quality analysis | Security scanning |
49
51
  | `SNYK_TOKEN` | Vulnerability scanning | Security scanning |
50
52
 
@@ -535,8 +537,6 @@ Variables are non-sensitive configuration values. Set them in **Settings** > **S
535
537
  | `JIRA_BASE_URL` | Jira instance base URL (enables Jira triage workflow) | `https://company.atlassian.net` |
536
538
  | `JIRA_USER_EMAIL` | Email associated with the Jira API token | `user@company.com` |
537
539
  | `JIRA_PROJECT_KEY` | Jira project key for ticket queries | `PROJ` |
538
- | `SENTRY_ORG` | Sentry organization slug | `my-company` |
539
- | `SENTRY_PROJECT` | Sentry project slug | `frontend-app` |
540
540
 
541
541
  ## External Service Setup
542
542
 
@@ -487,6 +487,40 @@ export const SETTLE_INTERVAL_SECONDS = 15;
487
487
  /** Enables the blocking review-evidence policy. */
488
488
  const REQUIRE_REVIEW_EVIDENCE_FLAG = "--require-review-evidence";
489
489
 
490
+ /**
491
+ * Selects how many recently merged pull requests the waive-rate sample reads.
492
+ *
493
+ * Zero — the shipped default — disables the sample entirely, so no repository
494
+ * acquires a network read it did not ask for. The workflow that ships with Lisa
495
+ * passes a value, because the number is the point: `.github/workflows/
496
+ * review-evidence.yml` recorded "of the last 40 merged pull requests, 39 waive"
497
+ * in a COMMENT, where it changed nothing for eight months. A rate that an
498
+ * operator reads at merge time is a different artefact from a rate somebody
499
+ * once measured.
500
+ */
501
+ const WAIVE_RATE_SAMPLE_FLAG = "--waive-rate-sample";
502
+
503
+ /**
504
+ * The one call the waive-rate sample makes.
505
+ *
506
+ * Kept beside the flag rather than inline in the reader so the network shape is
507
+ * visible without reading a function: merged pull requests, newest first, each
508
+ * with its head commit's rollup contexts. `StatusContext` and `CheckRun` are
509
+ * both selected because a review bot may report as either and the description —
510
+ * `description` on one, `title` on the other — is the only field that separates
511
+ * a review from a waiver.
512
+ */
513
+ const RECENT_MERGED_CHECKS_QUERY = [
514
+ "query($owner:String!,$name:String!,$limit:Int!){",
515
+ "repository(owner:$owner,name:$name){",
516
+ "pullRequests(states:MERGED,first:$limit,orderBy:{field:UPDATED_AT,direction:DESC}){",
517
+ "nodes{number commits(last:1){nodes{commit{statusCheckRollup{contexts(last:100){",
518
+ "nodes{__typename",
519
+ " ... on StatusContext{context state description}",
520
+ " ... on CheckRun{name conclusion title}",
521
+ "}}}}}}}}}}",
522
+ ].join("");
523
+
490
524
  /**
491
525
  * The flag that used to select the live-ruleset drift mode, retired with it.
492
526
  *
@@ -1284,13 +1318,18 @@ export function reviewGateState(reading, vocabulary = {}) {
1284
1318
  * @param {object} declaration - The per-repo declaration
1285
1319
  * @param {ReadonlyArray<{name: string, state: string, description?: string}>} checks - The checks
1286
1320
  * @param {{headSha?: string}} [options] - `headSha` is cited in every finding
1287
- * @returns {{violations: object[], states: Record<string, string>, checked: number}} Findings, per-check state, and how many were examined
1321
+ * @returns {{violations: object[], states: Record<string, string>, descriptions: Record<string, string>, checked: number}} Findings, per-check state, the description each verdict was read from, and how many were examined
1288
1322
  */
1289
1323
  export function evaluateReviewGate(declaration, checks, options = {}) {
1290
1324
  const declared = declaration.evidence_bearing_checks ?? {};
1291
1325
  const at = citeHeadSha(options.headSha);
1292
1326
  const violations = [];
1293
1327
  const states = {};
1328
+ // Carried out with the states because the RENDERED verdict quotes it. A title
1329
+ // that said only "WAIVED" would tell an operator that something was waived
1330
+ // and not which vendor sentence did the waiving, which is the fact that says
1331
+ // whether to wait for the entitlement or merge on the waiver.
1332
+ const descriptions = {};
1294
1333
  let checked = 0;
1295
1334
 
1296
1335
  for (const [name, entry] of Object.entries(declared)) {
@@ -1308,6 +1347,13 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
1308
1347
  vocabulary
1309
1348
  );
1310
1349
  states[name] = verdict.state;
1350
+ // Assigned ONLY when the check reported. An absent check with a `""`
1351
+ // description entry renders as `CodeRabbit reported ""` — a sentence that
1352
+ // says it reported. MEASURED on this fix's own first commit, where the
1353
+ // published verdict read exactly that. Absence is not a quiet report, and
1354
+ // the whole file turns on the two being different facts.
1355
+ if (found !== undefined)
1356
+ descriptions[name] = String(found.description ?? "");
1311
1357
  if (verdict.state === REVIEW_GATE_STATES.satisfied) continue;
1312
1358
  violations.push({
1313
1359
  kind:
@@ -1320,7 +1366,295 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
1320
1366
  });
1321
1367
  }
1322
1368
 
1323
- return { violations, states, checked };
1369
+ return { violations, states, descriptions, checked };
1370
+ }
1371
+
1372
+ /**
1373
+ * The check-run conclusion each review-gate verdict is published under.
1374
+ *
1375
+ * THIS IS THE WHOLE POINT OF #3639. Before this map existed the gate's three
1376
+ * verdicts all reached a merge decision through the same rendering: the job
1377
+ * exited 0 for `satisfied` AND for `waived`, so `gh pr checks` printed
1378
+ *
1379
+ * 🕵️ Did the required review checks do any work? pass
1380
+ *
1381
+ * for a pull request nothing had read, character for character what it prints
1382
+ * for one a reviewer completed. The distinction lived in the run log, and the
1383
+ * file's own header had already measured how little that is worth: of the last
1384
+ * 40 merged pull requests, 39 waived. A waiver that applies to 39 of 40 merges
1385
+ * is the default path, and the default path rendering as the success path
1386
+ * reproduces — on the guard's own green — the exact indistinguishability the
1387
+ * guard exists to remove.
1388
+ *
1389
+ * `neutral` is the mechanism because it is the one conclusion that is NOT a
1390
+ * pass and NOT a failure. `gh pr checks` buckets NEUTRAL as `skipping`, so the
1391
+ * row reads differently at a glance, while contributing neither a failure to
1392
+ * gh's exit code nor a satisfied context to branch protection. The waiver keeps
1393
+ * its rationale — a pull request author cannot fix a vendor's billing state,
1394
+ * and reddening every PR on it would be a worse gate — and loses only its
1395
+ * disguise.
1396
+ */
1397
+ export const REVIEW_VERDICT_CONCLUSIONS = Object.freeze({
1398
+ satisfied: "success",
1399
+ waived: "neutral",
1400
+ unsatisfied: "failure",
1401
+ uninspected: "failure",
1402
+ });
1403
+
1404
+ /** GitHub truncates a check-run output title past this many characters. */
1405
+ export const REVIEW_VERDICT_TITLE_LIMIT = 255;
1406
+
1407
+ /**
1408
+ * Flattens a title to one line and fits it in a check-run output title.
1409
+ *
1410
+ * Single-line is a correctness requirement, not tidiness: this string is
1411
+ * written to `$GITHUB_OUTPUT` as `key=value`, where a newline in the value ends
1412
+ * the assignment and the remainder is parsed as further output keys. A vendor
1413
+ * description is arbitrary text from outside this repository.
1414
+ *
1415
+ * @param {string} text - The composed title
1416
+ * @returns {string} One line, at most {@link REVIEW_VERDICT_TITLE_LIMIT} chars
1417
+ */
1418
+ function fitTitle(text) {
1419
+ const flat = String(text).replace(/\s+/gu, " ").trim();
1420
+ return flat.length <= REVIEW_VERDICT_TITLE_LIMIT
1421
+ ? flat
1422
+ : `${flat.slice(0, REVIEW_VERDICT_TITLE_LIMIT - 1)}…`;
1423
+ }
1424
+
1425
+ /**
1426
+ * The waive-rate sentence appended to a verdict title, or nothing.
1427
+ *
1428
+ * @param {{waived: number, sampled: number}|undefined} rate - A sampled tally
1429
+ * @returns {string} The sentence, or an empty string when nothing was sampled
1430
+ */
1431
+ function waiveRateSuffix(rate) {
1432
+ return rate === undefined || rate.sampled === 0
1433
+ ? ""
1434
+ : ` · waived on ${rate.waived} of the last ${rate.sampled} merged pull requests`;
1435
+ }
1436
+
1437
+ /**
1438
+ * Renders one merge-time verdict for every declared evidence-bearing check.
1439
+ *
1440
+ * Worst-wins across the declared checks, in the order `unsatisfied` > `waived`
1441
+ * > `satisfied`. One waived check among four satisfied ones still means this
1442
+ * pull request has an unreviewed arm, and a verdict that averaged them would be
1443
+ * the summary that hides the finding.
1444
+ *
1445
+ * Pure, and deliberately separate from the publishing step, so the property
1446
+ * that matters — a waiver and a satisfaction never render the same — is
1447
+ * testable without a GitHub API.
1448
+ *
1449
+ * @param {{states?: Record<string, string>, descriptions?: Record<string, string>, refusal?: {kind: string}|null, waiveRate?: {waived: number, sampled: number}}} reading -
1450
+ * The gate's per-check states, the descriptions they were read from, any
1451
+ * refusal, and an optional sampled waive rate
1452
+ * @returns {{verdict: string, conclusion: string, title: string}} What to publish
1453
+ */
1454
+ export function reviewGateVerdict(reading = {}) {
1455
+ const states = reading.states ?? {};
1456
+ const descriptions = reading.descriptions ?? {};
1457
+ const names = Object.keys(states);
1458
+ const suffix = waiveRateSuffix(reading.waiveRate);
1459
+
1460
+ if (reading.refusal || names.length === 0) {
1461
+ return {
1462
+ verdict: "uninspected",
1463
+ conclusion: REVIEW_VERDICT_CONCLUSIONS.uninspected,
1464
+ title: fitTitle(
1465
+ `NOT INSPECTED — no review evidence was read${reading.refusal ? ` (${reading.refusal.kind})` : ""}. NOBODY LOOKED is not the same fact as a clean review.`
1466
+ ),
1467
+ };
1468
+ }
1469
+
1470
+ /**
1471
+ * Names one check alongside the exact description its verdict was read from.
1472
+ *
1473
+ * A check with no entry never REPORTED, and says so. `reported ""` for a
1474
+ * check that posted nothing is the one phrasing this whole file exists to
1475
+ * refuse: it makes absence look like a quiet answer.
1476
+ *
1477
+ * @param {string} name - The declared check name
1478
+ * @returns {string} `<name> reported "<description>"`, or that it posted none
1479
+ */
1480
+ const quote = name =>
1481
+ Object.hasOwn(descriptions, name)
1482
+ ? `${name} reported ${JSON.stringify(descriptions[name])}`
1483
+ : `${name} posted NO review status at all`;
1484
+
1485
+ const unsatisfied = names.filter(
1486
+ name => states[name] === REVIEW_GATE_STATES.unsatisfied
1487
+ );
1488
+ if (unsatisfied.length > 0) {
1489
+ return {
1490
+ verdict: REVIEW_GATE_STATES.unsatisfied,
1491
+ conclusion: REVIEW_VERDICT_CONCLUSIONS.unsatisfied,
1492
+ title: fitTitle(
1493
+ `UNREVIEWED — review evidence unsatisfied: ${unsatisfied.map(quote).join("; ")}${suffix}`
1494
+ ),
1495
+ };
1496
+ }
1497
+
1498
+ const waived = names.filter(
1499
+ name => states[name] === REVIEW_GATE_STATES.waived
1500
+ );
1501
+ if (waived.length > 0) {
1502
+ return {
1503
+ verdict: REVIEW_GATE_STATES.waived,
1504
+ conclusion: REVIEW_VERDICT_CONCLUSIONS.waived,
1505
+ title: fitTitle(
1506
+ `WAIVED — this pull request is UNREVIEWED and merging it is a decision taken on that basis: ${waived.map(quote).join("; ")}${suffix}`
1507
+ ),
1508
+ };
1509
+ }
1510
+
1511
+ return {
1512
+ verdict: REVIEW_GATE_STATES.satisfied,
1513
+ conclusion: REVIEW_VERDICT_CONCLUSIONS.satisfied,
1514
+ title: fitTitle(`REVIEWED — ${names.map(quote).join("; ")}${suffix}`),
1515
+ };
1516
+ }
1517
+
1518
+ /**
1519
+ * Counts how a sample of pull requests ended up, worst-wins per pull request.
1520
+ *
1521
+ * A pull request whose declared checks produced no state at all is not counted
1522
+ * as reviewed OR waived — it is not counted. A denominator that quietly absorbs
1523
+ * unreadable pull requests reports a lower waive rate than the truth, which is
1524
+ * the direction that makes the number reassuring rather than useful.
1525
+ *
1526
+ * @param {ReadonlyArray<Record<string, string>>} samples - One states map per pull request
1527
+ * @returns {{sampled: number, waived: number, satisfied: number, unsatisfied: number}} The tally
1528
+ */
1529
+ export function summarizeWaiveRate(samples) {
1530
+ const tally = { sampled: 0, waived: 0, satisfied: 0, unsatisfied: 0 };
1531
+ for (const states of samples) {
1532
+ const values = Object.values(states ?? {});
1533
+ if (values.length === 0) continue;
1534
+ tally.sampled += 1;
1535
+ if (values.includes(REVIEW_GATE_STATES.unsatisfied)) tally.unsatisfied += 1;
1536
+ else if (values.includes(REVIEW_GATE_STATES.waived)) tally.waived += 1;
1537
+ else tally.satisfied += 1;
1538
+ }
1539
+ return tally;
1540
+ }
1541
+
1542
+ /**
1543
+ * Reads the declared evidence-bearing rows for recently merged pull requests.
1544
+ *
1545
+ * ONE GraphQL call, not one REST call per pull request. A per-PR loop is ~2N
1546
+ * network reads inside a job that is already spending five minutes waiting for
1547
+ * a review bot to settle, and a sampler that can time the job out would be
1548
+ * deleted rather than read.
1549
+ *
1550
+ * `orderBy: UPDATED_AT` rather than a merge timestamp, and the difference is
1551
+ * worth naming: this is "recently touched merged pull requests", a proxy for
1552
+ * "recently merged" that the single-call shape buys. The number it produces is
1553
+ * an order-of-magnitude reading of how often the waiver is the path — which is
1554
+ * what an operator needs — not an audit figure.
1555
+ *
1556
+ * @param {number} limit - How many merged pull requests to read
1557
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
1558
+ * @returns {Array<Array<{name: string, state: string, description: string}>>} Rows per pull request
1559
+ * @throws {Error} When the slug cannot be resolved or `gh` cannot answer
1560
+ */
1561
+ export function fetchRecentMergedChecks(limit, repo) {
1562
+ const slug = resolveRepoSlug(repo);
1563
+ if (slug === undefined) {
1564
+ throw new Error(
1565
+ "check-skipped-required-checks: the waive-rate sample needs an OWNER/NAME. Pass `--repo=OWNER/NAME`, or set GITHUB_REPOSITORY."
1566
+ );
1567
+ }
1568
+ const [owner, name] = slug.split("/");
1569
+ const raw = boundedExecFileSync(
1570
+ "gh",
1571
+ [
1572
+ "api",
1573
+ "graphql",
1574
+ "-f",
1575
+ `query=${RECENT_MERGED_CHECKS_QUERY}`,
1576
+ "-F",
1577
+ `owner=${owner}`,
1578
+ "-F",
1579
+ `name=${name}`,
1580
+ "-F",
1581
+ `limit=${limit}`,
1582
+ ],
1583
+ { encoding: "utf8" }
1584
+ );
1585
+ const nodes = JSON.parse(raw)?.data?.repository?.pullRequests?.nodes ?? [];
1586
+ return nodes.map(node =>
1587
+ (
1588
+ node?.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ??
1589
+ []
1590
+ ).map(context =>
1591
+ context?.__typename === "CheckRun"
1592
+ ? normalizeCheckRow(context.name, context.conclusion, context.title)
1593
+ : normalizeCheckRow(
1594
+ context?.context,
1595
+ context?.state,
1596
+ context?.description
1597
+ )
1598
+ )
1599
+ );
1600
+ }
1601
+
1602
+ /**
1603
+ * Samples how often the review gate waives, best-effort and never fatal.
1604
+ *
1605
+ * A failed sample must never change the verdict. The rate is CONTEXT for a
1606
+ * decision the gate has already made from this pull request's own evidence, so
1607
+ * an unreachable API costs an operator one sentence rather than a red build —
1608
+ * and the absent sentence is the honest rendering of "not measured", which is
1609
+ * what the caller prints in its place.
1610
+ *
1611
+ * @param {object} declaration - The per-repo declaration
1612
+ * @param {number} limit - How many merged pull requests to read; 0 disables
1613
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
1614
+ * @param {(limit: number, repo?: string) => Array<Array<object>>} [fetch] - Injection seam
1615
+ * @returns {{sampled: number, waived: number, satisfied: number, unsatisfied: number}|undefined} The tally, or undefined when it could not be taken
1616
+ */
1617
+ export function sampleWaiveRate(declaration, limit, repo, fetch) {
1618
+ if (!Number.isInteger(limit) || limit <= 0) return undefined;
1619
+ try {
1620
+ const perPullRequest = (fetch ?? fetchRecentMergedChecks)(limit, repo);
1621
+ return summarizeWaiveRate(
1622
+ perPullRequest.map(
1623
+ checks => evaluateReviewGate(declaration, checks).states
1624
+ )
1625
+ );
1626
+ } catch {
1627
+ return undefined;
1628
+ }
1629
+ }
1630
+
1631
+ /**
1632
+ * Publishes the verdict where a workflow step can read it.
1633
+ *
1634
+ * `$GITHUB_OUTPUT` rather than stdout because the consumer is the next step in
1635
+ * the job, which turns it into a check run — the surface a merge decision
1636
+ * actually consults. Best-effort for the same reason the step summary is:
1637
+ * losing the rendering must not invert the exit code the guard worked to earn.
1638
+ *
1639
+ * @param {{verdict: string, conclusion: string, title: string}} verdict - What to publish
1640
+ * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
1641
+ * @returns {boolean} True when the outputs were written
1642
+ */
1643
+ export function writeVerdictOutputs(verdict, env = process.env) {
1644
+ const target = env.GITHUB_OUTPUT;
1645
+ if (target === undefined || target === "") return false;
1646
+ try {
1647
+ appendFileSync(
1648
+ target,
1649
+ `review_evidence_verdict=${verdict.verdict}\nreview_evidence_conclusion=${verdict.conclusion}\nreview_evidence_title=${verdict.title}\n`
1650
+ );
1651
+ return true;
1652
+ } catch (error) {
1653
+ console.error(
1654
+ `[skipped-required-checks] review verdict outputs not written: ${error.message}`
1655
+ );
1656
+ return false;
1657
+ }
1324
1658
  }
1325
1659
 
1326
1660
  /**
@@ -1917,6 +2251,26 @@ export function vacuityRefusal(input) {
1917
2251
  return null;
1918
2252
  }
1919
2253
 
2254
+ /**
2255
+ * Reads a `--name=<integer>` flag, falling back when absent or unreadable.
2256
+ *
2257
+ * Separate from the seconds reader because the fallback for an unreadable value
2258
+ * differs in kind: a bad timeout should still wait, while a bad sample size must
2259
+ * NOT silently become the shipped default and start making network calls a
2260
+ * caller did not ask for. Both clamp at zero.
2261
+ *
2262
+ * @param {ReadonlyArray<string>} argv - CLI arguments
2263
+ * @param {string} name - The flag, including its leading dashes
2264
+ * @param {number} fallback - Value to use when the flag is absent or unreadable
2265
+ * @returns {number} A non-negative integer
2266
+ */
2267
+ function readIntegerFlag(argv, name, fallback) {
2268
+ const raw = readFlagValue(argv, name);
2269
+ if (raw === undefined) return fallback;
2270
+ const value = Number(raw);
2271
+ return Number.isInteger(value) && value >= 0 ? value : fallback;
2272
+ }
2273
+
1920
2274
  /**
1921
2275
  * Reads a `--name=<seconds>` flag, falling back when absent or unreadable.
1922
2276
  *
@@ -1968,8 +2322,19 @@ export function inspectVacuity(argv, declaration, options = {}) {
1968
2322
  violations: [],
1969
2323
  settled: false,
1970
2324
  };
2325
+ /**
2326
+ * Attaches the published verdict to a refusal, which must not render as green.
2327
+ *
2328
+ * @param {{kind: string, reason: string}|null} refusal - Why nothing was read
2329
+ * @returns {object} The refusal result, verdict included
2330
+ */
2331
+ const refused = refusal => ({
2332
+ ...empty,
2333
+ refusal,
2334
+ verdict: reviewGateVerdict({ refusal }),
2335
+ });
1971
2336
  if (pr === undefined) {
1972
- return { ...empty, refusal: vacuityRefusal({ declaration, pr }) };
2337
+ return refused(vacuityRefusal({ declaration, pr }));
1973
2338
  }
1974
2339
 
1975
2340
  const repo = readFlagValue(argv, "--repo");
@@ -1992,16 +2357,15 @@ export function inspectVacuity(argv, declaration, options = {}) {
1992
2357
  headSha: options.headSha,
1993
2358
  });
1994
2359
  } catch (error) {
1995
- return { ...empty, refusal: vacuityRefusal({ declaration, pr, error }) };
2360
+ return refused(vacuityRefusal({ declaration, pr, error }));
1996
2361
  }
1997
2362
 
1998
2363
  const refusal = vacuityRefusal({ declaration, pr, checks: read.checks });
1999
2364
  if (refusal !== null) {
2000
2365
  return {
2001
- ...empty,
2366
+ ...refused(refusal),
2002
2367
  headSha: read.headSha,
2003
2368
  settled: read.settled,
2004
- refusal,
2005
2369
  };
2006
2370
  }
2007
2371
  const evaluated = evaluateVacuousChecks(declaration, read.checks, {
@@ -2015,6 +2379,20 @@ export function inspectVacuity(argv, declaration, options = {}) {
2015
2379
  const gate = evaluateReviewGate(declaration, read.checks, {
2016
2380
  headSha: read.headSha,
2017
2381
  });
2382
+ // Sampled ONLY when this pull request is itself waived. The rate answers
2383
+ // "is this the exception or the rule?", a question that only arises once a
2384
+ // waiver is on the table, and every other run keeps the sampler's network
2385
+ // call — and its failure modes — out of the job entirely.
2386
+ const waiveRate = Object.values(gate.states).includes(
2387
+ REVIEW_GATE_STATES.waived
2388
+ )
2389
+ ? sampleWaiveRate(
2390
+ declaration,
2391
+ readIntegerFlag(argv, WAIVE_RATE_SAMPLE_FLAG, 0),
2392
+ repo,
2393
+ options.sampleChecks
2394
+ )
2395
+ : undefined;
2018
2396
  return {
2019
2397
  pr,
2020
2398
  prSource: source,
@@ -2022,6 +2400,13 @@ export function inspectVacuity(argv, declaration, options = {}) {
2022
2400
  checked: evaluated.checked,
2023
2401
  violations: [...evaluated.violations, ...gate.violations],
2024
2402
  gateStates: gate.states,
2403
+ gateDescriptions: gate.descriptions,
2404
+ waiveRate,
2405
+ verdict: reviewGateVerdict({
2406
+ states: gate.states,
2407
+ descriptions: gate.descriptions,
2408
+ waiveRate,
2409
+ }),
2025
2410
  settled: read.settled,
2026
2411
  refusal: null,
2027
2412
  };
@@ -2081,6 +2466,8 @@ export function runGuard(argv, options = {}) {
2081
2466
  recipe: transcriptionRecipe(declaration),
2082
2467
  pr: vacuity?.pr,
2083
2468
  evidenceChecked: vacuity?.checked ?? 0,
2469
+ verdict: vacuity?.verdict,
2470
+ waiveRate: vacuity?.waiveRate,
2084
2471
  vacuity,
2085
2472
  };
2086
2473
  }
@@ -2141,6 +2528,11 @@ function main(argv) {
2141
2528
  : !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
2142
2529
  };
2143
2530
  const blocking = result.violations.filter(blocks);
2531
+ // Published BEFORE the report and before the exit code, because it is the only
2532
+ // artefact of this run that a merge decision reads. A verdict computed after a
2533
+ // `return` on some branch is a verdict that exists on the happy path only.
2534
+ const verdict = result.verdict;
2535
+ if (verdict !== undefined) writeVerdictOutputs(verdict);
2144
2536
  const refusal = result.vacuity?.refusal ?? null;
2145
2537
  const refusalBlocks =
2146
2538
  refusal !== null && (!warnOnly || requireReviewEvidence);
@@ -2169,6 +2561,22 @@ function main(argv) {
2169
2561
 
2170
2562
  const lines = ["## 🔒 Required checks that prove nothing", ""];
2171
2563
 
2564
+ // The verdict is the FIRST line of the report and not a footnote, and it is
2565
+ // the same string published to the check run, so the two surfaces cannot
2566
+ // drift into disagreeing about what this run concluded.
2567
+ if (verdict !== undefined) {
2568
+ lines.push(
2569
+ `**${verdict.title}**`,
2570
+ "",
2571
+ `Published as check-run conclusion \`${verdict.conclusion}\`. A \`neutral\` conclusion is how a WAIVED pull request is kept out of the shape a satisfied one prints — the waiver still blocks nothing, and it no longer looks like a review.`,
2572
+ "",
2573
+ result.waiveRate === undefined
2574
+ ? "The waive rate over recently merged pull requests was NOT sampled on this run, so no rate is claimed here."
2575
+ : `Recently merged pull requests sampled: ${result.waiveRate.sampled} — ${result.waiveRate.satisfied} reviewed, ${result.waiveRate.waived} waived, ${result.waiveRate.unsatisfied} unsatisfied.`,
2576
+ ""
2577
+ );
2578
+ }
2579
+
2172
2580
  // The refusal comes FIRST and replaces the verdict. Printing "✅ none
2173
2581
  // silences a required check" from a snapshot nobody transcribed is the one
2174
2582
  // outcome that is worse than never running: it is a confident wrong answer,