@codyswann/lisa 3.6.2 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/cli/doctor-worktree-hygiene.d.ts +40 -0
  2. package/dist/cli/doctor-worktree-hygiene.d.ts.map +1 -0
  3. package/dist/cli/doctor-worktree-hygiene.js +123 -0
  4. package/dist/cli/doctor-worktree-hygiene.js.map +1 -0
  5. package/dist/cli/doctor.d.ts.map +1 -1
  6. package/dist/cli/doctor.js +6 -0
  7. package/dist/cli/doctor.js.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +24 -11
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/expo/create-only/.github/required-checks.json +9 -1
  12. package/nestjs/create-only/.github/required-checks.json +9 -1
  13. package/package.json +1 -1
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-pull-request-review/SKILL.md +50 -2
  18. package/plugins/lisa/rules/eager/falsifiable-checks.md +3 -1
  19. package/plugins/lisa/rules/reference/derived-branch-plan.md +18 -2
  20. package/plugins/lisa/rules/reference/falsifiable-checks.md +57 -5
  21. package/plugins/lisa/scripts/design-source-gate.mjs +11 -2
  22. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  23. package/plugins/lisa/skills/lisa-pull-request-review/SKILL.md +50 -2
  24. package/plugins/lisa-agy/plugin.json +1 -1
  25. package/plugins/lisa-agy/scripts/design-source-gate.mjs +11 -2
  26. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  27. package/plugins/lisa-agy/skills/lisa-pull-request-review/SKILL.md +50 -2
  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-copilot/rules/eager/falsifiable-checks.md +3 -1
  35. package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +18 -2
  36. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +57 -5
  37. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +11 -2
  38. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  39. package/plugins/lisa-copilot/skills/lisa-pull-request-review/SKILL.md +50 -2
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +18 -2
  42. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +57 -5
  43. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +3 -1
  44. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +11 -2
  45. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  46. package/plugins/lisa-cursor/skills/lisa-pull-request-review/SKILL.md +50 -2
  47. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-agy/plugin.json +1 -1
  50. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  60. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  70. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-agy/plugin.json +1 -1
  75. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  80. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  85. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/src/base/rules/eager/falsifiable-checks.md +3 -1
  88. package/plugins/src/base/rules/reference/derived-branch-plan.md +18 -2
  89. package/plugins/src/base/rules/reference/falsifiable-checks.md +57 -5
  90. package/plugins/src/base/scripts/design-source-gate.mjs +11 -2
  91. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
  92. package/plugins/src/base/skills/lisa-pull-request-review/SKILL.md +50 -2
  93. package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member-js.yml +71 -0
  94. package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member.yml +90 -0
  95. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +402 -5
  96. package/typescript/create-only/.github/required-checks.json +13 -3
  97. package/typescript/package-lisa/package.lisa.json +2 -1
@@ -0,0 +1,71 @@
1
+ # This file is managed by Lisa.
2
+ # Do not edit directly — changes will be overwritten on the next `lisa` run.
3
+ #
4
+ # JavaScript-language twin of `no-missing-fs-extra-namespace-member.yml`.
5
+ #
6
+ # ast-grep binds one parser per rule: `language: typescript` scans only
7
+ # .ts/.mts/.cts, so .js/.mjs/.cjs would otherwise be an unguarded gap — and a
8
+ # guard with a silent hole is the failure mode this rule exists to prevent.
9
+ # Build scripts and tooling in TypeScript projects are routinely .mjs, and
10
+ # fs-extra is exactly the kind of dependency they reach for.
11
+ #
12
+ # The `constraints` block MUST stay byte-identical to the TypeScript rule.
13
+ # `tests/unit/config/ast-grep-template.test.ts` asserts both files carry the
14
+ # same allow list AND that it still equals the set real Node reports, so these
15
+ # two cannot drift apart or drift away from reality unnoticed.
16
+ #
17
+ # See the TypeScript rule for the full rationale on why this is an allow list
18
+ # rather than a deny list, and why the set is stable across fs-extra 11.x.
19
+ id: no-missing-fs-extra-namespace-member-js
20
+ language: javascript
21
+ severity: error
22
+ message: |
23
+ `fs-extra` does not expose this member on its ESM namespace object.
24
+
25
+ `import * as fse from "fs-extra"` resolves to fs-extra's CommonJS entry, so
26
+ Node derives the namespace keys with cjs-module-lexer. Only fs-extra's own
27
+ directly-assigned functions survive that. Absent members include every JSON
28
+ helper (readJson, readJSON, writeJson, outputJson, and their Sync forms),
29
+ every node:fs passthrough (readFile, writeFile, readdir, stat, existsSync,
30
+ appendFile, ...), and copySync, moveSync, removeSync, pathExistsSync and
31
+ outputFileSync.
32
+
33
+ The call is `undefined` at run time and throws a TypeError, and unit tests
34
+ cannot see it — Vitest and Jest resolve the import through CJS interop and
35
+ hand back the default export's properties.
36
+
37
+ Fix by importing the concrete API instead:
38
+ - file I/O -> `readFile` / `writeFile` from `node:fs/promises`
39
+ - JSON -> read the file, then `JSON.parse` / `JSON.stringify`
40
+ - whole module -> `import fse from "fs-extra"`. The DEFAULT export does
41
+ carry every member and is deliberately not flagged here.
42
+ # Test trees are excluded on purpose — see the TypeScript rule for the full
43
+ # rationale. In short: the defect class is "works under a bundler, `undefined`
44
+ # under real Node", so it can only bite code Node executes directly, and the
45
+ # runtime control in the Lisa monorepo already scopes itself to source trees
46
+ # for exactly that reason. Keep this list in step with the TypeScript rule.
47
+ ignores:
48
+ - '**/tests/**'
49
+ - '**/test/**'
50
+ - '**/__tests__/**'
51
+ - '**/__mocks__/**'
52
+ - '**/*.test.js'
53
+ - '**/*.test.mjs'
54
+ - '**/*.test.cjs'
55
+ - '**/*.test.jsx'
56
+ - '**/*.spec.js'
57
+ - '**/*.spec.mjs'
58
+ - '**/*.spec.cjs'
59
+ - '**/*.spec.jsx'
60
+ rule:
61
+ all:
62
+ - pattern: $NS.$MEMBER
63
+ - inside:
64
+ kind: program
65
+ stopBy: end
66
+ has:
67
+ pattern: import * as $NS from "fs-extra"
68
+ constraints:
69
+ MEMBER:
70
+ not:
71
+ regex: ^(copy|createFile|createFileSync|createLink|createLinkSync|createSymlink|createSymlinkSync|default|emptyDir|emptyDirSync|emptydir|emptydirSync|ensureDir|ensureDirSync|ensureFile|ensureFileSync|ensureLink|ensureLinkSync|ensureSymlink|ensureSymlinkSync|exists|mkdirp|mkdirpSync|mkdirs|mkdirsSync|move|outputFile|pathExists|read|readv|remove|write|writev)$
@@ -0,0 +1,90 @@
1
+ # This file is managed by Lisa.
2
+ # Do not edit directly — changes will be overwritten on the next `lisa` run.
3
+ #
4
+ # Bans `fs-extra` namespace members that do not exist under real Node ESM.
5
+ #
6
+ # The allow list below is an ALLOW list, not a deny list, and that is
7
+ # deliberate. A deny list fails open: any absent member nobody thought to
8
+ # enumerate passes silently, which is the exact rot this rule exists to
9
+ # prevent. An allow list fails closed — an unrecognised member is flagged
10
+ # loudly and a human decides.
11
+ #
12
+ # The list is the set of keys real Node reports for
13
+ # `import * as fse from "fs-extra"`. It is byte-identical across fs-extra
14
+ # 11.1.1, 11.3.3, 11.3.4 and 11.3.5, because the bare specifier always routes
15
+ # through the package `exports` map to the CommonJS entry (`./lib/index.js`)
16
+ # and Node derives the namespace keys with cjs-module-lexer from a re-export
17
+ # shape that has not changed. `lib/esm.mjs` — which does export the JSON
18
+ # helpers — is reachable only via the `fs-extra/esm` subpath, which is a
19
+ # different import.
20
+ #
21
+ # Anti-rot: `tests/unit/core/fs-extra-namespace-callsites.test.ts` in the Lisa
22
+ # monorepo derives this same set from real Node at run time, and
23
+ # `tests/unit/config/ast-grep-template.test.ts` asserts the list below still
24
+ # equals it. If fs-extra ever changes its namespace surface, that pairing goes
25
+ # red centrally instead of this list silently drifting in every host project.
26
+ id: no-missing-fs-extra-namespace-member
27
+ language: typescript
28
+ severity: error
29
+ message: |
30
+ `fs-extra` does not expose this member on its ESM namespace object.
31
+
32
+ `import * as fse from "fs-extra"` resolves to fs-extra's CommonJS entry, so
33
+ Node derives the namespace keys with cjs-module-lexer. Only fs-extra's own
34
+ directly-assigned functions survive that. Absent members include every JSON
35
+ helper (readJson, readJSON, writeJson, outputJson, and their Sync forms),
36
+ every node:fs passthrough (readFile, writeFile, readdir, stat, existsSync,
37
+ appendFile, ...), and copySync, moveSync, removeSync, pathExistsSync and
38
+ outputFileSync.
39
+
40
+ The call is `undefined` at run time and throws a TypeError. Unit tests are
41
+ structurally blind to it: Vitest and Jest resolve the import through CJS
42
+ interop and hand back the default export's properties, so the call is a
43
+ function in every test and `undefined` in production. Lisa shipped this
44
+ defect three times in one release window (#2482, #2487) — once as a crash
45
+ that stranded seven merged PRs, twice inside error-swallowing `catch` blocks
46
+ where the TypeError replaced the error being handled.
47
+
48
+ Fix by importing the concrete API instead:
49
+ - file I/O -> `readFile` / `writeFile` from `node:fs/promises`
50
+ - JSON -> read the file, then `JSON.parse` / `JSON.stringify`
51
+ - whole module -> `import fse from "fs-extra"`. The DEFAULT export does
52
+ carry every member and is deliberately not flagged here.
53
+ # Test trees are excluded on purpose, matching the scope the runtime control
54
+ # already chose. The defect class is "works under a bundler, `undefined` under
55
+ # real Node" — so it can only bite code that Node executes directly. Vitest and
56
+ # Jest resolve `import * as fse from "fs-extra"` through CJS interop and hand
57
+ # back the default export's properties, which is precisely why test files can
58
+ # call `fse.writeJson` forever without ever being wrong.
59
+ #
60
+ # `tests/unit/core/fs-extra-namespace-callsites.test.ts` scopes itself to
61
+ # ["src", "scripts"] for this reason — "trees whose code is executed by Node
62
+ # directly, not through a bundler". This rule matches that scope rather than
63
+ # inventing a second, wider one. Path globs are used instead of a src/scripts
64
+ # allowlist because host project layouts vary (apps/, packages/, lib/) while
65
+ # test directory conventions do not.
66
+ ignores:
67
+ - '**/tests/**'
68
+ - '**/test/**'
69
+ - '**/__tests__/**'
70
+ - '**/__mocks__/**'
71
+ - '**/*.test.ts'
72
+ - '**/*.test.mts'
73
+ - '**/*.test.cts'
74
+ - '**/*.test.tsx'
75
+ - '**/*.spec.ts'
76
+ - '**/*.spec.mts'
77
+ - '**/*.spec.cts'
78
+ - '**/*.spec.tsx'
79
+ rule:
80
+ all:
81
+ - pattern: $NS.$MEMBER
82
+ - inside:
83
+ kind: program
84
+ stopBy: end
85
+ has:
86
+ pattern: import * as $NS from "fs-extra"
87
+ constraints:
88
+ MEMBER:
89
+ not:
90
+ regex: ^(copy|createFile|createFileSync|createLink|createLinkSync|createSymlink|createSymlinkSync|default|emptyDir|emptyDirSync|emptydir|emptydirSync|ensureDir|ensureDirSync|ensureFile|ensureFileSync|ensureLink|ensureLinkSync|ensureSymlink|ensureSymlinkSync|exists|mkdirp|mkdirpSync|mkdirs|mkdirsSync|move|outputFile|pathExists|read|readv|remove|write|writev)$
@@ -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,6 +113,57 @@
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
@@ -170,6 +238,55 @@ export const VIOLATIONS = Object.freeze({
170
238
  badExemption: "exemption_without_valid_ticket",
171
239
  remoteDrift: "ruleset_snapshot_drift",
172
240
  whitespace: "whitespace_in_skip_token",
241
+ vacuous: "vacuous_required_check",
242
+ unproven: "unproven_required_check",
243
+ });
244
+
245
+ /**
246
+ * The shipped description vocabulary for review-bot style checks.
247
+ *
248
+ * `proof` is matched STRICTLY (whole description, case-insensitive, trimmed)
249
+ * because a match grants credit. `no_work` is matched LOOSELY (substring)
250
+ * because a match denies it. See the header for why that asymmetry is the safe
251
+ * direction.
252
+ *
253
+ * Every string here was read off a real check on a real PR in this fleet, not
254
+ * invented: `Review rate limited` (#2483, #2484, #2495), `Review approved`
255
+ * (#2350). A repository may extend either list per check without losing these.
256
+ */
257
+ export const REVIEW_DESCRIPTION_DEFAULTS = Object.freeze({
258
+ proof: Object.freeze([
259
+ "review approved",
260
+ "review completed",
261
+ "changes requested",
262
+ "comments posted",
263
+ ]),
264
+ no_work: Object.freeze([
265
+ "rate limited",
266
+ "review queued",
267
+ "review skipped",
268
+ "skipped",
269
+ "queued",
270
+ "waiting",
271
+ "in progress",
272
+ "no review",
273
+ "disabled",
274
+ "quota",
275
+ "billing",
276
+ ]),
277
+ });
278
+
279
+ /**
280
+ * Verdicts `classifyCheckDescription` returns.
281
+ *
282
+ * `unproven` is the FALLBACK on purpose: the absence of a recognised phrase is
283
+ * the absence of evidence, and this file's whole thesis is that those are not
284
+ * the same as a pass.
285
+ */
286
+ export const DESCRIPTION_VERDICTS = Object.freeze({
287
+ proved: "proved",
288
+ noWork: "no-work",
289
+ unproven: "unproven",
173
290
  });
174
291
 
175
292
  /** Enforcement modes a declaration may select. */
@@ -195,6 +312,50 @@ const ALWAYS_BLOCKING = Object.freeze([
195
312
  VIOLATIONS.remoteDrift,
196
313
  ]);
197
314
 
315
+ /**
316
+ * Violation kinds that NEVER fail the build, in any enforcement mode.
317
+ *
318
+ * The vacuity arm reports and stops there. A required check can go hollow
319
+ * because a vendor hit an org-wide SPENDING CAP, and a gate that reddens every
320
+ * PR the moment a bill goes unpaid is a worse gate than the one it is
321
+ * criticising. Whether such a check belongs in the required set at all is a
322
+ * governance decision an owner makes in an admin console, not one this script
323
+ * may pre-empt by turning its own finding into a blocker.
324
+ *
325
+ * Detection is the uncontroversial half, and it is the half that was missing:
326
+ * nothing anywhere could previously tell "the check reported success" apart
327
+ * from "the check did anything".
328
+ *
329
+ * This list is checked BEFORE `ALWAYS_BLOCKING` and before the enforcement
330
+ * mode, so deleting the `enforcement` key cannot silently arm it.
331
+ */
332
+ export const NEVER_BLOCKING = Object.freeze([
333
+ VIOLATIONS.vacuous,
334
+ VIOLATIONS.unproven,
335
+ ]);
336
+
337
+ /**
338
+ * Reads `--name=value` or `--name value` out of argv.
339
+ *
340
+ * Returns `undefined` for an absent flag and for `--name` with no value, so a
341
+ * typo cannot be read as an empty PR number and silently examine nothing.
342
+ *
343
+ * @param {ReadonlyArray<string>} argv - CLI arguments
344
+ * @param {string} name - The flag, including its leading dashes
345
+ * @returns {string|undefined} The value, or undefined
346
+ */
347
+ export function readFlagValue(argv, name) {
348
+ const inline = argv.find(arg => arg.startsWith(`${name}=`));
349
+ if (inline !== undefined) {
350
+ const value = inline.slice(name.length + 1).trim();
351
+ return value === "" ? undefined : value;
352
+ }
353
+ const at = argv.indexOf(name);
354
+ if (at === -1) return undefined;
355
+ const next = argv[at + 1];
356
+ return next === undefined || next.startsWith("--") ? undefined : next;
357
+ }
358
+
198
359
  /**
199
360
  * True when a line is a whole-line YAML comment.
200
361
  *
@@ -403,6 +564,30 @@ export function loadDeclaration(rootDir) {
403
564
  );
404
565
  }
405
566
  }
567
+
568
+ // Same reasoning for the vacuity declarations: a non-object entry would read
569
+ // as "declared" and then yield an empty vocabulary, quietly examining the
570
+ // check against defaults the author thought they had overridden.
571
+ for (const [name, entry] of Object.entries(
572
+ declaration.evidence_bearing_checks ?? {}
573
+ )) {
574
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
575
+ throw new Error(
576
+ `check-skipped-required-checks: the declaration for \`${name}\` in \`evidence_bearing_checks\` must be an object — use \`{}\` to accept the shipped description vocabulary.`
577
+ );
578
+ }
579
+ for (const list of ["proof", "no_work"]) {
580
+ if (entry[list] === undefined) continue;
581
+ if (
582
+ !Array.isArray(entry[list]) ||
583
+ entry[list].some(phrase => typeof phrase !== "string")
584
+ ) {
585
+ throw new Error(
586
+ `check-skipped-required-checks: \`evidence_bearing_checks.${name}.${list}\` must be an array of description strings.`
587
+ );
588
+ }
589
+ }
590
+ }
406
591
  return declaration;
407
592
  }
408
593
 
@@ -616,6 +801,184 @@ export function evaluateSkippedRequiredChecks(
616
801
  return { violations, checked: skipped.length };
617
802
  }
618
803
 
804
+ /**
805
+ * Decides whether a check's description proves the check did any work.
806
+ *
807
+ * @param {string|undefined} description - The check's description, verbatim
808
+ * @param {{proof?: ReadonlyArray<string>, no_work?: ReadonlyArray<string>}} [vocabulary] -
809
+ * Per-check additions. Merged WITH the shipped defaults rather than replacing
810
+ * them, so a repository naming one extra proof phrase does not silently lose
811
+ * the no-work list that catches the measured defect.
812
+ * @returns {string} One of `DESCRIPTION_VERDICTS`
813
+ */
814
+ export function classifyCheckDescription(description, vocabulary = {}) {
815
+ const text = (description ?? "").trim().toLowerCase();
816
+ if (text === "") return DESCRIPTION_VERDICTS.unproven;
817
+
818
+ // No-work is tested FIRST. The lists are asserted non-overlapping in the
819
+ // suite, so order cannot change a verdict today — testing the denying rule
820
+ // first means a future overlap fails safe (denied) rather than granting
821
+ // credit, which is the direction that matters.
822
+ const noWork = [
823
+ ...REVIEW_DESCRIPTION_DEFAULTS.no_work,
824
+ ...(vocabulary.no_work ?? []),
825
+ ];
826
+ if (noWork.some(phrase => text.includes(phrase.trim().toLowerCase()))) {
827
+ return DESCRIPTION_VERDICTS.noWork;
828
+ }
829
+
830
+ const proof = [
831
+ ...REVIEW_DESCRIPTION_DEFAULTS.proof,
832
+ ...(vocabulary.proof ?? []),
833
+ ];
834
+ if (proof.some(phrase => text === phrase.trim().toLowerCase())) {
835
+ return DESCRIPTION_VERDICTS.proved;
836
+ }
837
+ return DESCRIPTION_VERDICTS.unproven;
838
+ }
839
+
840
+ /**
841
+ * Reports every declared evidence-bearing check that satisfied without proving
842
+ * it did work.
843
+ *
844
+ * A check is examined only when the repository named it in
845
+ * `evidence_bearing_checks` — matched by EXACT name, like every other
846
+ * comparison in this file. Most CI jobs ship an empty description, so
847
+ * flagging them all would bury the one finding that matters, and the obvious
848
+ * fix for a noisy guard is to delete it.
849
+ *
850
+ * Four outcomes per declared check:
851
+ *
852
+ * - Green + a `proof` description → nothing. This is the case the whole
853
+ * machine exists to reach.
854
+ * - Green + a `no_work` description → `vacuous_required_check`. The measured
855
+ * #2483/#2484 defect.
856
+ * - Green + anything else → `unproven_required_check`. Not an accusation: a
857
+ * statement that this run produced no evidence either way.
858
+ * - Absent entirely → `unproven_required_check`. Measured on #2493/#2491/
859
+ * #2488, where the bot posted no context at all; "no unresolved review
860
+ * threads" there means nobody looked, not that nothing was wrong.
861
+ *
862
+ * A RED check is deliberately ignored. Required-and-red is the loud case and
863
+ * needs no help from here; reporting it too would make this arm indistinguish-
864
+ * able from ordinary CI noise.
865
+ *
866
+ * `required_contexts` changes only the WORDING — whether branch protection
867
+ * actually recorded this hollow green as a satisfied gate. When that snapshot
868
+ * is untrusted the finding still stands; the guard just declines to claim
869
+ * required-ness it has not transcribed.
870
+ *
871
+ * @param {object} declaration - The per-repo declaration
872
+ * @param {ReadonlyArray<{name: string, state: string, bucket?: string, description?: string}>} checks -
873
+ * Checks as `gh pr checks --json name,state,bucket,description` returns them
874
+ * @param {{trustRequiredContexts?: boolean}} [options] - Set
875
+ * `trustRequiredContexts: false` to stop asserting whether a check is required
876
+ * @returns {{violations: object[], checked: number}} Violations and how many declared checks were examined
877
+ */
878
+ export function evaluateVacuousChecks(declaration, checks, options = {}) {
879
+ const declared = declaration.evidence_bearing_checks ?? {};
880
+ const trustRequired = options.trustRequiredContexts !== false;
881
+ const required = new Set(declaration.required_contexts ?? []);
882
+ const violations = [];
883
+ let checked = 0;
884
+
885
+ for (const [name, entry] of Object.entries(declared)) {
886
+ checked += 1;
887
+ const vocabulary = typeof entry === "object" && entry !== null ? entry : {};
888
+ const found = checks.find(check => check.name === name);
889
+
890
+ if (found === undefined) {
891
+ violations.push({
892
+ kind: VIOLATIONS.unproven,
893
+ token: name,
894
+ 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.)`,
895
+ });
896
+ continue;
897
+ }
898
+
899
+ const state = String(found.state ?? "").toUpperCase();
900
+ if (state === "FAILURE" || state === "ERROR") continue;
901
+
902
+ const verdict = classifyCheckDescription(found.description, vocabulary);
903
+ if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
904
+ continue;
905
+ }
906
+
907
+ const requiredNote = !trustRequired
908
+ ? " Whether it is ruleset-required is NOT KNOWN here — `required_contexts` has not been transcribed, so this cannot say what the merge gate recorded."
909
+ : required.has(name)
910
+ ? " This context IS ruleset-required, so branch protection recorded a satisfied review gate for a review that did not happen."
911
+ : " This context is not in `required_contexts`, so no merge gate was falsified — but nothing reviewed this either.";
912
+
913
+ violations.push(
914
+ verdict === DESCRIPTION_VERDICTS.noWork && state === "SUCCESS"
915
+ ? {
916
+ kind: VIOLATIONS.vacuous,
917
+ token: name,
918
+ contexts: [name],
919
+ 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.`,
920
+ }
921
+ : {
922
+ kind: VIOLATIONS.unproven,
923
+ token: name,
924
+ 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.`,
925
+ }
926
+ );
927
+ }
928
+
929
+ return { violations, checked };
930
+ }
931
+
932
+ /**
933
+ * Reads one pull request's checks, descriptions included.
934
+ *
935
+ * `--json` is what makes this usable: the plain `gh pr checks` table is the
936
+ * human triage, and the description column is the load-bearing one, but only
937
+ * the JSON form survives being parsed. Both CheckRuns and legacy commit
938
+ * StatusContexts come back through this single call — CodeRabbit posts the
939
+ * latter, which `gh pr view --json statusCheckRollup` returns WITHOUT a
940
+ * description, so that route cannot see the defect at all.
941
+ *
942
+ * A non-zero exit is expected and ignored: `gh pr checks` exits 8 while checks
943
+ * are pending and 1 when any check failed, and both are perfectly readable
944
+ * states for this arm. Only unparseable output is an error.
945
+ *
946
+ * @param {string|number} pr - Pull request number or URL
947
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
948
+ * @returns {Array<{name: string, state: string, bucket?: string, description?: string}>} The checks
949
+ * @throws {Error} When `gh` is unavailable or its output cannot be parsed
950
+ */
951
+ export function fetchPullRequestChecks(pr, repo) {
952
+ const args = [
953
+ "pr",
954
+ "checks",
955
+ String(pr),
956
+ "--json",
957
+ "name,state,bucket,description",
958
+ ];
959
+ if (repo) args.push("--repo", repo);
960
+ let raw;
961
+ try {
962
+ raw = execFileSync("gh", args, { encoding: "utf8" });
963
+ } catch (error) {
964
+ raw = typeof error?.stdout === "string" ? error.stdout : "";
965
+ if (raw.trim() === "") {
966
+ throw new Error(
967
+ `check-skipped-required-checks: could not read checks for PR ${pr}${repo ? ` in ${repo}` : ""} — ${error instanceof Error ? error.message : String(error)}`
968
+ );
969
+ }
970
+ }
971
+ try {
972
+ const parsed = JSON.parse(raw);
973
+ if (!Array.isArray(parsed)) throw new TypeError("not an array");
974
+ return parsed;
975
+ } catch (error) {
976
+ throw new Error(
977
+ `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.`
978
+ );
979
+ }
980
+ }
981
+
619
982
  /**
620
983
  * Fetches the live required contexts for every declared ruleset.
621
984
  *
@@ -693,6 +1056,7 @@ export function runGuard(argv) {
693
1056
  const positional = argv.filter(arg => !arg.startsWith("--"));
694
1057
  const rootDir = positional[0] ?? process.cwd();
695
1058
  const declaration = loadDeclaration(rootDir);
1059
+ const pr = readFlagValue(argv, "--pr");
696
1060
  const collected = collectSkipJobTokens(rootDir, declaration.workflows);
697
1061
  const remote = argv.includes("--remote");
698
1062
  const live = remote
@@ -714,6 +1078,21 @@ export function runGuard(argv) {
714
1078
  ...compareRulesetBaseline(declaration.required_contexts, live)
715
1079
  );
716
1080
  }
1081
+
1082
+ // The vacuity arm is layered ON TOP of the offline run rather than replacing
1083
+ // it: it is a third variant of one family, so it belongs in one report. Its
1084
+ // findings are `NEVER_BLOCKING`, so adding them cannot change the exit code
1085
+ // the offline arm would have produced on its own.
1086
+ const vacuity =
1087
+ pr === undefined
1088
+ ? undefined
1089
+ : evaluateVacuousChecks(
1090
+ declaration,
1091
+ fetchPullRequestChecks(pr, readFlagValue(argv, "--repo")),
1092
+ { trustRequiredContexts: trust.trusted }
1093
+ );
1094
+ if (vacuity !== undefined) violations.push(...vacuity.violations);
1095
+
717
1096
  return {
718
1097
  violations,
719
1098
  checked: result.checked,
@@ -721,6 +1100,8 @@ export function runGuard(argv) {
721
1100
  enforcement: declaration.enforcement ?? "error",
722
1101
  trust,
723
1102
  recipe: transcriptionRecipe(declaration),
1103
+ pr,
1104
+ evidenceChecked: vacuity?.checked ?? 0,
724
1105
  };
725
1106
  }
726
1107
 
@@ -774,9 +1155,10 @@ function main(argv) {
774
1155
  * @returns {boolean} True when it blocks
775
1156
  */
776
1157
  const blocks = violation =>
777
- !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
1158
+ !NEVER_BLOCKING.includes(violation.kind) &&
1159
+ (!warnOnly || ALWAYS_BLOCKING.includes(violation.kind));
778
1160
  const blocking = result.violations.filter(blocks);
779
- const lines = ["## 🔒 Skipped required checks", ""];
1161
+ const lines = ["## 🔒 Required checks that prove nothing", ""];
780
1162
 
781
1163
  // The refusal comes FIRST and replaces the verdict. Printing "✅ none
782
1164
  // silences a required check" from a snapshot nobody transcribed is the one
@@ -803,7 +1185,12 @@ function main(argv) {
803
1185
  if (result.violations.length === 0) {
804
1186
  if (result.trust.trusted) {
805
1187
  lines.push(
806
- `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
1188
+ `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`,
1189
+ ...(result.pr === undefined
1190
+ ? []
1191
+ : [
1192
+ `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.`,
1193
+ ])
807
1194
  );
808
1195
  } else {
809
1196
  lines.push(
@@ -827,6 +1214,16 @@ function main(argv) {
827
1214
  `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
1215
  );
829
1216
  }
1217
+ if (
1218
+ result.violations.some(violation =>
1219
+ NEVER_BLOCKING.includes(violation.kind)
1220
+ )
1221
+ ) {
1222
+ lines.push(
1223
+ "",
1224
+ `\`${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.`
1225
+ );
1226
+ }
830
1227
  }
831
1228
  const report = `${lines.join("\n")}\n`;
832
1229
  process.stdout.write(report);