@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.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +11 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- 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-cursor/.claude-plugin/plugin.json +1 -1
- 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/scripts/check-shell-guard-refusal-coverage.mjs +233 -0
- package/scripts/lib/shell-guard-refusal-coverage.mjs +315 -0
- package/scripts/lib/shell-guard-trace.mjs +256 -0
- 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
|
|
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,
|