@codyswann/lisa 4.4.18 → 4.4.19

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 (63) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +1 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +10 -5
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/expo/create-only/.github/required-checks.json +4 -2
  8. package/nestjs/create-only/.github/required-checks.json +4 -2
  9. package/package.json +2 -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-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +593 -24
  61. package/typescript/create-only/.github/required-checks.json +4 -2
  62. package/typescript/create-only/.github/workflows/review-evidence.yml +122 -0
  63. package/typescript/package-lisa/package.lisa.json +1 -1
@@ -13,8 +13,15 @@
13
13
  *
14
14
  * Usage:
15
15
  * node scripts/check-skipped-required-checks.mjs [rootDir] [--remote] [--json]
16
+ * node scripts/check-skipped-required-checks.mjs --vacuity [--fail-on-vacuous]
16
17
  * node scripts/check-skipped-required-checks.mjs --pr=1234 [--repo=OWNER/NAME]
17
18
  *
19
+ * `--vacuity` is the WIRED form of the third bullet: it resolves the pull
20
+ * request itself (`--pr`, else the Actions event payload, else `GITHUB_REF`,
21
+ * else the current branch), waits for the declared checks to settle, and
22
+ * REFUSES rather than reporting all-clear when it inspected nothing. See
23
+ * "`--vacuity` — why a flag nobody passes is the same defect" below.
24
+ *
18
25
  * ## The family this guard covers
19
26
  *
20
27
  * **Required-and-red is loud; required-and-vacuous is not; advisory-and-stale is
@@ -136,6 +143,45 @@
136
143
  * distinguishes them.** So anything gating on such a check must read the
137
144
  * description, and `--pr` is the machine-readable form of that one-line triage.
138
145
  *
146
+ * ## `--vacuity` — why a flag nobody passes is the same defect
147
+ *
148
+ * MEASURED (CodySwannGT/lisa#2928): for the whole life of this arm, NOTHING
149
+ * INVOKED IT. `quality.yml` ran the offline arm with no `--pr`, the shipped
150
+ * `required-checks-drift.yml` ran `--remote`, and the package script named
151
+ * `check:vacuous-required-checks` was a bare invocation — so the command named
152
+ * for the vacuous check reported on SKIPS and said nothing about vacuity unless
153
+ * a caller happened to remember `-- --pr=1234`. A rule that runs only when
154
+ * somebody types a flag is `declared-but-uncallable`: exactly the family this
155
+ * file exists to describe, reproduced one level up inside its own shipping.
156
+ *
157
+ * `--vacuity` closes it, and three properties are what make it a gate rather
158
+ * than a second flag to forget:
159
+ *
160
+ * 1. **It resolves the pull request itself.** `--pr`, else the `pull_request`
161
+ * event payload at `GITHUB_EVENT_PATH`, else `refs/pull/N/merge` in
162
+ * `GITHUB_REF`, else `gh pr view` on the current branch.
163
+ * 2. **It waits for the declared checks to SETTLE.** A review bot posts
164
+ * `pending — "Review queued"` and `pending — "Review in progress"` before
165
+ * it settles, in about nine seconds, and identically on a pull request it
166
+ * reviews and one it rate-limits. Both intermediate strings are in the
167
+ * `no_work` vocabulary, so evaluating on the `pull_request` event without
168
+ * waiting manufactures a finding on EVERY pull request — and a guard that
169
+ * fires every time gets deleted rather than read.
170
+ * 3. **It REFUSES rather than reporting all-clear from an empty inspection.**
171
+ * An unresolvable pull request, a `gh` that could not be read, a roster
172
+ * with no checks in it, or a declaration naming no evidence-bearing check
173
+ * all print the same "nothing vacuous here" as a genuinely clean run. That
174
+ * collapse is this file's own thesis, so the four causes are separated and
175
+ * named — see `VACUITY_REFUSALS`. `gh pr checks` in particular resolves the
176
+ * rollup through `checkSuite.workflowRun` and therefore needs `actions:
177
+ * read`; without it `gh` exits non-zero with EMPTY STDOUT, which reads as a
178
+ * content problem and never says the word "permission".
179
+ *
180
+ * A refusal is not a finding: it fails, and under `"enforcement": "warn"` it is
181
+ * loud and exits 0, matching what an untranscribed snapshot already does.
182
+ *
183
+ * ## `--fail-on-vacuous` — the supported exit code
184
+ *
139
185
  * This arm NEVER blocks — `NEVER_BLOCKING`, enforced regardless of the
140
186
  * declaration's `enforcement` mode. Two independent reasons, both load-bearing:
141
187
  *
@@ -146,6 +192,13 @@
146
192
  * decision an owner has to make. Shipping the gate before the decision
147
193
  * would pre-empt it. Detection is what is uncontroversial; act on it.
148
194
  *
195
+ * Neither argument says the finding must be UNREPORTABLE. A consumer who has
196
+ * made the governance call and wants a red job asks for one with
197
+ * `--fail-on-vacuous`, which is opt-in and changes nothing for anybody who does
198
+ * not pass it. Before that flag existed the only way to get an exit code was a
199
+ * wrapper reading `--json` — every consumer writing the same twelve lines, and
200
+ * the ones who did not write them got a finding that lived only in a log.
201
+ *
149
202
  * ## Proof is matched STRICTLY, no-work LOOSELY
150
203
  *
151
204
  * The two description lists are deliberately asymmetric, because their errors
@@ -363,6 +416,45 @@ export const NEVER_BLOCKING = Object.freeze([
363
416
  VIOLATIONS.unproven,
364
417
  ]);
365
418
 
419
+ /**
420
+ * Why the vacuity arm could not inspect anything, as stable tokens.
421
+ *
422
+ * A REFUSAL IS NOT A FINDING, and the distinction is the whole point.
423
+ * `NEVER_BLOCKING` covers the findings — a check that reported success having
424
+ * done no work — and it is deliberate that those never redden a build. These
425
+ * are the other thing entirely: the arm did not run. An empty inspection and a
426
+ * genuinely clean pull request print the same "nothing vacuous here", which is
427
+ * this file's own thesis applied to itself, so the causes are separated and
428
+ * each one names itself (#2928).
429
+ *
430
+ * They are separated from each other for the same reason. A red job caused by a
431
+ * missing `actions: read` means NOBODY LOOKED; a red job caused by a hollow
432
+ * review means the review was fake. Reported through one message they are
433
+ * indistinguishable, and the first gets misreported as the second.
434
+ */
435
+ export const VACUITY_REFUSALS = Object.freeze({
436
+ unresolvedPr: "vacuity_pr_unresolved",
437
+ unreadableChecks: "vacuity_checks_unreadable",
438
+ emptyRoster: "vacuity_no_checks_reported",
439
+ noneDeclared: "vacuity_none_declared",
440
+ });
441
+
442
+ /**
443
+ * Ceiling, in seconds, on waiting for the declared checks to settle.
444
+ *
445
+ * Only `--vacuity` waits. An ad-hoc `--pr=1234` is a human triaging one pull
446
+ * request and answers immediately, exactly as it always has; the wait exists
447
+ * for the unattended run that starts the instant the pull request opens, when
448
+ * the bot has not posted anything yet.
449
+ */
450
+ export const SETTLE_TIMEOUT_SECONDS = 300;
451
+
452
+ /** Seconds between polls while waiting for the declared checks to settle. */
453
+ export const SETTLE_INTERVAL_SECONDS = 15;
454
+
455
+ /** Matches the `GITHUB_REF` a `pull_request` event run carries. */
456
+ const REF_PULL = /^refs\/pull\/(\d+)\/(?:merge|head)$/u;
457
+
366
458
  /**
367
459
  * Reads `--name=value` or `--name value` out of argv.
368
460
  *
@@ -992,9 +1084,13 @@ export function fetchPullRequestChecks(pr, repo) {
992
1084
  } catch (error) {
993
1085
  raw = typeof error?.stdout === "string" ? error.stdout : "";
994
1086
  if (raw.trim() === "") {
995
- throw new Error(
996
- `check-skipped-required-checks: could not read checks for PR ${pr}${repo ? ` in ${repo}` : ""} — ${error instanceof Error ? error.message : String(error)}`
997
- );
1087
+ // MEASURED (#2928): this is what a missing `actions: read` looks like.
1088
+ // `gh pr checks` resolves the rollup through `checkSuite.workflowRun`, so
1089
+ // without that scope it exits non-zero with EMPTY STDOUT — a failure
1090
+ // shaped exactly like unreadable content, which never says the word
1091
+ // "permission". The commit-status route below needs no `actions` scope
1092
+ // and carries the description this arm reads, so try it before refusing.
1093
+ return fetchChecksViaApi(pr, repo, error);
998
1094
  }
999
1095
  }
1000
1096
  try {
@@ -1008,6 +1104,355 @@ export function fetchPullRequestChecks(pr, repo) {
1008
1104
  }
1009
1105
  }
1010
1106
 
1107
+ /**
1108
+ * Resolves `OWNER/NAME` for the repository this run is about.
1109
+ *
1110
+ * @param {string} [repo] - An explicit `--repo` value, returned unchanged
1111
+ * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
1112
+ * @returns {string|undefined} The slug, or undefined when nothing resolves it
1113
+ */
1114
+ export function resolveRepoSlug(repo, env = process.env) {
1115
+ if (repo) return repo;
1116
+ if (env.GITHUB_REPOSITORY) return env.GITHUB_REPOSITORY;
1117
+ try {
1118
+ const raw = boundedExecFileSync(
1119
+ "gh",
1120
+ ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
1121
+ { encoding: "utf8" }
1122
+ ).trim();
1123
+ return raw === "" ? undefined : raw;
1124
+ } catch {
1125
+ return undefined;
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Reads one `gh api` call that yields a JSON array, or throws with context.
1131
+ *
1132
+ * @param {ReadonlyArray<string>} args - Arguments after `gh`
1133
+ * @returns {object[]} The parsed array
1134
+ */
1135
+ function ghApiArray(args) {
1136
+ const parsed = JSON.parse(
1137
+ boundedExecFileSync("gh", args, { encoding: "utf8" })
1138
+ );
1139
+ if (!Array.isArray(parsed)) throw new TypeError("not an array");
1140
+ return parsed;
1141
+ }
1142
+
1143
+ /**
1144
+ * The permission-light route to the same rows, for when `gh pr checks` cannot.
1145
+ *
1146
+ * Two calls, neither of which needs `actions: read`: the combined commit status
1147
+ * (`statuses: read`), which is where a legacy status like CodeRabbit's lives
1148
+ * and is the ONLY route that carries its description, and the check-run list
1149
+ * (`checks: read`). `gh pr checks` is still the primary because it is one call
1150
+ * and already normalises both families; this exists so a repository that has
1151
+ * not granted the rollup scope gets an ANSWER rather than a refusal it will
1152
+ * misread as a detection.
1153
+ *
1154
+ * A check run with no conclusion yet is reported `PENDING` rather than by its
1155
+ * `status` string, so the settle test sees one vocabulary from both routes.
1156
+ *
1157
+ * @param {string|number} pr - Pull request number
1158
+ * @param {string|undefined} repo - `OWNER/NAME`, or undefined to resolve it
1159
+ * @param {unknown} cause - The `gh pr checks` failure this is falling back from
1160
+ * @returns {Array<{name: string, state: string, bucket?: string, description?: string}>} The checks
1161
+ * @throws {Error} When the fallback cannot answer either
1162
+ */
1163
+ export function fetchChecksViaApi(pr, repo, cause) {
1164
+ const slug = resolveRepoSlug(repo);
1165
+ const why = cause instanceof Error ? cause.message : String(cause ?? "");
1166
+ if (slug === undefined) {
1167
+ throw new Error(
1168
+ `check-skipped-required-checks: could not read checks for PR ${pr} — \`gh pr checks\` failed (${why}) and the commit-status fallback has no OWNER/NAME to query. Pass \`--repo=OWNER/NAME\`, or set GITHUB_REPOSITORY.`
1169
+ );
1170
+ }
1171
+ try {
1172
+ const sha = boundedExecFileSync(
1173
+ "gh",
1174
+ [
1175
+ "pr",
1176
+ "view",
1177
+ String(pr),
1178
+ "--repo",
1179
+ slug,
1180
+ "--json",
1181
+ "headRefOid",
1182
+ "--jq",
1183
+ ".headRefOid",
1184
+ ],
1185
+ { encoding: "utf8" }
1186
+ ).trim();
1187
+ const statuses = ghApiArray([
1188
+ "api",
1189
+ `repos/${slug}/commits/${sha}/status`,
1190
+ "--jq",
1191
+ "[.statuses[] | {name: .context, state: .state, description: .description}]",
1192
+ ]);
1193
+ const runs = ghApiArray([
1194
+ "api",
1195
+ `repos/${slug}/commits/${sha}/check-runs?per_page=100`,
1196
+ "--jq",
1197
+ '[.check_runs[] | {name: .name, conclusion: .conclusion, description: (.output.title // "")}]',
1198
+ ]);
1199
+ return [
1200
+ ...statuses.map(row =>
1201
+ normalizeCheckRow(row.name, row.state, row.description)
1202
+ ),
1203
+ ...runs.map(row =>
1204
+ normalizeCheckRow(row.name, row.conclusion, row.description)
1205
+ ),
1206
+ ];
1207
+ } catch (error) {
1208
+ throw new Error(
1209
+ `check-skipped-required-checks: could not read checks for PR ${pr} in ${slug}. \`gh pr checks\` failed (${why}) — which is what a missing \`actions: read\` looks like, because it resolves the rollup through \`checkSuite.workflowRun\` and exits non-zero with empty output — and the commit-status fallback also failed (${error instanceof Error ? error.message : String(error)}). NOBODY LOOKED at this pull request; that is not the same as nothing being wrong with it.`
1210
+ );
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Puts one row from either API route into the shape this file evaluates.
1216
+ *
1217
+ * @param {string} name - Context or check-run name
1218
+ * @param {string|null|undefined} outcome - Status state, or a run's conclusion
1219
+ * @param {string|undefined} description - The description, verbatim
1220
+ * @returns {{name: string, state: string, bucket: string, description: string}} One row
1221
+ */
1222
+ function normalizeCheckRow(name, outcome, description) {
1223
+ const state =
1224
+ outcome === null || outcome === undefined || outcome === ""
1225
+ ? "PENDING"
1226
+ : String(outcome).toUpperCase();
1227
+ const bucket =
1228
+ state === "PENDING" ? "pending" : state === "SUCCESS" ? "pass" : "fail";
1229
+ return { name: String(name), state, bucket, description: description ?? "" };
1230
+ }
1231
+
1232
+ /**
1233
+ * The check names a declaration says carry evidence.
1234
+ *
1235
+ * @param {object} declaration - The per-repo declaration
1236
+ * @returns {string[]} Declared evidence-bearing check names
1237
+ */
1238
+ export function declaredEvidenceChecks(declaration) {
1239
+ return Object.keys(declaration.evidence_bearing_checks ?? {});
1240
+ }
1241
+
1242
+ /**
1243
+ * True when every declared evidence-bearing check has reached a terminal state.
1244
+ *
1245
+ * MEASURED (#2928): a review bot posts `pending — "Review queued"`, then
1246
+ * `pending — "Review in progress"`, and only then settles — in about nine
1247
+ * seconds, and IDENTICALLY on a pull request it goes on to review and one it
1248
+ * rate-limits. Both intermediate descriptions are in the shipped `no_work`
1249
+ * vocabulary, so an arm that evaluates the moment a pull request opens
1250
+ * manufactures a `vacuous_required_check` on EVERY pull request. A guard that
1251
+ * fires every time is indistinguishable from one that fires at random.
1252
+ *
1253
+ * A check that has posted NOTHING is unsettled for the same reason rather than
1254
+ * a different one: the bot has seconds of latency before its first status
1255
+ * exists, and "absent" is the shape that latency takes. Absent AFTER the wait
1256
+ * is a real finding — that is the measured #2493/#2491/#2488 case — so this
1257
+ * only decides when to stop waiting, never what the verdict is.
1258
+ *
1259
+ * @param {object} declaration - The per-repo declaration
1260
+ * @param {ReadonlyArray<{name: string, state: string, bucket?: string}>} checks - Rows
1261
+ * @returns {boolean} True when nothing declared is still in flight
1262
+ */
1263
+ export function checksSettled(declaration, checks) {
1264
+ return declaredEvidenceChecks(declaration).every(name => {
1265
+ const found = checks.find(check => check.name === name);
1266
+ if (found === undefined) return false;
1267
+ if (String(found.bucket ?? "").toLowerCase() === "pending") return false;
1268
+ return String(found.state ?? "").toUpperCase() !== "PENDING";
1269
+ });
1270
+ }
1271
+
1272
+ /**
1273
+ * Blocks this thread for `ms` without turning the event loop.
1274
+ *
1275
+ * `Atomics.wait` rather than a busy loop or an `await`: every other child start
1276
+ * in this file is synchronous, and making `main` async to sleep would change
1277
+ * the exit-code path that the rest of it works to keep meaningful.
1278
+ *
1279
+ * @param {number} ms - Milliseconds to sleep; non-positive returns at once
1280
+ * @returns {void}
1281
+ */
1282
+ function sleepSync(ms) {
1283
+ if (ms <= 0) return;
1284
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1285
+ }
1286
+
1287
+ /**
1288
+ * Reads a pull request's checks, re-reading until the declared ones settle.
1289
+ *
1290
+ * @param {object} declaration - The per-repo declaration
1291
+ * @param {string|number} pr - Pull request number
1292
+ * @param {string|undefined} repo - `OWNER/NAME`, or undefined
1293
+ * @param {{timeoutSeconds?: number, intervalSeconds?: number, fetch?: Function, now?: Function, sleep?: Function}} [options] -
1294
+ * Injection seams; the suite drives the whole loop through them so nothing
1295
+ * here has to sleep in real time
1296
+ * @returns {{checks: object[], settled: boolean}} The rows, and whether they settled
1297
+ */
1298
+ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
1299
+ const timeoutMs = (options.timeoutSeconds ?? SETTLE_TIMEOUT_SECONDS) * 1000;
1300
+ const intervalMs =
1301
+ (options.intervalSeconds ?? SETTLE_INTERVAL_SECONDS) * 1000;
1302
+ const read = options.fetch ?? fetchPullRequestChecks;
1303
+ const clock = options.now ?? Date.now;
1304
+ const sleep = options.sleep ?? sleepSync;
1305
+ const deadline = clock() + timeoutMs;
1306
+ let checks = read(pr, repo);
1307
+ while (!checksSettled(declaration, checks) && clock() < deadline) {
1308
+ sleep(Math.min(intervalMs, deadline - clock()));
1309
+ checks = read(pr, repo);
1310
+ }
1311
+ return { checks, settled: checksSettled(declaration, checks) };
1312
+ }
1313
+
1314
+ /**
1315
+ * Finds the open pull request for the checked-out branch, when nothing named one.
1316
+ *
1317
+ * `gh pr list --head` rather than `gh pr view`: the latter infers the branch
1318
+ * only when no `--repo` is passed, and REJECTS the combination outright, so the
1319
+ * one call that has to tolerate both shapes cannot use it.
1320
+ *
1321
+ * Streams are captured rather than inherited. The failure here is expected and
1322
+ * ordinary — a branch with no pull request is most of the branches there are —
1323
+ * and letting `gh`'s usage text reach stderr would put a scary block above a
1324
+ * report that is about to explain the situation in a sentence.
1325
+ *
1326
+ * @param {string} [repo] - `OWNER/NAME`, or undefined for the current repo
1327
+ * @returns {string|undefined} The number as written, or undefined
1328
+ */
1329
+ function currentBranchPullRequest(repo) {
1330
+ try {
1331
+ const branch = boundedExecFileSync(
1332
+ "git",
1333
+ ["rev-parse", "--abbrev-ref", "HEAD"],
1334
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
1335
+ ).trim();
1336
+ if (branch === "" || branch === "HEAD") return undefined;
1337
+ const args = [
1338
+ "pr",
1339
+ "list",
1340
+ "--head",
1341
+ branch,
1342
+ "--state",
1343
+ "open",
1344
+ "--limit",
1345
+ "1",
1346
+ "--json",
1347
+ "number",
1348
+ "--jq",
1349
+ ".[0].number // empty",
1350
+ ];
1351
+ if (repo) args.push("--repo", repo);
1352
+ const raw = boundedExecFileSync("gh", args, {
1353
+ encoding: "utf8",
1354
+ stdio: ["ignore", "pipe", "pipe"],
1355
+ }).trim();
1356
+ return /^\d+$/u.test(raw) ? raw : undefined;
1357
+ } catch {
1358
+ return undefined;
1359
+ }
1360
+ }
1361
+
1362
+ /**
1363
+ * Parses a JSON file, returning null rather than throwing.
1364
+ *
1365
+ * @param {string} file - Absolute path
1366
+ * @returns {object|null} The parsed value, or null
1367
+ */
1368
+ function readJsonOrNull(file) {
1369
+ try {
1370
+ return JSON.parse(readFileSync(file, "utf8"));
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * Resolves the pull request this run is about, without being told.
1378
+ *
1379
+ * The order is most-explicit-first, and every step is a fact about the run
1380
+ * rather than a guess: an operator's `--pr`, the event payload Actions wrote
1381
+ * for THIS run, the ref that run checked out, and finally the branch a human
1382
+ * has checked out locally. Returning `undefined` is a real answer — the caller
1383
+ * refuses on it rather than examining a pull request it picked.
1384
+ *
1385
+ * @param {ReadonlyArray<string>} argv - CLI arguments
1386
+ * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
1387
+ * @param {{probeBranch?: Function}} [options] - Injection seam for the last step
1388
+ * @returns {{pr: string|undefined, source: string|null}} The number and where it came from
1389
+ */
1390
+ export function resolvePullRequestNumber(
1391
+ argv,
1392
+ env = process.env,
1393
+ options = {}
1394
+ ) {
1395
+ const explicit = readFlagValue(argv, "--pr");
1396
+ if (explicit !== undefined) return { pr: explicit, source: "--pr" };
1397
+
1398
+ const eventPath = env.GITHUB_EVENT_PATH;
1399
+ if (eventPath !== undefined && eventPath !== "" && existsSync(eventPath)) {
1400
+ const payload = readJsonOrNull(eventPath);
1401
+ const number = payload?.pull_request?.number ?? payload?.number;
1402
+ if (Number.isInteger(number)) {
1403
+ return { pr: String(number), source: "GITHUB_EVENT_PATH" };
1404
+ }
1405
+ }
1406
+
1407
+ const ref = REF_PULL.exec(env.GITHUB_REF ?? "");
1408
+ if (ref !== null) return { pr: ref[1], source: "GITHUB_REF" };
1409
+
1410
+ const probe = options.probeBranch ?? currentBranchPullRequest;
1411
+ const found = probe(readFlagValue(argv, "--repo"));
1412
+ return found === undefined
1413
+ ? { pr: undefined, source: null }
1414
+ : { pr: found, source: "gh pr view" };
1415
+ }
1416
+
1417
+ /**
1418
+ * Names the reason this run inspected nothing, or null when it inspected.
1419
+ *
1420
+ * Ordered by how early the inspection died, so the message names the FIRST
1421
+ * thing that went wrong rather than a downstream symptom of it.
1422
+ *
1423
+ * @param {{declaration: object, pr?: string, checks?: ReadonlyArray<object>, error?: unknown}} input -
1424
+ * What the arm managed to obtain
1425
+ * @returns {{kind: string, reason: string}|null} The refusal, or null
1426
+ */
1427
+ export function vacuityRefusal(input) {
1428
+ const { declaration, pr, checks, error } = input;
1429
+ if (pr === undefined) {
1430
+ return {
1431
+ kind: VACUITY_REFUSALS.unresolvedPr,
1432
+ reason: `No pull request could be resolved, so no check was read and this arm examined NOTHING. It looked at \`--pr\`, the \`pull_request\` payload at GITHUB_EVENT_PATH, \`refs/pull/N/merge\` in GITHUB_REF, and \`gh pr list --head\` for the checked-out branch. Reporting a clean bill of health here would be indistinguishable from a pull request whose review really did happen — which is the exact collapse this guard exists to refuse.`,
1433
+ };
1434
+ }
1435
+ if (error !== undefined) {
1436
+ return {
1437
+ kind: VACUITY_REFUSALS.unreadableChecks,
1438
+ reason: `${error instanceof Error ? error.message : String(error)}\n NOBODY LOOKED at PR #${pr}. That is a different fact from "the review was hollow", and this job being red says only the first.`,
1439
+ };
1440
+ }
1441
+ if ((checks ?? []).length === 0) {
1442
+ return {
1443
+ kind: VACUITY_REFUSALS.emptyRoster,
1444
+ reason: `PR #${pr} reported ZERO checks of any kind. A pull request with no checks and a pull request whose checks are all honest produce the same silence from this arm, so it refuses rather than picking one. Confirm the token can read checks (\`actions: read\` for the \`gh pr checks\` rollup, or \`checks: read\` + \`statuses: read\` for the fallback) before believing this.`,
1445
+ };
1446
+ }
1447
+ if (declaredEvidenceChecks(declaration).length === 0) {
1448
+ return {
1449
+ kind: VACUITY_REFUSALS.noneDeclared,
1450
+ reason: `\`evidence_bearing_checks\` in ${DECLARATION_PATH} is empty, so this repository has declared that NO check's green means anything reviewed the code — and the vacuity arm examined nothing on PR #${pr}. If that is true, say so by removing the arm from your workflow. If it is not, name the checks: \`"evidence_bearing_checks": { "CodeRabbit": {} }\`.`,
1451
+ };
1452
+ }
1453
+ return null;
1454
+ }
1455
+
1011
1456
  /**
1012
1457
  * Fetches the live required contexts for every declared ruleset.
1013
1458
  *
@@ -1069,6 +1514,99 @@ export function compareRulesetBaseline(snapshot, live) {
1069
1514
  ];
1070
1515
  }
1071
1516
 
1517
+ /**
1518
+ * Reads a `--name=<seconds>` flag, falling back when absent or unreadable.
1519
+ *
1520
+ * @param {ReadonlyArray<string>} argv - CLI arguments
1521
+ * @param {string} name - The flag, including its leading dashes
1522
+ * @param {number} fallback - Value to use when the flag is absent
1523
+ * @returns {number} A non-negative number of seconds
1524
+ */
1525
+ function readSecondsFlag(argv, name, fallback) {
1526
+ const raw = readFlagValue(argv, name);
1527
+ if (raw === undefined) return fallback;
1528
+ const value = Number(raw);
1529
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
1530
+ }
1531
+
1532
+ /**
1533
+ * Runs the vacuity arm, refusing rather than reporting an empty inspection.
1534
+ *
1535
+ * Returns `undefined` when the arm was not asked for at all — which is the only
1536
+ * way this stays silent. Every other outcome, including "I could not look", is
1537
+ * a reported one.
1538
+ *
1539
+ * The settle wait is armed by `--vacuity` and NOT by a bare `--pr`, because the
1540
+ * two are different situations: `--pr=1234` is a human triaging one pull
1541
+ * request who wants today's instant answer, and `--vacuity` is an unattended
1542
+ * run that starts the moment a pull request opens, before the bot has posted
1543
+ * anything at all.
1544
+ *
1545
+ * @param {ReadonlyArray<string>} argv - CLI arguments
1546
+ * @param {object} declaration - The per-repo declaration
1547
+ * @param {{trustRequiredContexts?: boolean, env?: NodeJS.ProcessEnv, fetch?: Function, probeBranch?: Function, now?: Function, sleep?: Function}} [options] -
1548
+ * Injection seams for the suite
1549
+ * @returns {{pr: string|undefined, prSource: string|null, checked: number, violations: object[], settled: boolean, refusal: {kind: string, reason: string}|null}|undefined} The inspection
1550
+ */
1551
+ export function inspectVacuity(argv, declaration, options = {}) {
1552
+ const wired = argv.includes("--vacuity");
1553
+ if (!wired && readFlagValue(argv, "--pr") === undefined) return undefined;
1554
+
1555
+ const { pr, source } = resolvePullRequestNumber(
1556
+ argv,
1557
+ options.env ?? process.env,
1558
+ options
1559
+ );
1560
+ const empty = {
1561
+ pr,
1562
+ prSource: source,
1563
+ checked: 0,
1564
+ violations: [],
1565
+ settled: false,
1566
+ };
1567
+ if (pr === undefined) {
1568
+ return { ...empty, refusal: vacuityRefusal({ declaration, pr }) };
1569
+ }
1570
+
1571
+ const repo = readFlagValue(argv, "--repo");
1572
+ let read;
1573
+ try {
1574
+ read = fetchSettledChecks(declaration, pr, repo, {
1575
+ timeoutSeconds: readSecondsFlag(
1576
+ argv,
1577
+ "--settle-timeout",
1578
+ wired ? SETTLE_TIMEOUT_SECONDS : 0
1579
+ ),
1580
+ intervalSeconds: readSecondsFlag(
1581
+ argv,
1582
+ "--settle-interval",
1583
+ SETTLE_INTERVAL_SECONDS
1584
+ ),
1585
+ fetch: options.fetch,
1586
+ now: options.now,
1587
+ sleep: options.sleep,
1588
+ });
1589
+ } catch (error) {
1590
+ return { ...empty, refusal: vacuityRefusal({ declaration, pr, error }) };
1591
+ }
1592
+
1593
+ const refusal = vacuityRefusal({ declaration, pr, checks: read.checks });
1594
+ if (refusal !== null) {
1595
+ return { ...empty, settled: read.settled, refusal };
1596
+ }
1597
+ const evaluated = evaluateVacuousChecks(declaration, read.checks, {
1598
+ trustRequiredContexts: options.trustRequiredContexts,
1599
+ });
1600
+ return {
1601
+ pr,
1602
+ prSource: source,
1603
+ checked: evaluated.checked,
1604
+ violations: evaluated.violations,
1605
+ settled: read.settled,
1606
+ refusal: null,
1607
+ };
1608
+ }
1609
+
1072
1610
  /**
1073
1611
  * Runs the guard.
1074
1612
  *
@@ -1079,13 +1617,13 @@ export function compareRulesetBaseline(snapshot, live) {
1079
1617
  * about skips.
1080
1618
  *
1081
1619
  * @param {ReadonlyArray<string>} argv - CLI arguments
1082
- * @returns {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} The result
1620
+ * @param {object} [options] - Injection seams forwarded to {@link inspectVacuity}
1621
+ * @returns {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string, pr: string|undefined, evidenceChecked: number, vacuity: object|undefined}} The result
1083
1622
  */
1084
- export function runGuard(argv) {
1623
+ export function runGuard(argv, options = {}) {
1085
1624
  const positional = argv.filter(arg => !arg.startsWith("--"));
1086
1625
  const rootDir = positional[0] ?? process.cwd();
1087
1626
  const declaration = loadDeclaration(rootDir);
1088
- const pr = readFlagValue(argv, "--pr");
1089
1627
  const collected = collectSkipJobTokens(rootDir, declaration.workflows);
1090
1628
  const remote = argv.includes("--remote");
1091
1629
  const live = remote
@@ -1110,16 +1648,13 @@ export function runGuard(argv) {
1110
1648
 
1111
1649
  // The vacuity arm is layered ON TOP of the offline run rather than replacing
1112
1650
  // it: it is a third variant of one family, so it belongs in one report. Its
1113
- // findings are `NEVER_BLOCKING`, so adding them cannot change the exit code
1114
- // the offline arm would have produced on its own.
1115
- const vacuity =
1116
- pr === undefined
1117
- ? undefined
1118
- : evaluateVacuousChecks(
1119
- declaration,
1120
- fetchPullRequestChecks(pr, readFlagValue(argv, "--repo")),
1121
- { trustRequiredContexts: trust.trusted }
1122
- );
1651
+ // FINDINGS are `NEVER_BLOCKING`, so adding them cannot change the exit code
1652
+ // the offline arm would have produced on its own. Its REFUSAL is not a
1653
+ // finding and does change it — see `VACUITY_REFUSALS`.
1654
+ const vacuity = inspectVacuity(argv, declaration, {
1655
+ ...options,
1656
+ trustRequiredContexts: trust.trusted,
1657
+ });
1123
1658
  if (vacuity !== undefined) violations.push(...vacuity.violations);
1124
1659
 
1125
1660
  return {
@@ -1129,8 +1664,9 @@ export function runGuard(argv) {
1129
1664
  enforcement: declaration.enforcement ?? "error",
1130
1665
  trust,
1131
1666
  recipe: transcriptionRecipe(declaration),
1132
- pr,
1667
+ pr: vacuity?.pr,
1133
1668
  evidenceChecked: vacuity?.checked ?? 0,
1669
+ vacuity,
1134
1670
  };
1135
1671
  }
1136
1672
 
@@ -1165,8 +1701,13 @@ function main(argv) {
1165
1701
  process.stdout.write(
1166
1702
  `${JSON.stringify(
1167
1703
  {
1168
- ok: result.violations.length === 0 && result.trust.trusted,
1704
+ ok:
1705
+ result.violations.length === 0 &&
1706
+ result.trust.trusted &&
1707
+ result.vacuity?.refusal == null,
1169
1708
  answered: result.trust.trusted,
1709
+ inspected:
1710
+ result.vacuity === undefined || result.vacuity.refusal === null,
1170
1711
  ...result,
1171
1712
  },
1172
1713
  null,
@@ -1177,6 +1718,9 @@ function main(argv) {
1177
1718
  }
1178
1719
 
1179
1720
  const warnOnly = result.enforcement === "warn";
1721
+ // The supported alternative to a per-consumer `--json` wrapper. Opt-in, so
1722
+ // the shipped default is unchanged for everyone who does not pass it.
1723
+ const failOnVacuous = argv.includes("--fail-on-vacuous");
1180
1724
  /**
1181
1725
  * True when a violation still fails the build under the active mode.
1182
1726
  *
@@ -1184,9 +1728,11 @@ function main(argv) {
1184
1728
  * @returns {boolean} True when it blocks
1185
1729
  */
1186
1730
  const blocks = violation =>
1187
- !NEVER_BLOCKING.includes(violation.kind) &&
1188
- (!warnOnly || ALWAYS_BLOCKING.includes(violation.kind));
1731
+ NEVER_BLOCKING.includes(violation.kind)
1732
+ ? failOnVacuous
1733
+ : !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
1189
1734
  const blocking = result.violations.filter(blocks);
1735
+ const refusal = result.vacuity?.refusal ?? null;
1190
1736
  const lines = ["## 🔒 Required checks that prove nothing", ""];
1191
1737
 
1192
1738
  // The refusal comes FIRST and replaces the verdict. Printing "✅ none
@@ -1211,14 +1757,32 @@ function main(argv) {
1211
1757
  );
1212
1758
  }
1213
1759
 
1760
+ // Second refusal, same shape and the same reason as the first: an inspection
1761
+ // that never happened must not print the sentence a clean one prints.
1762
+ if (refusal !== null) {
1763
+ lines.push(
1764
+ `⛔ **NOT INSPECTED** (\`${refusal.kind}\`) — the vacuity arm did not examine a single check, and will not report that nothing was vacuous.`,
1765
+ "",
1766
+ refusal.reason,
1767
+ ""
1768
+ );
1769
+ process.stderr.write(
1770
+ `::${warnOnly ? "warning" : "error"} title=${refusal.kind}::${refusal.reason.split("\n")[0]}\n`
1771
+ );
1772
+ }
1773
+
1214
1774
  if (result.violations.length === 0) {
1215
1775
  if (result.trust.trusted) {
1216
1776
  lines.push(
1217
1777
  `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`,
1218
- ...(result.pr === undefined
1778
+ ...(result.pr === undefined || refusal !== null
1219
1779
  ? []
1220
1780
  : [
1221
- `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.`,
1781
+ `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.${
1782
+ result.vacuity?.settled === false
1783
+ ? " (One or more had not settled when the wait expired, so this is what was true at that moment.)"
1784
+ : ""
1785
+ }`,
1222
1786
  ])
1223
1787
  );
1224
1788
  } else {
@@ -1250,7 +1814,9 @@ function main(argv) {
1250
1814
  ) {
1251
1815
  lines.push(
1252
1816
  "",
1253
- `\`${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.`
1817
+ failOnVacuous
1818
+ ? `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY by default; this run passed \`--fail-on-vacuous\`, which is the supported way to ask for an exit code once the governance call has been made. What they mean is unchanged: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed.`
1819
+ : `\`${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. Pass \`--fail-on-vacuous\` to make them block.`
1254
1820
  );
1255
1821
  }
1256
1822
  }
@@ -1276,7 +1842,10 @@ function main(argv) {
1276
1842
  // for a clean bill of health. `warn` — which only Lisa's untranscribed seeds
1277
1843
  // ship — downgrades it, because reddening a whole fleet the day a seed
1278
1844
  // arrives is how a gate gets deleted instead of transcribed.
1279
- if (blocking.length > 0 || (!result.trust.trusted && !warnOnly)) {
1845
+ if (
1846
+ blocking.length > 0 ||
1847
+ ((!result.trust.trusted || refusal !== null) && !warnOnly)
1848
+ ) {
1280
1849
  process.exitCode = 1;
1281
1850
  }
1282
1851
  }