@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +21 -12
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/health/ruleset-inspection.d.ts.map +1 -1
- package/dist/health/ruleset-inspection.js +106 -1
- package/dist/health/ruleset-inspection.js.map +1 -1
- package/expo/create-only/.github/required-checks.json +9 -1
- package/nestjs/create-only/.github/required-checks.json +9 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/plugins/lisa/rules/eager/falsifiable-checks.md +3 -1
- package/plugins/lisa/rules/reference/derived-branch-plan.md +18 -2
- package/plugins/lisa/rules/reference/falsifiable-checks.md +57 -5
- package/plugins/lisa/scripts/design-source-gate.mjs +11 -2
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/lisa/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/design-source-gate.mjs +11 -2
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/lisa-agy/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +3 -1
- package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +18 -2
- package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +57 -5
- package/plugins/lisa-copilot/scripts/design-source-gate.mjs +11 -2
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/lisa-copilot/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +18 -2
- package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +57 -5
- package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +3 -1
- package/plugins/lisa-cursor/scripts/design-source-gate.mjs +11 -2
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/lisa-cursor/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/falsifiable-checks.md +3 -1
- package/plugins/src/base/rules/reference/derived-branch-plan.md +18 -2
- package/plugins/src/base/rules/reference/falsifiable-checks.md +57 -5
- package/plugins/src/base/scripts/design-source-gate.mjs +11 -2
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +29 -1
- package/plugins/src/base/skills/lisa-pull-request-review/SKILL.md +50 -2
- package/scripts/lisa-github-rulesets.sh +77 -0
- package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member-js.yml +71 -0
- package/typescript/copy-overwrite/ast-grep/rules/no-missing-fs-extra-namespace-member.yml +90 -0
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +415 -9
- package/typescript/create-only/.github/required-checks.json +15 -4
- 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
|
|
4
|
-
*
|
|
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`;
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
!
|
|
1167
|
+
!NEVER_BLOCKING.includes(violation.kind) &&
|
|
1168
|
+
(!warnOnly || ALWAYS_BLOCKING.includes(violation.kind));
|
|
778
1169
|
const blocking = result.violations.filter(blocks);
|
|
779
|
-
const lines = ["## ๐
|
|
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`;
|
|
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": [
|
|
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",
|