@codyswann/lisa 3.7.0 โ†’ 3.8.1

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 (94) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +21 -12
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/dist/health/ruleset-inspection.d.ts.map +1 -1
  5. package/dist/health/ruleset-inspection.js +106 -1
  6. package/dist/health/ruleset-inspection.js.map +1 -1
  7. package/expo/create-only/.github/required-checks.json +9 -1
  8. package/nestjs/create-only/.github/required-checks.json +9 -1
  9. package/package.json +1 -1
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  13. package/plugins/lisa/.codex-plugin/skills/lisa-pull-request-review/SKILL.md +50 -2
  14. package/plugins/lisa/rules/eager/falsifiable-checks.md +3 -1
  15. package/plugins/lisa/rules/reference/derived-branch-plan.md +18 -2
  16. package/plugins/lisa/rules/reference/falsifiable-checks.md +57 -5
  17. package/plugins/lisa/scripts/design-source-gate.mjs +11 -2
  18. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  19. package/plugins/lisa/skills/lisa-pull-request-review/SKILL.md +50 -2
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/scripts/design-source-gate.mjs +11 -2
  22. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  23. package/plugins/lisa-agy/skills/lisa-pull-request-review/SKILL.md +50 -2
  24. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +3 -1
  31. package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +18 -2
  32. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +57 -5
  33. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +11 -2
  34. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  35. package/plugins/lisa-copilot/skills/lisa-pull-request-review/SKILL.md +50 -2
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +18 -2
  38. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +57 -5
  39. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +3 -1
  40. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +11 -2
  41. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  42. package/plugins/lisa-cursor/skills/lisa-pull-request-review/SKILL.md +50 -2
  43. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-agy/plugin.json +1 -1
  46. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  66. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-agy/plugin.json +1 -1
  71. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  76. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  81. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/src/base/rules/eager/falsifiable-checks.md +3 -1
  84. package/plugins/src/base/rules/reference/derived-branch-plan.md +18 -2
  85. package/plugins/src/base/rules/reference/falsifiable-checks.md +57 -5
  86. package/plugins/src/base/scripts/design-source-gate.mjs +11 -2
  87. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  88. package/plugins/src/base/skills/lisa-pull-request-review/SKILL.md +50 -2
  89. package/scripts/lisa-github-rulesets.sh +77 -0
  90. package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member-js.yml +71 -0
  91. package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member.yml +90 -0
  92. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +415 -9
  93. package/typescript/create-only/.github/required-checks.json +15 -4
  94. package/typescript/package-lisa/package.lisa.json +2 -1
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * check-skipped-required-checks โ€” refuse a `skip_jobs` token that silences a
4
- * ruleset-required status check.
3
+ * check-skipped-required-checks โ€” refuse a required status check that satisfies
4
+ * without proving anything.
5
5
  *
6
6
  * Shipped by Lisa (copy-overwrite). Generalized from tunnl's TUN-402 guard: the
7
7
  * logic is Lisa's and gets updated fleet-wide, the two REVIEWED SNAPSHOTS it
@@ -10,6 +10,23 @@
10
10
  *
11
11
  * Usage:
12
12
  * node scripts/check-skipped-required-checks.mjs [rootDir] [--remote] [--json]
13
+ * node scripts/check-skipped-required-checks.mjs --pr=1234 [--repo=OWNER/NAME]
14
+ *
15
+ * ## The family this guard covers
16
+ *
17
+ * **Required-and-red is loud; required-and-vacuous is not; advisory-and-stale is
18
+ * invisible.** All three are the same defect wearing different clothes โ€” a gate
19
+ * that reports satisfied without having proven anything โ€” and the useful
20
+ * question is never "did the check pass" but "did the check do anything".
21
+ *
22
+ * Two of the three live here:
23
+ *
24
+ * - **Skipped** (`--remote` / offline arm, below): GitHub counts a `skipped`
25
+ * required check as SATISFIED, so a `skip_jobs` token makes the gate
26
+ * decorative. Static, offline, BLOCKING.
27
+ * - **Vacuous** (`--pr` arm): the check really ran and really reported
28
+ * `success`, having done no work โ€” measured on CodeRabbit posting
29
+ * `success โ€” "Review rate limited"`. Live, per-PR, REPORTING ONLY.
13
30
  *
14
31
  * ## Where this runs
15
32
  *
@@ -96,14 +113,74 @@
96
113
  * network and `gh` auth on every run would flake, and a flaky guard gets
97
114
  * skipped โ€” which reintroduces exactly the false-green class this file refuses.
98
115
  *
116
+ * ## `--pr` โ€” the VACUOUS arm, and why it only ever reports
117
+ *
118
+ * Measured (CodySwannGT/lisa#2497): `CodeRabbit` was in this repository's
119
+ * required set, and on PRs #2483 and #2484 it posted `success` with the
120
+ * description `Review rate limited` having performed ZERO reviews. Both merged
121
+ * on that green, both carried security-relevant changes, both shipped in tag
122
+ * `v3.5.1`. Branch protection recorded "reviewed" for work nothing reviewed.
123
+ *
124
+ * The failure is silent by construction, and this is the whole point:
125
+ *
126
+ * ```
127
+ * gh pr checks <PR> | grep -i coderabbit
128
+ * CodeRabbit pass 0 Review rate limited <- hollow
129
+ * CodeRabbit pass 1 Review completed <- real
130
+ * ```
131
+ *
132
+ * **The status column says `pass` either way. Only the description
133
+ * distinguishes them.** So anything gating on such a check must read the
134
+ * description, and `--pr` is the machine-readable form of that one-line triage.
135
+ *
136
+ * This arm NEVER blocks โ€” `NEVER_BLOCKING`, enforced regardless of the
137
+ * declaration's `enforcement` mode. Two independent reasons, both load-bearing:
138
+ *
139
+ * 1. A review bot's availability can depend on an org-wide SPENDING CAP. A
140
+ * blocking check that fires on a billing state makes merges hostage to
141
+ * accounting, which is a worse gate than the one it replaces.
142
+ * 2. Whether a review bot belongs in the required set at all is a governance
143
+ * decision an owner has to make. Shipping the gate before the decision
144
+ * would pre-empt it. Detection is what is uncontroversial; act on it.
145
+ *
146
+ * ## Proof is matched STRICTLY, no-work LOOSELY
147
+ *
148
+ * The two description lists are deliberately asymmetric, because their errors
149
+ * are not symmetric:
150
+ *
151
+ * - A `proof` phrase must match the whole description (case-insensitive,
152
+ * trimmed). Matching here GRANTS CREDIT, and a loose match that grants
153
+ * credit is exactly the false green this file exists to refuse.
154
+ * - A `no_work` phrase matches as a substring. Matching here DENIES credit,
155
+ * so breadth is safe โ€” and it survives a vendor appending detail
156
+ * (`Review rate limited (retry in 12m)`).
157
+ *
158
+ * Anything matching neither is `unproven` โ€” reported, never silently passed. A
159
+ * vocabulary nobody enumerated must not read as a pass.
160
+ *
161
+ * Unlike `required_contexts`, this vocabulary is NOT repo-specific: `Review
162
+ * rate limited` is the vendor's own product string, identical in every
163
+ * repository. That is why shipping it as a default is safe where shipping a
164
+ * guessed ruleset was not (#2476) โ€” and why a wrong guess here costs one line
165
+ * of report rather than a red build.
166
+ *
99
167
  * ## Exact string equality, everywhere
100
168
  *
101
169
  * Every comparison here is `===`. Repos routinely carry confusable pairs โ€” an
102
170
  * external app's required `SonarCloud Code Analysis` beside a skippable,
103
- * NOT-required in-workflow `๐Ÿ” SonarCloud SAST`; `๐Ÿงช Run Tests` beside
104
- * `๐Ÿงช Run Unit Tests`. A `includes` / `startsWith` / case-folded match would
105
- * report a false positive on a legitimate skip, and the natural fix for a false
106
- * alarm is to delete the guard.
171
+ * NOT-required in-workflow `๐Ÿ” SonarCloud SAST`; `๐Ÿงน Lint` beside
172
+ * `๐Ÿข Slow Lint Rules`, whose skip tokens `lint` and `lint_slow` are a strict
173
+ * prefix pair. A `includes` / `startsWith` / case-folded match would report a
174
+ * false positive on a legitimate skip, and the natural fix for a false alarm is
175
+ * to delete the guard.
176
+ *
177
+ * Lisa's `quality.yml` used to carry the worst pair of all โ€” a NOT-required
178
+ * `๐Ÿงช Run Tests` beside the required `๐Ÿงช Run Unit Tests` โ€” and it merged red on
179
+ * two PRs because "Run Tests failed" reads like the required test gate failing.
180
+ * That job was deleted outright in #2485 rather than renamed: it was pure
181
+ * duplication of the two coverage-carrying required contexts. If your own repo
182
+ * still has a near-duplicate advisory check, prefer deleting it to relying on a
183
+ * reader to tell three similar names apart.
107
184
  *
108
185
  * @module scripts/check-skipped-required-checks
109
186
  */
@@ -170,6 +247,55 @@ export const VIOLATIONS = Object.freeze({
170
247
  badExemption: "exemption_without_valid_ticket",
171
248
  remoteDrift: "ruleset_snapshot_drift",
172
249
  whitespace: "whitespace_in_skip_token",
250
+ vacuous: "vacuous_required_check",
251
+ unproven: "unproven_required_check",
252
+ });
253
+
254
+ /**
255
+ * The shipped description vocabulary for review-bot style checks.
256
+ *
257
+ * `proof` is matched STRICTLY (whole description, case-insensitive, trimmed)
258
+ * because a match grants credit. `no_work` is matched LOOSELY (substring)
259
+ * because a match denies it. See the header for why that asymmetry is the safe
260
+ * direction.
261
+ *
262
+ * Every string here was read off a real check on a real PR in this fleet, not
263
+ * invented: `Review rate limited` (#2483, #2484, #2495), `Review approved`
264
+ * (#2350). A repository may extend either list per check without losing these.
265
+ */
266
+ export const REVIEW_DESCRIPTION_DEFAULTS = Object.freeze({
267
+ proof: Object.freeze([
268
+ "review approved",
269
+ "review completed",
270
+ "changes requested",
271
+ "comments posted",
272
+ ]),
273
+ no_work: Object.freeze([
274
+ "rate limited",
275
+ "review queued",
276
+ "review skipped",
277
+ "skipped",
278
+ "queued",
279
+ "waiting",
280
+ "in progress",
281
+ "no review",
282
+ "disabled",
283
+ "quota",
284
+ "billing",
285
+ ]),
286
+ });
287
+
288
+ /**
289
+ * Verdicts `classifyCheckDescription` returns.
290
+ *
291
+ * `unproven` is the FALLBACK on purpose: the absence of a recognised phrase is
292
+ * the absence of evidence, and this file's whole thesis is that those are not
293
+ * the same as a pass.
294
+ */
295
+ export const DESCRIPTION_VERDICTS = Object.freeze({
296
+ proved: "proved",
297
+ noWork: "no-work",
298
+ unproven: "unproven",
173
299
  });
174
300
 
175
301
  /** Enforcement modes a declaration may select. */
@@ -195,6 +321,50 @@ const ALWAYS_BLOCKING = Object.freeze([
195
321
  VIOLATIONS.remoteDrift,
196
322
  ]);
197
323
 
324
+ /**
325
+ * Violation kinds that NEVER fail the build, in any enforcement mode.
326
+ *
327
+ * The vacuity arm reports and stops there. A required check can go hollow
328
+ * because a vendor hit an org-wide SPENDING CAP, and a gate that reddens every
329
+ * PR the moment a bill goes unpaid is a worse gate than the one it is
330
+ * criticising. Whether such a check belongs in the required set at all is a
331
+ * governance decision an owner makes in an admin console, not one this script
332
+ * may pre-empt by turning its own finding into a blocker.
333
+ *
334
+ * Detection is the uncontroversial half, and it is the half that was missing:
335
+ * nothing anywhere could previously tell "the check reported success" apart
336
+ * from "the check did anything".
337
+ *
338
+ * This list is checked BEFORE `ALWAYS_BLOCKING` and before the enforcement
339
+ * mode, so deleting the `enforcement` key cannot silently arm it.
340
+ */
341
+ export const NEVER_BLOCKING = Object.freeze([
342
+ VIOLATIONS.vacuous,
343
+ VIOLATIONS.unproven,
344
+ ]);
345
+
346
+ /**
347
+ * Reads `--name=value` or `--name value` out of argv.
348
+ *
349
+ * Returns `undefined` for an absent flag and for `--name` with no value, so a
350
+ * typo cannot be read as an empty PR number and silently examine nothing.
351
+ *
352
+ * @param {ReadonlyArray<string>} argv - CLI arguments
353
+ * @param {string} name - The flag, including its leading dashes
354
+ * @returns {string|undefined} The value, or undefined
355
+ */
356
+ export function readFlagValue(argv, name) {
357
+ const inline = argv.find(arg => arg.startsWith(`${name}=`));
358
+ if (inline !== undefined) {
359
+ const value = inline.slice(name.length + 1).trim();
360
+ return value === "" ? undefined : value;
361
+ }
362
+ const at = argv.indexOf(name);
363
+ if (at === -1) return undefined;
364
+ const next = argv[at + 1];
365
+ return next === undefined || next.startsWith("--") ? undefined : next;
366
+ }
367
+
198
368
  /**
199
369
  * True when a line is a whole-line YAML comment.
200
370
  *
@@ -403,6 +573,30 @@ export function loadDeclaration(rootDir) {
403
573
  );
404
574
  }
405
575
  }
576
+
577
+ // Same reasoning for the vacuity declarations: a non-object entry would read
578
+ // as "declared" and then yield an empty vocabulary, quietly examining the
579
+ // check against defaults the author thought they had overridden.
580
+ for (const [name, entry] of Object.entries(
581
+ declaration.evidence_bearing_checks ?? {}
582
+ )) {
583
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
584
+ throw new Error(
585
+ `check-skipped-required-checks: the declaration for \`${name}\` in \`evidence_bearing_checks\` must be an object โ€” use \`{}\` to accept the shipped description vocabulary.`
586
+ );
587
+ }
588
+ for (const list of ["proof", "no_work"]) {
589
+ if (entry[list] === undefined) continue;
590
+ if (
591
+ !Array.isArray(entry[list]) ||
592
+ entry[list].some(phrase => typeof phrase !== "string")
593
+ ) {
594
+ throw new Error(
595
+ `check-skipped-required-checks: \`evidence_bearing_checks.${name}.${list}\` must be an array of description strings.`
596
+ );
597
+ }
598
+ }
599
+ }
406
600
  return declaration;
407
601
  }
408
602
 
@@ -616,6 +810,184 @@ export function evaluateSkippedRequiredChecks(
616
810
  return { violations, checked: skipped.length };
617
811
  }
618
812
 
813
+ /**
814
+ * Decides whether a check's description proves the check did any work.
815
+ *
816
+ * @param {string|undefined} description - The check's description, verbatim
817
+ * @param {{proof?: ReadonlyArray<string>, no_work?: ReadonlyArray<string>}} [vocabulary] -
818
+ * Per-check additions. Merged WITH the shipped defaults rather than replacing
819
+ * them, so a repository naming one extra proof phrase does not silently lose
820
+ * the no-work list that catches the measured defect.
821
+ * @returns {string} One of `DESCRIPTION_VERDICTS`
822
+ */
823
+ export function classifyCheckDescription(description, vocabulary = {}) {
824
+ const text = (description ?? "").trim().toLowerCase();
825
+ if (text === "") return DESCRIPTION_VERDICTS.unproven;
826
+
827
+ // No-work is tested FIRST. The lists are asserted non-overlapping in the
828
+ // suite, so order cannot change a verdict today โ€” testing the denying rule
829
+ // first means a future overlap fails safe (denied) rather than granting
830
+ // credit, which is the direction that matters.
831
+ const noWork = [
832
+ ...REVIEW_DESCRIPTION_DEFAULTS.no_work,
833
+ ...(vocabulary.no_work ?? []),
834
+ ];
835
+ if (noWork.some(phrase => text.includes(phrase.trim().toLowerCase()))) {
836
+ return DESCRIPTION_VERDICTS.noWork;
837
+ }
838
+
839
+ const proof = [
840
+ ...REVIEW_DESCRIPTION_DEFAULTS.proof,
841
+ ...(vocabulary.proof ?? []),
842
+ ];
843
+ if (proof.some(phrase => text === phrase.trim().toLowerCase())) {
844
+ return DESCRIPTION_VERDICTS.proved;
845
+ }
846
+ return DESCRIPTION_VERDICTS.unproven;
847
+ }
848
+
849
+ /**
850
+ * Reports every declared evidence-bearing check that satisfied without proving
851
+ * it did work.
852
+ *
853
+ * A check is examined only when the repository named it in
854
+ * `evidence_bearing_checks` โ€” matched by EXACT name, like every other
855
+ * comparison in this file. Most CI jobs ship an empty description, so
856
+ * flagging them all would bury the one finding that matters, and the obvious
857
+ * fix for a noisy guard is to delete it.
858
+ *
859
+ * Four outcomes per declared check:
860
+ *
861
+ * - Green + a `proof` description โ†’ nothing. This is the case the whole
862
+ * machine exists to reach.
863
+ * - Green + a `no_work` description โ†’ `vacuous_required_check`. The measured
864
+ * #2483/#2484 defect.
865
+ * - Green + anything else โ†’ `unproven_required_check`. Not an accusation: a
866
+ * statement that this run produced no evidence either way.
867
+ * - Absent entirely โ†’ `unproven_required_check`. Measured on #2493/#2491/
868
+ * #2488, where the bot posted no context at all; "no unresolved review
869
+ * threads" there means nobody looked, not that nothing was wrong.
870
+ *
871
+ * A RED check is deliberately ignored. Required-and-red is the loud case and
872
+ * needs no help from here; reporting it too would make this arm indistinguish-
873
+ * able from ordinary CI noise.
874
+ *
875
+ * `required_contexts` changes only the WORDING โ€” whether branch protection
876
+ * actually recorded this hollow green as a satisfied gate. When that snapshot
877
+ * is untrusted the finding still stands; the guard just declines to claim
878
+ * required-ness it has not transcribed.
879
+ *
880
+ * @param {object} declaration - The per-repo declaration
881
+ * @param {ReadonlyArray<{name: string, state: string, bucket?: string, description?: string}>} checks -
882
+ * Checks as `gh pr checks --json name,state,bucket,description` returns them
883
+ * @param {{trustRequiredContexts?: boolean}} [options] - Set
884
+ * `trustRequiredContexts: false` to stop asserting whether a check is required
885
+ * @returns {{violations: object[], checked: number}} Violations and how many declared checks were examined
886
+ */
887
+ export function evaluateVacuousChecks(declaration, checks, options = {}) {
888
+ const declared = declaration.evidence_bearing_checks ?? {};
889
+ const trustRequired = options.trustRequiredContexts !== false;
890
+ const required = new Set(declaration.required_contexts ?? []);
891
+ const violations = [];
892
+ let checked = 0;
893
+
894
+ for (const [name, entry] of Object.entries(declared)) {
895
+ checked += 1;
896
+ const vocabulary = typeof entry === "object" && entry !== null ? entry : {};
897
+ const found = checks.find(check => check.name === name);
898
+
899
+ if (found === undefined) {
900
+ violations.push({
901
+ kind: VIOLATIONS.unproven,
902
+ token: name,
903
+ message: `\`${name}\` is declared evidence-bearing but did not report on this pull request at all. A report of "no unresolved review threads" from this PR means NOBODY LOOKED, not that nothing was wrong โ€” say which one you observed. (If the context was renamed, fix \`evidence_bearing_checks\`; names are compared byte for byte.)`,
904
+ });
905
+ continue;
906
+ }
907
+
908
+ const state = String(found.state ?? "").toUpperCase();
909
+ if (state === "FAILURE" || state === "ERROR") continue;
910
+
911
+ const verdict = classifyCheckDescription(found.description, vocabulary);
912
+ if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
913
+ continue;
914
+ }
915
+
916
+ const requiredNote = !trustRequired
917
+ ? " Whether it is ruleset-required is NOT KNOWN here โ€” `required_contexts` has not been transcribed, so this cannot say what the merge gate recorded."
918
+ : required.has(name)
919
+ ? " This context IS ruleset-required, so branch protection recorded a satisfied review gate for a review that did not happen."
920
+ : " This context is not in `required_contexts`, so no merge gate was falsified โ€” but nothing reviewed this either.";
921
+
922
+ violations.push(
923
+ verdict === DESCRIPTION_VERDICTS.noWork && state === "SUCCESS"
924
+ ? {
925
+ kind: VIOLATIONS.vacuous,
926
+ token: name,
927
+ contexts: [name],
928
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which says it DID NO WORK.${requiredNote} \`gh pr checks\` prints \`pass\` for this exactly as it does for a real review โ€” the description is the only thing that tells them apart. Treat this PR as UNREVIEWED.`,
929
+ }
930
+ : {
931
+ kind: VIOLATIONS.unproven,
932
+ token: name,
933
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which proves neither that it reviewed anything nor that it did not.${requiredNote} Read the check itself before treating this PR as reviewed, or add the phrase to \`evidence_bearing_checks.${name}.proof\` once you have confirmed what it means.`,
934
+ }
935
+ );
936
+ }
937
+
938
+ return { violations, checked };
939
+ }
940
+
941
+ /**
942
+ * Reads one pull request's checks, descriptions included.
943
+ *
944
+ * `--json` is what makes this usable: the plain `gh pr checks` table is the
945
+ * human triage, and the description column is the load-bearing one, but only
946
+ * the JSON form survives being parsed. Both CheckRuns and legacy commit
947
+ * StatusContexts come back through this single call โ€” CodeRabbit posts the
948
+ * latter, which `gh pr view --json statusCheckRollup` returns WITHOUT a
949
+ * description, so that route cannot see the defect at all.
950
+ *
951
+ * A non-zero exit is expected and ignored: `gh pr checks` exits 8 while checks
952
+ * are pending and 1 when any check failed, and both are perfectly readable
953
+ * states for this arm. Only unparseable output is an error.
954
+ *
955
+ * @param {string|number} pr - Pull request number or URL
956
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
957
+ * @returns {Array<{name: string, state: string, bucket?: string, description?: string}>} The checks
958
+ * @throws {Error} When `gh` is unavailable or its output cannot be parsed
959
+ */
960
+ export function fetchPullRequestChecks(pr, repo) {
961
+ const args = [
962
+ "pr",
963
+ "checks",
964
+ String(pr),
965
+ "--json",
966
+ "name,state,bucket,description",
967
+ ];
968
+ if (repo) args.push("--repo", repo);
969
+ let raw;
970
+ try {
971
+ raw = execFileSync("gh", args, { encoding: "utf8" });
972
+ } catch (error) {
973
+ raw = typeof error?.stdout === "string" ? error.stdout : "";
974
+ if (raw.trim() === "") {
975
+ throw new Error(
976
+ `check-skipped-required-checks: could not read checks for PR ${pr}${repo ? ` in ${repo}` : ""} โ€” ${error instanceof Error ? error.message : String(error)}`
977
+ );
978
+ }
979
+ }
980
+ try {
981
+ const parsed = JSON.parse(raw);
982
+ if (!Array.isArray(parsed)) throw new TypeError("not an array");
983
+ return parsed;
984
+ } catch (error) {
985
+ throw new Error(
986
+ `check-skipped-required-checks: \`gh pr checks --json\` returned output this cannot parse (${error instanceof Error ? error.message : String(error)}). Refusing to report "nothing vacuous" from output nobody read.`
987
+ );
988
+ }
989
+ }
990
+
619
991
  /**
620
992
  * Fetches the live required contexts for every declared ruleset.
621
993
  *
@@ -693,6 +1065,7 @@ export function runGuard(argv) {
693
1065
  const positional = argv.filter(arg => !arg.startsWith("--"));
694
1066
  const rootDir = positional[0] ?? process.cwd();
695
1067
  const declaration = loadDeclaration(rootDir);
1068
+ const pr = readFlagValue(argv, "--pr");
696
1069
  const collected = collectSkipJobTokens(rootDir, declaration.workflows);
697
1070
  const remote = argv.includes("--remote");
698
1071
  const live = remote
@@ -714,6 +1087,21 @@ export function runGuard(argv) {
714
1087
  ...compareRulesetBaseline(declaration.required_contexts, live)
715
1088
  );
716
1089
  }
1090
+
1091
+ // The vacuity arm is layered ON TOP of the offline run rather than replacing
1092
+ // it: it is a third variant of one family, so it belongs in one report. Its
1093
+ // findings are `NEVER_BLOCKING`, so adding them cannot change the exit code
1094
+ // the offline arm would have produced on its own.
1095
+ const vacuity =
1096
+ pr === undefined
1097
+ ? undefined
1098
+ : evaluateVacuousChecks(
1099
+ declaration,
1100
+ fetchPullRequestChecks(pr, readFlagValue(argv, "--repo")),
1101
+ { trustRequiredContexts: trust.trusted }
1102
+ );
1103
+ if (vacuity !== undefined) violations.push(...vacuity.violations);
1104
+
717
1105
  return {
718
1106
  violations,
719
1107
  checked: result.checked,
@@ -721,6 +1109,8 @@ export function runGuard(argv) {
721
1109
  enforcement: declaration.enforcement ?? "error",
722
1110
  trust,
723
1111
  recipe: transcriptionRecipe(declaration),
1112
+ pr,
1113
+ evidenceChecked: vacuity?.checked ?? 0,
724
1114
  };
725
1115
  }
726
1116
 
@@ -774,9 +1164,10 @@ function main(argv) {
774
1164
  * @returns {boolean} True when it blocks
775
1165
  */
776
1166
  const blocks = violation =>
777
- !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
1167
+ !NEVER_BLOCKING.includes(violation.kind) &&
1168
+ (!warnOnly || ALWAYS_BLOCKING.includes(violation.kind));
778
1169
  const blocking = result.violations.filter(blocks);
779
- const lines = ["## ๐Ÿ”’ Skipped required checks", ""];
1170
+ const lines = ["## ๐Ÿ”’ Required checks that prove nothing", ""];
780
1171
 
781
1172
  // The refusal comes FIRST and replaces the verdict. Printing "โœ… none
782
1173
  // silences a required check" from a snapshot nobody transcribed is the one
@@ -803,7 +1194,12 @@ function main(argv) {
803
1194
  if (result.violations.length === 0) {
804
1195
  if (result.trust.trusted) {
805
1196
  lines.push(
806
- `โœ… ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
1197
+ `โœ… ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`,
1198
+ ...(result.pr === undefined
1199
+ ? []
1200
+ : [
1201
+ `โœ… ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.`,
1202
+ ])
807
1203
  );
808
1204
  } else {
809
1205
  lines.push(
@@ -827,6 +1223,16 @@ function main(argv) {
827
1223
  `This declaration sets \`"enforcement": "warn"\`, so everything above except a proven false green (\`${VIOLATIONS.suppressesRequired}\`) is reported without failing the build. Review each finding, fix or declare it, then delete the \`enforcement\` key so this guard can block.`
828
1224
  );
829
1225
  }
1226
+ if (
1227
+ result.violations.some(violation =>
1228
+ NEVER_BLOCKING.includes(violation.kind)
1229
+ )
1230
+ ) {
1231
+ lines.push(
1232
+ "",
1233
+ `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY in every enforcement mode โ€” they never fail a build. A required check can go hollow because a vendor hit an org-wide spending cap, and reddening every PR on a billing state would be a worse gate than the one being criticised. What they change is what you may CLAIM: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed.`
1234
+ );
1235
+ }
830
1236
  }
831
1237
  const report = `${lines.join("\n")}\n`;
832
1238
  process.stdout.write(report);
@@ -5,11 +5,17 @@
5
5
  "Why it matters: GitHub counts a SKIPPED required status check as SATISFIED. A job named in `skip_jobs` still reports green against its required context having run zero steps, so the merge gate can never be red and therefore can never block anything.",
6
6
  "โš ๏ธ `required_contexts` SHIPS EMPTY AND UNSTAMPED ON PURPOSE. Lisa cannot know what YOUR ruleset requires, and an earlier version of this seed shipped a guess that was measured WRONG (#2476): it claimed `๐Ÿ”— Work-Item Traceability` was required when no ruleset required it, and omitted six contexts that were. Until `ruleset.baseline_fetched_at` carries the date you transcribed the real list, the guard reports NOT CHECKED rather than answering from fiction.",
7
7
  "To arm it: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"' to find the ids, then gh api repos/OWNER/NAME/rulesets/ID --jq '.rules[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context' for the contexts. Paste them into `required_contexts` BYTE FOR BYTE (emoji and the ' / ' separator included), fill in `ruleset.repo` / `ruleset.ids`, and set `baseline_fetched_at` to today.",
8
- "The guard compares with exact string equality on purpose: a repo routinely carries confusable pairs (an external app's required `SonarCloud Code Analysis` beside a skippable, not-required `SonarCloud SAST`; `Run Tests` beside `Run Unit Tests`), and a fuzzy match would raise a false alarm whose obvious fix is to delete the guard.",
8
+ "The guard compares with exact string equality on purpose: a repo routinely carries confusable pairs (an external app's required `SonarCloud Code Analysis` beside a skippable, not-required `SonarCloud SAST`; `๐Ÿงน Lint` beside `๐Ÿข Slow Lint Rules`, whose skip tokens `lint` and `lint_slow` are a strict prefix pair), and a fuzzy match would raise a false alarm whose obvious fix is to delete the guard.",
9
+ "The worst confusable pair Lisa ever shipped was a NOT-required `๐Ÿงช Run Tests` beside the required `๐Ÿงช Run Unit Tests` โ€” it merged red on two PRs because 'Run Tests failed' reads like the required test gate failing. It was DELETED in #2485, not renamed, because it only re-ran what the two coverage-carrying required contexts already run. If you find a near-duplicate advisory check in your own roster, prefer deleting it over trusting a reader to tell three similar names apart.",
9
10
  "A transcription expires after 90 days, because a ruleset can be edited with no signal in this repository. The shipped `.github/workflows/required-checks-drift.yml` runs `--remote` weekly to catch that; `--remote` reads the ruleset live and so answers even when the cache is untrusted.",
10
11
  "Lisa's quality.yml runs the offline arm on every pull request. This seed ships `\"enforcement\": \"warn\"`, which downgrades findings AND the NOT-CHECKED refusal to reports so a fresh install does not go red on arrival. Delete the key once you have transcribed the list โ€” then it blocks.",
11
12
  "`skip_jobs` is matched as an exact comma-delimited token and GitHub Actions expression syntax has no string-replace, so write the list with no spaces: `skip_jobs: 'a,b'`, never `skip_jobs: 'a, b'`. A spaced token matches nothing and the job runs โ€” reported as `whitespace_in_skip_token`.",
12
- "`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it."
13
+ "`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it.",
14
+ "THE FAMILY: required-and-red is loud; required-and-vacuous is not; advisory-and-stale is invisible. All three are one gate reporting satisfied without proving anything. `required_contexts` + `skip_job_declarations` above cover the SKIPPED variant; `evidence_bearing_checks` below covers the VACUOUS one.",
15
+ "VACUOUS, measured (CodySwannGT/lisa#2497): a required `CodeRabbit` context posted `success` with the description `Review rate limited`, having reviewed nothing, on two security-relevant PRs that then merged and shipped. `gh pr checks` prints `pass` for that exactly as it does for a real review โ€” only the DESCRIPTION tells them apart.",
16
+ "Run it per PR: `npm run check:vacuous-required-checks -- --pr=1234` (or `node scripts/check-skipped-required-checks.mjs --pr=1234`). It reads `gh pr checks --json name,state,bucket,description`, which is the only route that carries the description for a legacy commit status like CodeRabbit's.",
17
+ "`evidence_bearing_checks` names the checks whose GREEN is supposed to mean something reviewed the code. Use `{}` to accept the shipped description vocabulary, or add `proof` / `no_work` arrays to extend it โ€” extensions ADD to the defaults, they do not replace them. Undeclared checks are never examined, because most CI jobs ship an empty description and flagging them all would bury the one finding that matters.",
18
+ "This arm REPORTS AND NEVER BLOCKS, in every enforcement mode. A review bot can go hollow because an org-wide SPENDING CAP was hit, and a gate that reddens every PR on a billing state is worse than the one it criticises. What it changes is what you may CLAIM: a PR carrying a `vacuous_required_check` finding has not been shown to be reviewed, so do not record it as reviewed."
13
19
  ],
14
20
  "enforcement": "warn",
15
21
  "ruleset": {
@@ -17,7 +23,9 @@
17
23
  "ids": [],
18
24
  "baseline_fetched_at": ""
19
25
  },
20
- "workflows": [".github/workflows/ci.yml"],
26
+ "workflows": [
27
+ ".github/workflows/ci.yml"
28
+ ],
21
29
  "exemption_ticket_pattern": "^[A-Z][A-Z0-9]+-\\d+$",
22
30
  "required_contexts": [],
23
31
  "_example_required_contexts": [
@@ -29,5 +37,8 @@
29
37
  "๐Ÿ” Quality Checks / ๐Ÿงช Run Unit Tests",
30
38
  "๐Ÿ” Quality Checks / ๐Ÿงช Run Integration Tests"
31
39
  ],
32
- "skip_job_declarations": {}
40
+ "skip_job_declarations": {},
41
+ "evidence_bearing_checks": {
42
+ "CodeRabbit": {}
43
+ }
33
44
  }
@@ -19,7 +19,8 @@
19
19
  "prepare": "node -e \"if (process.env.INIT_CWD?.includes('.serverless')) process.exit(0); process.exit(1);\" || husky install || true",
20
20
  "nightly:health": "node scripts/check-nightly-e2e-health.mjs",
21
21
  "check:skipped-required-checks": "node scripts/check-skipped-required-checks.mjs",
22
- "check:skipped-required-checks:remote": "node scripts/check-skipped-required-checks.mjs --remote"
22
+ "check:skipped-required-checks:remote": "node scripts/check-skipped-required-checks.mjs --remote",
23
+ "check:vacuous-required-checks": "node scripts/check-skipped-required-checks.mjs"
23
24
  },
24
25
  "devDependencies": {
25
26
  "eslint-plugin-oxlint": "^1.62.0",