@codyswann/lisa 4.33.5 → 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 (58) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  5. package/dist/core/upstream-evidence-manifest.js +2 -2
  6. package/package.json +4 -4
  7. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa-agy/plugin.json +1 -1
  10. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-agy/plugin.json +1 -1
  20. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  40. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-agy/plugin.json +1 -1
  45. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  50. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  55. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  57. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +414 -6
  58. package/typescript/create-only/.github/workflows/review-evidence.yml +78 -3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.33.5",
3
+ "version": "4.33.6",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -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,