@codyswann/lisa 4.33.4 → 4.33.6

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 (88) hide show
  1. package/dist/configs/eslint/expo.d.ts.map +1 -1
  2. package/dist/configs/eslint/expo.js +4 -7
  3. package/dist/configs/eslint/expo.js.map +1 -1
  4. package/dist/configs/eslint/view-gate.d.ts +42 -0
  5. package/dist/configs/eslint/view-gate.d.ts.map +1 -0
  6. package/dist/configs/eslint/view-gate.js +110 -0
  7. package/dist/configs/eslint/view-gate.js.map +1 -0
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +20 -9
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/eslint-plugin-component-structure/README.md +85 -2
  16. package/eslint-plugin-component-structure/__tests__/no-hooks-in-view.test.js +229 -0
  17. package/eslint-plugin-component-structure/__tests__/no-return-in-view.test.js +185 -0
  18. package/eslint-plugin-component-structure/__tests__/plugin-index.test.js +8 -1
  19. package/eslint-plugin-component-structure/index.js +4 -1
  20. package/eslint-plugin-component-structure/lib/view-file-scope.js +62 -0
  21. package/eslint-plugin-component-structure/rules/no-hooks-in-view.js +101 -0
  22. package/eslint-plugin-component-structure/rules/no-return-in-view.js +133 -62
  23. package/expo/copy-overwrite/eslint.expo.ts +88 -1
  24. package/package.json +4 -4
  25. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/SKILL.md +80 -24
  38. package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/scripts/validate_component.py +166 -43
  39. package/plugins/lisa-expo/skills/container-view-pattern/SKILL.md +80 -24
  40. package/plugins/lisa-expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
  41. package/plugins/lisa-expo-agy/plugin.json +1 -1
  42. package/plugins/lisa-expo-agy/skills/container-view-pattern/SKILL.md +80 -24
  43. package/plugins/lisa-expo-agy/skills/container-view-pattern/scripts/validate_component.py +166 -43
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-copilot/skills/container-view-pattern/SKILL.md +80 -24
  46. package/plugins/lisa-expo-copilot/skills/container-view-pattern/scripts/validate_component.py +166 -43
  47. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-cursor/skills/container-view-pattern/SKILL.md +80 -24
  49. package/plugins/lisa-expo-cursor/skills/container-view-pattern/scripts/validate_component.py +166 -43
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/src/expo/skills/container-view-pattern/SKILL.md +80 -24
  86. package/plugins/src/expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
  87. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +414 -6
  88. package/typescript/create-only/.github/workflows/review-evidence.yml +78 -3
@@ -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,
@@ -30,6 +30,25 @@
30
30
  # `Review skipped: manual review required for this OSS repository`. They stay
31
31
  # loud and nonblocking because a pull request author cannot fix the entitlement.
32
32
  #
33
+ # AND "LOUD" IS A RENDERING, NOT A LOG LINE (#3639). This job exits 0 for a
34
+ # satisfied gate and 0 for a waived one, so on its own `gh pr checks` prints
35
+ # `pass` for a pull request nothing read, character for character what it
36
+ # prints for a reviewed one. Measured on the repository that ships this: 39 of
37
+ # the last 40 merged pull requests waived, which makes the waiver the DEFAULT
38
+ # path rather than an exception. The second step republishes the verdict as its
39
+ # own check run whose conclusion the guard chose — `success` reviewed,
40
+ # `neutral` WAIVED, `failure` unsatisfied. `neutral` blocks nothing and buckets
41
+ # as `skipping`, so the waiver keeps its rationale and loses its disguise. Do
42
+ # not "simplify" that step away, and do not turn the waiver into a failure:
43
+ # a gate that reddens every pull request on a vendor's billing state gets
44
+ # deleted, and then nothing is watching at all.
45
+ #
46
+ # `--waive-rate-sample=<N>` samples recently merged pull requests and reports
47
+ # how many of them waived, in the job summary and in the verdict title. It runs
48
+ # ONLY when this pull request is itself waived, is one GraphQL call, and is
49
+ # best-effort: a failed sample costs one sentence, never the verdict. Omit the
50
+ # flag to disable the sample entirely.
51
+ #
33
52
  # A rate-limit queue may be roughly hour-long. Agentic delivery retries through
34
53
  # it, with an auditable admin-merge only at the exterior governance gate after
35
54
  # every other required check and independent review has passed.
@@ -70,7 +89,11 @@ on:
70
89
  permissions:
71
90
  contents: read
72
91
  actions: read
73
- checks: read
92
+ # `checks: write` is what publishing the verdict needs. A check run is the
93
+ # only artefact that can carry a `neutral` conclusion, and `neutral` is the
94
+ # only rendering that is neither a pass nor a failure; a commit status has no
95
+ # such state, so a waiver posted that way would be a `pass` again.
96
+ checks: write
74
97
  statuses: read
75
98
  pull-requests: read
76
99
 
@@ -102,6 +125,7 @@ jobs:
102
125
  node-version: '22.21.1'
103
126
 
104
127
  - name: 🕵️ Refuse a required check that reviewed nothing
128
+ id: review_evidence
105
129
  env:
106
130
  GH_TOKEN: ${{ github.token }}
107
131
  # Absent on a `pull_request` run, where the guard reads the event
@@ -124,7 +148,58 @@ jobs:
124
148
  exit 1
125
149
  fi
126
150
  if [ -n "${VACUITY_PR:-}" ]; then
127
- node scripts/check-skipped-required-checks.mjs --vacuity --require-review-evidence --pr="$VACUITY_PR"
151
+ node scripts/check-skipped-required-checks.mjs --vacuity --require-review-evidence --waive-rate-sample=40 --pr="$VACUITY_PR"
128
152
  else
129
- node scripts/check-skipped-required-checks.mjs --vacuity --require-review-evidence
153
+ node scripts/check-skipped-required-checks.mjs --vacuity --require-review-evidence --waive-rate-sample=40
154
+ fi
155
+
156
+ # ---------------------------------------------------------------------
157
+ # The point of #3639: render the verdict where a merge decision reads it.
158
+ # ---------------------------------------------------------------------
159
+ # The job above exits 0 for a SATISFIED gate and 0 for a WAIVED one, so
160
+ # `gh pr checks` printed `pass` for a pull request nothing had read,
161
+ # character for character what it prints for a reviewed one. The waiver
162
+ # was honest in the run log and invisible at the only layer anybody
163
+ # consults before pressing merge; measured on this repository, 39 of the
164
+ # last 40 merged pull requests took that path, so the disguise was the
165
+ # DEFAULT rendering rather than an edge case.
166
+ #
167
+ # This step republishes the same verdict as its own check run, whose
168
+ # conclusion the guard chose: `success` reviewed, `neutral` WAIVED,
169
+ # `failure` unsatisfied. `gh pr checks` buckets NEUTRAL as `skipping`, so
170
+ # a waiver stops looking like a pass without acquiring the power to block
171
+ # — the waiver's rationale is untouched, because a pull request author
172
+ # still cannot fix a vendor's billing state.
173
+ #
174
+ # `continue-on-error` and `if: always()`: this is RENDERING, downstream of
175
+ # a verdict already reached and already exited on. A fork pull request
176
+ # gets a read-only token and cannot write a check run at all, and losing
177
+ # the rendering there must not invert the verdict above it.
178
+ - name: 🕵️ Publish the review verdict where a merge decision reads it
179
+ if: always() && steps.review_evidence.outputs.review_evidence_conclusion != ''
180
+ continue-on-error: true
181
+ env:
182
+ GH_TOKEN: ${{ github.token }}
183
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
184
+ # Empty on the `workflow_dispatch` path, where there is no
185
+ # `pull_request` payload to read a head SHA from. The step resolves it
186
+ # from the dispatched number instead, so it needs the number too.
187
+ VACUITY_PR: ${{ inputs.pr }}
188
+ VERDICT_CONCLUSION: ${{ steps.review_evidence.outputs.review_evidence_conclusion }}
189
+ VERDICT_TITLE: ${{ steps.review_evidence.outputs.review_evidence_title }}
190
+ run: |
191
+ set -eu
192
+ if [ -z "${HEAD_SHA:-}" ]; then
193
+ HEAD_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${VACUITY_PR}" --jq .head.sha)"
130
194
  fi
195
+ gh api "repos/${GITHUB_REPOSITORY}/check-runs" \
196
+ --method POST \
197
+ --field name='🕵️ Review evidence verdict' \
198
+ --field head_sha="$HEAD_SHA" \
199
+ --field status=completed \
200
+ --field conclusion="$VERDICT_CONCLUSION" \
201
+ --field output[title]="$VERDICT_TITLE" \
202
+ --field output[summary]="$VERDICT_TITLE
203
+
204
+ Published by the \`🕵️ Review Evidence\` workflow. A \`neutral\` conclusion here means WAIVED: a declared review check said in its own words that it could not review, this pull request is UNREVIEWED, and merging it is a decision taken on that basis. It is deliberately not a failure — a pull request author cannot fix a vendor entitlement — and deliberately not a pass." \
205
+ > /dev/null