@codyswann/lisa 4.17.4 → 4.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +2 -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 +11 -1
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/package.json +3 -2
  8. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo-agy/plugin.json +1 -1
  21. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  41. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails-agy/plugin.json +1 -1
  46. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  51. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  56. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  58. package/scripts/check-shell-guard-refusal-coverage.mjs +233 -0
  59. package/scripts/lib/shell-guard-refusal-coverage.mjs +315 -0
  60. package/scripts/lib/shell-guard-trace.mjs +256 -0
  61. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +88 -7
@@ -0,0 +1,256 @@
1
+ /**
2
+ * shell-guard-trace — record which shipped `.sh` guards a test run actually
3
+ * EXECUTES, and with what exit status (CodySwannGT/lisa#3190).
4
+ *
5
+ * @remarks
6
+ * ## Why a runtime trace rather than a scan of the tests
7
+ *
8
+ * The question this module answers is "which guard did the suite run", and
9
+ * every static spelling of that question has been tried here and is wrong in
10
+ * the direction that produces silence:
11
+ *
12
+ * - **By filename.** `foo.test.ts` next to `foo.sh` proves nothing. A test
13
+ * named after a guard that never runs it is the deceptive case
14
+ * CodySwannGT/lisa#3190 was filed about, and a scan keyed on names reports
15
+ * it as covered.
16
+ * - **By the path appearing in the test source.** Measured on this tree:
17
+ * 258 of 259 tracked `.sh` files are named somewhere inside a test file that
18
+ * also spawns something — inventories, manifests, and parity tables mention
19
+ * every shipped artifact. The signal is noise.
20
+ * - **By resolving the spawn argument statically.** Precise where it works
21
+ * (26 guards found) and blind exactly where this repository's suites are
22
+ * most interesting: the dominant idiom READS a guard's bytes, writes them
23
+ * into a fixture project under a different name, and executes THAT. The
24
+ * shipped path never reaches a spawn argument at all.
25
+ *
26
+ * The trace has none of those failure modes because it observes the process
27
+ * table instead of arguing about it, and it handles the write-a-copy idiom by
28
+ * matching on **content hash**: the copy is byte-identical to the guard, so it
29
+ * is the guard.
30
+ *
31
+ * ## What it does not see, stated rather than hidden
32
+ *
33
+ * - A helper reached by `source` rather than by `bash` is not a child process
34
+ * and never appears here. `lisa-edit-gate.sh` is the live example — every
35
+ * on-edit hook sources it. Such a helper enters the population only when a
36
+ * suite executes it directly, which is how CodySwannGT/lisa#3190's roster
37
+ * covers it.
38
+ * - Asynchronous child starts are not patched. Every child start in this
39
+ * test tree is synchronous by rule — see `tests/helpers/unbounded-spawn-scan`
40
+ * — so patching the three synchronous entry points covers the population,
41
+ * and a suite that started a guard asynchronously would be a rule violation
42
+ * caught by that scan first.
43
+ * - A suite that mocks `node:child_process` wholesale never reaches this
44
+ * module, and nothing real ran, so there is nothing to record.
45
+ *
46
+ * ## Loaded with `--import`, not as a vitest setup file
47
+ *
48
+ * `--import` runs before any user module links, so the patch lands on the CJS
49
+ * `child_process` exports that node's ESM facade for the builtin is later
50
+ * generated from. Measured: a vitest case using `import { spawnSync } from
51
+ * "node:child_process"` is intercepted. A vitest `setupFiles` entry runs after
52
+ * linking and would miss every already-bound import, and it would also collide
53
+ * with the suites that `vi.mock` the module.
54
+ *
55
+ * It NEVER throws and never changes a return value: a tracer that can break the
56
+ * suite it observes would be turned off, and then this whole control is prose.
57
+ *
58
+ * @module scripts/lib/shell-guard-trace
59
+ */
60
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
61
+ import { createHash } from "node:crypto";
62
+ import { createRequire } from "node:module";
63
+ import path from "node:path";
64
+ import process from "node:process";
65
+
66
+ /** Largest file the tracer will hash, in bytes. */
67
+ const MAX_HASHED_BYTES = 2_000_000;
68
+
69
+ /** Longest argv element the tracer will treat as a possible path. */
70
+ const MAX_PATH_LENGTH = 4096;
71
+
72
+ const traceFile = process.env["LISA_SHELL_GUARD_TRACE"];
73
+ const indexFile = process.env["LISA_SHELL_GUARD_INDEX"];
74
+
75
+ if (traceFile && indexFile) install(traceFile, indexFile);
76
+
77
+ /**
78
+ * Patch the synchronous child starts so every guard execution is recorded.
79
+ * @param {string} trace - Path of the JSONL trace to append to.
80
+ * @param {string} index - Path of the guard index JSON to read.
81
+ * @returns {void}
82
+ */
83
+ function install(trace, index) {
84
+ /** @type {{ byHash: Record<string, string[]>, sizes: number[] }} */
85
+ let inventory;
86
+ try {
87
+ inventory = JSON.parse(readFileSync(index, "utf8"));
88
+ } catch {
89
+ return;
90
+ }
91
+ const sizes = new Set(inventory.sizes);
92
+ const seen = new Set();
93
+
94
+ /**
95
+ * Append one observation, at most once per guard/status pair per process.
96
+ * @param {string} script - Repository-relative guard path.
97
+ * @param {number | null} status - The guard's exit status.
98
+ * @param {string} origin - Test frame that started it.
99
+ * @returns {void}
100
+ */
101
+ const record = (script, status, origin) => {
102
+ const key = `${script}:${String(status)}`;
103
+ if (seen.has(key)) return;
104
+ seen.add(key);
105
+ try {
106
+ appendFileSync(trace, `${JSON.stringify({ script, status, origin })}\n`);
107
+ } catch {
108
+ /* A trace that cannot be written must not fail the suite. */
109
+ }
110
+ };
111
+
112
+ /**
113
+ * Whether one child start RUNS its shell operand rather than inspecting it.
114
+ *
115
+ * This distinction is load-bearing and was found by measurement, not by
116
+ * reasoning. Without it, `bash -n script.sh` — a syntax check that never
117
+ * executes a line — reported fourteen `scripts/*.sh` as "driven, only ever
118
+ * exit 0", which is a false accusation of exactly the defect this control
119
+ * looks for. `shellcheck`, `cat` and `cp` name a guard in argv for the same
120
+ * reason and run none of it.
121
+ *
122
+ * So the operand counts only under an interpreter that will execute it, with
123
+ * POSIX `-n` (noexec) absent.
124
+ * @param {string} command - The command the child start was given.
125
+ * @param {string[]} argv - Its argument vector.
126
+ * @returns {boolean} Whether the operand is being executed.
127
+ */
128
+ const executes = (command, argv) => {
129
+ const runner = path.basename(command.split(/\s+/u)[0] ?? "");
130
+ if (!["bash", "sh", "dash", "ksh", "zsh", "env"].includes(runner)) {
131
+ return false;
132
+ }
133
+ return !argv.includes("-n");
134
+ };
135
+
136
+ /**
137
+ * Every tracked guard whose bytes appear in one child start's argv.
138
+ * @param {unknown} command - First argument of the child start.
139
+ * @param {unknown} args - Second argument: an argv array, or the options.
140
+ * @param {unknown} third - Third argument, when the second was an argv array.
141
+ * @returns {string[]} Repository-relative guard paths.
142
+ */
143
+ const matched = (command, args, third) => {
144
+ if (typeof command !== "string") return [];
145
+ const argv = Array.isArray(args)
146
+ ? args.filter(entry => typeof entry === "string")
147
+ : [];
148
+ // A guard invoked as the command itself is executing itself; anything else
149
+ // is executing only when the interpreter says so.
150
+ const tokens = executes(command, argv)
151
+ ? [...command.split(/\s+/u), ...argv]
152
+ : [command];
153
+ const options = Array.isArray(args) ? third : args;
154
+ const cwd =
155
+ options !== null &&
156
+ typeof options === "object" &&
157
+ typeof (/** @type {{cwd?: unknown}} */ (options).cwd) === "string"
158
+ ? /** @type {{cwd: string}} */ (options).cwd
159
+ : process.cwd();
160
+ const hits = new Set();
161
+ for (const token of tokens) {
162
+ if (!token || token.length > MAX_PATH_LENGTH) continue;
163
+ const absolute = path.isAbsolute(token)
164
+ ? token
165
+ : path.resolve(cwd, token);
166
+ let size = -1;
167
+ try {
168
+ const stats = statSync(absolute);
169
+ if (!stats.isFile()) continue;
170
+ size = stats.size;
171
+ } catch {
172
+ continue;
173
+ }
174
+ // Hash only what could be a guard. Every tracked guard's size is known,
175
+ // so a `git`, a `node`, or a fixture source is rejected by an integer
176
+ // comparison rather than by reading it.
177
+ if (!sizes.has(size) || size > MAX_HASHED_BYTES) continue;
178
+ try {
179
+ const digest = createHash("sha256")
180
+ .update(readFileSync(absolute))
181
+ .digest("hex");
182
+ for (const guard of inventory.byHash[digest] ?? []) hits.add(guard);
183
+ } catch {
184
+ continue;
185
+ }
186
+ }
187
+ return [...hits];
188
+ };
189
+
190
+ /**
191
+ * The first stack frame inside the test tree, for the failure report.
192
+ * @returns {string} A `file:line` frame, or an empty string.
193
+ */
194
+ const origin = () => {
195
+ const frames = (new Error("trace").stack ?? "")
196
+ .split("\n")
197
+ .filter(line => line.includes("/tests/"));
198
+ // The suite, not the helper it reached through. Every bounded spawn in this
199
+ // tree goes through `tests/helpers/io-latency-budget`, so naming the first
200
+ // `tests/` frame would name that helper for nearly every observation and
201
+ // the report would say nothing about where to look.
202
+ const frame = frames.find(line => line.includes(".test.ts")) ?? frames[0];
203
+ return (frame ?? "").trim().replace(/^at\s+/u, "");
204
+ };
205
+
206
+ const require = createRequire(import.meta.url);
207
+ const childProcess = require("node:child_process");
208
+
209
+ const originalSpawnSync = childProcess.spawnSync;
210
+ childProcess.spawnSync = function tracedSpawnSync(command, args, options) {
211
+ const result = originalSpawnSync.call(this, command, args, options);
212
+ try {
213
+ const guards = matched(command, args, options);
214
+ if (guards.length > 0) {
215
+ const where = origin();
216
+ for (const guard of guards) record(guard, result.status, where);
217
+ }
218
+ } catch {
219
+ /* Never let observation change the observed. */
220
+ }
221
+ return result;
222
+ };
223
+
224
+ for (const name of ["execFileSync", "execSync"]) {
225
+ const original = childProcess[name];
226
+ childProcess[name] = function tracedExec(command, args, options) {
227
+ /**
228
+ * Record one outcome of the wrapped call.
229
+ * @param {number | null} status - Its exit status.
230
+ * @returns {void}
231
+ */
232
+ const observe = status => {
233
+ try {
234
+ const guards = matched(command, args, options);
235
+ if (guards.length === 0) return;
236
+ const where = origin();
237
+ for (const guard of guards) record(guard, status, where);
238
+ } catch {
239
+ /* Never let observation change the observed. */
240
+ }
241
+ };
242
+ try {
243
+ const result = original.call(this, command, args, options);
244
+ observe(0);
245
+ return result;
246
+ } catch (error) {
247
+ observe(
248
+ typeof (/** @type {{status?: unknown}} */ (error).status) === "number"
249
+ ? /** @type {{status: number}} */ (error).status
250
+ : null
251
+ );
252
+ throw error;
253
+ }
254
+ };
255
+ }
256
+ }
@@ -167,6 +167,16 @@
167
167
  * `no_work` vocabulary, so evaluating on the `pull_request` event without
168
168
  * waiting manufactures a finding on EVERY pull request — and a guard that
169
169
  * fires every time gets deleted rather than read.
170
+ *
171
+ * RE-MEASURED (CodySwannGT/lisa#3221) across the last 40 merged pull
172
+ * requests: EVERY one carried multiple `CodeRabbit` statuses on the same
173
+ * head SHA — the sequence `Review queued` -> `Review in progress` -> the
174
+ * verdict, and SEVEN statuses on one of them, because a re-request restarts
175
+ * the sequence on the same commit. Only the LAST settled status is the
176
+ * verdict; reading any earlier one reports a hollow review on a pull
177
+ * request that may have been genuinely reviewed. Anyone tempted to drop
178
+ * `--settle-timeout`/`--settle-interval` for a single immediate read is
179
+ * re-introducing exactly that.
170
180
  * 3. **It REFUSES rather than reporting all-clear from an empty inspection.**
171
181
  * An unresolvable pull request, a `gh` that could not be read, a roster
172
182
  * with no checks in it, or a declaration naming no evidence-bearing check
@@ -992,13 +1002,17 @@ export function classifyCheckDescription(description, vocabulary = {}) {
992
1002
  * @param {object} declaration - The per-repo declaration
993
1003
  * @param {ReadonlyArray<{name: string, state: string, bucket?: string, description?: string}>} checks -
994
1004
  * Checks as `gh pr checks --json name,state,bucket,description` returns them
995
- * @param {{trustRequiredContexts?: boolean}} [options] - Set
996
- * `trustRequiredContexts: false` to stop asserting whether a check is required
1005
+ * @param {{trustRequiredContexts?: boolean, headSha?: string}} [options] - Set
1006
+ * `trustRequiredContexts: false` to stop asserting whether a check is required;
1007
+ * `headSha` is the commit the checks were read at, cited in every finding
997
1008
  * @returns {{violations: object[], checked: number}} Violations and how many declared checks were examined
998
1009
  */
999
1010
  export function evaluateVacuousChecks(declaration, checks, options = {}) {
1000
1011
  const declared = declaration.evidence_bearing_checks ?? {};
1001
1012
  const trustRequired = options.trustRequiredContexts !== false;
1013
+ // Every finding names the commit it was read at. A description is a property
1014
+ // of a SHA, not of a pull request — see {@link resolveHeadSha}.
1015
+ const at = citeHeadSha(options.headSha);
1002
1016
  const required = new Set(declaration.required_contexts ?? []);
1003
1017
  const violations = [];
1004
1018
  let checked = 0;
@@ -1012,7 +1026,7 @@ export function evaluateVacuousChecks(declaration, checks, options = {}) {
1012
1026
  violations.push({
1013
1027
  kind: VIOLATIONS.unproven,
1014
1028
  token: name,
1015
- 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.)`,
1029
+ message: `\`${name}\` is declared evidence-bearing but did not report on this pull request at all.${at} 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.)`,
1016
1030
  });
1017
1031
  continue;
1018
1032
  }
@@ -1037,12 +1051,12 @@ export function evaluateVacuousChecks(declaration, checks, options = {}) {
1037
1051
  kind: VIOLATIONS.vacuous,
1038
1052
  token: name,
1039
1053
  contexts: [name],
1040
- 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.`,
1054
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which says it DID NO WORK.${at}${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.`,
1041
1055
  }
1042
1056
  : {
1043
1057
  kind: VIOLATIONS.unproven,
1044
1058
  token: name,
1045
- 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.`,
1059
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which proves neither that it reviewed anything nor that it did not.${at}${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.`,
1046
1060
  }
1047
1061
  );
1048
1062
  }
@@ -1126,6 +1140,67 @@ export function resolveRepoSlug(repo, env = process.env) {
1126
1140
  }
1127
1141
  }
1128
1142
 
1143
+ /**
1144
+ * The commit this pull request's checks were read at.
1145
+ *
1146
+ * Named in every finding, and that is not decoration. A status DESCRIPTION is
1147
+ * not stable within a pull request: measured on CodySwannGT/lisa#3221, a head
1148
+ * commit carried `Review rate limited` while a commit pushed to the same branch
1149
+ * moments later carried `Review skipped: manual review required`, and whichever
1150
+ * one is head at merge time is the one branch protection recorded. Two readers
1151
+ * quoting the same pull request confidently disagreed; neither had misread
1152
+ * anything, and both had omitted the only fact that reconciles them.
1153
+ *
1154
+ * The PR HEAD is the right commit to read, and that was measured rather than
1155
+ * assumed: across the last 40 merged pull requests here, the MERGE COMMIT
1156
+ * carried no `CodeRabbit` status at all, 40 times out of 40. A guard keyed on
1157
+ * the merge commit would have read "absent" on every one of them.
1158
+ *
1159
+ * Returns undefined rather than throwing. Failing to name the commit must
1160
+ * degrade the finding's provenance, never suppress the finding — an unnamed SHA
1161
+ * is reported as unnamed, which is the honest shape.
1162
+ *
1163
+ * @param {string|number} pr - Pull request number
1164
+ * @param {string} [repo] - `OWNER/NAME`, or undefined to resolve it
1165
+ * @returns {string|undefined} The head commit SHA, or undefined when unresolvable
1166
+ */
1167
+ export function resolveHeadSha(pr, repo) {
1168
+ const slug = resolveRepoSlug(repo);
1169
+ if (slug === undefined) return undefined;
1170
+ try {
1171
+ const raw = boundedExecFileSync(
1172
+ "gh",
1173
+ [
1174
+ "pr",
1175
+ "view",
1176
+ String(pr),
1177
+ "--repo",
1178
+ slug,
1179
+ "--json",
1180
+ "headRefOid",
1181
+ "--jq",
1182
+ ".headRefOid",
1183
+ ],
1184
+ { encoding: "utf8" }
1185
+ ).trim();
1186
+ return raw === "" ? undefined : raw;
1187
+ } catch {
1188
+ return undefined;
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * How a finding cites the commit its evidence was read at.
1194
+ *
1195
+ * @param {string|undefined} headSha - The resolved head SHA, if any
1196
+ * @returns {string} A sentence naming the commit, or naming its absence
1197
+ */
1198
+ export function citeHeadSha(headSha) {
1199
+ return headSha === undefined
1200
+ ? " The head commit could NOT be resolved for this run, so this finding cannot be pinned to a SHA — treat it as unlocated evidence and re-read the pull request before quoting it."
1201
+ : ` Read at head commit ${headSha}.`;
1202
+ }
1203
+
1129
1204
  /**
1130
1205
  * Reads one `gh api` call that yields a JSON array, or throws with context.
1131
1206
  *
@@ -1544,9 +1619,9 @@ function readSecondsFlag(argv, name, fallback) {
1544
1619
  *
1545
1620
  * @param {ReadonlyArray<string>} argv - CLI arguments
1546
1621
  * @param {object} declaration - The per-repo declaration
1547
- * @param {{trustRequiredContexts?: boolean, env?: NodeJS.ProcessEnv, fetch?: Function, probeBranch?: Function, now?: Function, sleep?: Function}} [options] -
1622
+ * @param {{trustRequiredContexts?: boolean, env?: NodeJS.ProcessEnv, fetch?: Function, probeBranch?: Function, now?: Function, sleep?: Function, headSha?: Function}} [options] -
1548
1623
  * 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
1624
+ * @returns {{pr: string|undefined, prSource: string|null, headSha: string|undefined, checked: number, violations: object[], settled: boolean, refusal: {kind: string, reason: string}|null}|undefined} The inspection
1550
1625
  */
1551
1626
  export function inspectVacuity(argv, declaration, options = {}) {
1552
1627
  const wired = argv.includes("--vacuity");
@@ -1594,12 +1669,18 @@ export function inspectVacuity(argv, declaration, options = {}) {
1594
1669
  if (refusal !== null) {
1595
1670
  return { ...empty, settled: read.settled, refusal };
1596
1671
  }
1672
+ // Resolved here rather than inside a fetch path, so the commit is named
1673
+ // whichever route read the checks — `gh pr checks` does not return it, and
1674
+ // only the permission-light fallback was resolving it at all.
1675
+ const headSha = (options.headSha ?? resolveHeadSha)(pr, repo);
1597
1676
  const evaluated = evaluateVacuousChecks(declaration, read.checks, {
1598
1677
  trustRequiredContexts: options.trustRequiredContexts,
1678
+ headSha,
1599
1679
  });
1600
1680
  return {
1601
1681
  pr,
1602
1682
  prSource: source,
1683
+ headSha,
1603
1684
  checked: evaluated.checked,
1604
1685
  violations: evaluated.violations,
1605
1686
  settled: read.settled,