@codyswann/lisa 4.17.5 → 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 (57) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +10 -0
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +3 -2
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo-agy/plugin.json +1 -1
  18. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  28. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  33. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  38. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails-agy/plugin.json +1 -1
  43. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  48. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  53. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  55. package/scripts/check-shell-guard-refusal-coverage.mjs +233 -0
  56. package/scripts/lib/shell-guard-refusal-coverage.mjs +315 -0
  57. package/scripts/lib/shell-guard-trace.mjs +256 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-shell-guard-refusal-coverage — refuse a shell guard that tests EXECUTE
4
+ * but never drive onto a refusal path (CodySwannGT/lisa#3190).
5
+ *
6
+ * ## What it does
7
+ *
8
+ * Runs the suites that can execute a shipped `.sh` guard with
9
+ * {@link module:scripts/lib/shell-guard-trace} loaded, so every guard execution
10
+ * and its exit status is recorded, then judges the trace with
11
+ * {@link module:scripts/lib/shell-guard-refusal-coverage}. A guard the run
12
+ * observed only ever exiting 0 is reported by name.
13
+ *
14
+ * The population is what RAN. That is the whole point: the roster this check
15
+ * governs cannot be written down, because a written roster is what went stale
16
+ * in CodySwannGT/lisa#2847 and what reads as covered in CodySwannGT/lisa#3190.
17
+ * A new guard with a new driving test joins the population the moment its
18
+ * suite runs it, with nothing edited here.
19
+ *
20
+ * ## Why it fails at zero observed
21
+ *
22
+ * A run that observed nothing and a clean tree would otherwise print the same
23
+ * tick. A missing tracer, an unwritable trace file, a candidate filter that
24
+ * matched no suite, and a vitest invocation that never started all reach that
25
+ * branch — and every one of them is a control reporting success while inert,
26
+ * which is the family this whole campaign has been closing. Zero observed is
27
+ * exit 2, never exit 0.
28
+ *
29
+ * CLI:
30
+ * node scripts/check-shell-guard-refusal-coverage.mjs [--json] [--trace FILE]
31
+ *
32
+ * --trace FILE judge an existing trace instead of producing one. Use it in
33
+ * CI when the full suite already ran with the tracer imported.
34
+ *
35
+ * Exit codes (mirroring the sibling check-* scripts):
36
+ * 0 — every guard the run drove was observed refusing and allowing.
37
+ * 1 — >=1 finding.
38
+ * 2 — operational error: unknown flag, vitest could not run, or ZERO guards
39
+ * observed.
40
+ *
41
+ * @module scripts/check-shell-guard-refusal-coverage
42
+ */
43
+ import { execFileSync, spawnSync } from "node:child_process";
44
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import path from "node:path";
47
+ import process from "node:process";
48
+ import { fileURLToPath } from "node:url";
49
+
50
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
51
+ import {
52
+ formatReport,
53
+ guardPopulation,
54
+ judge,
55
+ parseTrace,
56
+ tracerIndex,
57
+ } from "./lib/shell-guard-refusal-coverage.mjs";
58
+
59
+ /** Wall-clock ceiling for the traced vitest run. */
60
+ const RUN_TIMEOUT_MS = 45 * 60_000;
61
+
62
+ /** Wall-clock ceiling for the one `git` call this script makes. */
63
+ const GIT_TIMEOUT_MS = 60_000;
64
+
65
+ /** Substrings whose presence means a module starts a child process. */
66
+ const CHILD_STARTS = Object.freeze([
67
+ "spawnSync",
68
+ "execFileSync",
69
+ "execSync",
70
+ "boundedSpawnSync",
71
+ "boundedExecFileSync",
72
+ "boundedBash",
73
+ ]);
74
+
75
+ /**
76
+ * Read one tracked file, tolerating a path git lists but the tree lacks.
77
+ * @param {string} root - Repository root.
78
+ * @param {string} file - Repository-relative path.
79
+ * @returns {string} File text, or an empty string.
80
+ */
81
+ function readOrEmpty(root, file) {
82
+ try {
83
+ return readFileSync(path.join(root, file), "utf8");
84
+ } catch {
85
+ return "";
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The test files that could execute a shipped guard.
91
+ *
92
+ * A deliberate SUPERSET: a file is a candidate when its unit — itself plus the
93
+ * `tests/` modules it transitively imports — both names something ending in
94
+ * `.sh` and starts a child process. Over-inclusion costs runtime;
95
+ * under-inclusion costs a guard silently leaving the population, which is the
96
+ * failure this check exists to prevent.
97
+ * @param {string} root - Repository root.
98
+ * @returns {string[]} Repository-relative test file paths, sorted.
99
+ */
100
+ export function candidateSuites(root) {
101
+ const tracked = execFileSync("git", ["ls-files", "-z", "tests"], {
102
+ cwd: root,
103
+ encoding: "utf8",
104
+ maxBuffer: 64 * 1024 * 1024,
105
+ timeout: GIT_TIMEOUT_MS,
106
+ })
107
+ .split("\0")
108
+ .filter(file => file.endsWith(".ts"));
109
+ const text = new Map(tracked.map(file => [file, readOrEmpty(root, file)]));
110
+
111
+ /**
112
+ * Every `tests/` module one file reaches, itself included.
113
+ * @param {string} file - Repository-relative path.
114
+ * @param {Set<string>} seen - Modules already walked.
115
+ * @returns {string[]} Repository-relative module paths.
116
+ */
117
+ const unit = (file, seen) => {
118
+ if (seen.has(file)) return [];
119
+ seen.add(file);
120
+ const source = text.get(file) ?? "";
121
+ const imports = [...source.matchAll(/from\s+"(\.[^"]+)"/gu)]
122
+ .map(hit =>
123
+ path.join(path.dirname(file), hit[1] ?? "").replace(/\.js$/u, ".ts")
124
+ )
125
+ .filter(candidate => text.has(candidate));
126
+ return [file, ...imports.flatMap(next => unit(next, seen))];
127
+ };
128
+
129
+ return tracked
130
+ .filter(file => file.endsWith(".test.ts"))
131
+ .filter(file => {
132
+ const sources = unit(file, new Set()).map(part => text.get(part) ?? "");
133
+ return (
134
+ sources.some(source => source.includes(".sh")) &&
135
+ sources.some(source => CHILD_STARTS.some(call => source.includes(call)))
136
+ );
137
+ })
138
+ .sort((left, right) => left.localeCompare(right));
139
+ }
140
+
141
+ /**
142
+ * Run the candidate suites with the tracer imported and return the raw trace.
143
+ * @param {string} root - Repository root.
144
+ * @param {ReturnType<typeof guardPopulation>} population - Tracked guards.
145
+ * @returns {{trace: string, suites: number, ok: boolean, detail: string}} Result.
146
+ */
147
+ function produceTrace(root, population) {
148
+ const suites = candidateSuites(root);
149
+ const workspace = mkdtempSync(path.join(tmpdir(), "lisa-guard-trace-"));
150
+ const tracePath = path.join(workspace, "trace.jsonl");
151
+ const indexPath = path.join(workspace, "index.json");
152
+ writeFileSync(tracePath, "");
153
+ writeFileSync(indexPath, JSON.stringify(tracerIndex(population)));
154
+ const tracer = path.join(
155
+ path.dirname(fileURLToPath(import.meta.url)),
156
+ "lib",
157
+ "shell-guard-trace.mjs"
158
+ );
159
+ const result = spawnSync(
160
+ path.join(root, "node_modules", ".bin", "vitest"),
161
+ ["run", "--reporter=dot", ...suites],
162
+ {
163
+ cwd: root,
164
+ encoding: "utf8",
165
+ timeout: RUN_TIMEOUT_MS,
166
+ stdio: ["ignore", "pipe", "pipe"],
167
+ env: {
168
+ ...process.env,
169
+ LISA_SHELL_GUARD_TRACE: tracePath,
170
+ LISA_SHELL_GUARD_INDEX: indexPath,
171
+ NODE_OPTIONS:
172
+ `${process.env["NODE_OPTIONS"] ?? ""} --import=${tracer}`.trim(),
173
+ },
174
+ }
175
+ );
176
+ const detail =
177
+ result.signal !== null
178
+ ? `vitest was KILLED (${result.signal}) rather than completing`
179
+ : `vitest exited ${String(result.status)}`;
180
+ return {
181
+ trace: readOrEmpty(workspace, "trace.jsonl"),
182
+ suites: suites.length,
183
+ ok: result.signal === null && result.status !== null,
184
+ detail,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * CLI entry point.
190
+ * @returns {void}
191
+ */
192
+ export function main() {
193
+ const args = process.argv.slice(2);
194
+ const known = new Set(["--json", "--trace"]);
195
+ const unknown = args.find(
196
+ (arg, at) =>
197
+ arg.startsWith("--") && !known.has(arg) && args[at - 1] !== "--trace"
198
+ );
199
+ if (unknown) {
200
+ console.error(
201
+ `check:shell-guard-refusal-coverage: unknown flag ${unknown}`
202
+ );
203
+ process.exitCode = 2;
204
+ return;
205
+ }
206
+ const root = process.cwd();
207
+ const population = guardPopulation(root);
208
+ const at = args.indexOf("--trace");
209
+ const supplied = at === -1 ? undefined : args[at + 1];
210
+ const produced =
211
+ supplied === undefined
212
+ ? produceTrace(root, population)
213
+ : { trace: readOrEmpty(root, supplied), suites: 0, ok: true, detail: "" };
214
+ if (!produced.ok) {
215
+ console.error(`check:shell-guard-refusal-coverage: ${produced.detail}`);
216
+ process.exitCode = 2;
217
+ return;
218
+ }
219
+ const report = judge({ population, observed: parseTrace(produced.trace) });
220
+ console.log(
221
+ args.includes("--json")
222
+ ? JSON.stringify(report, (_key, value) =>
223
+ value instanceof Set ? [...value] : value
224
+ )
225
+ : formatReport(report)
226
+ );
227
+ if (report.driven === 0) process.exitCode = 2;
228
+ else if (report.findings.length > 0) process.exitCode = 1;
229
+ }
230
+
231
+ if (invokedAsScript(import.meta.url)) {
232
+ main();
233
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * shell-guard-refusal-coverage — the population, the rule, and the verdict for
3
+ * "a shell guard that tests run must be seen REFUSING, not only allowing"
4
+ * (CodySwannGT/lisa#3190).
5
+ *
6
+ * @remarks
7
+ * ## The defect this exists to keep closed
8
+ *
9
+ * CodySwannGT/lisa#3111 established that Stryker cannot instrument shell at
10
+ * all, so a driving test — a payload table asserting blocked and allowed with a
11
+ * control on both sides — is the only bite evidence a shell guard can have.
12
+ * That survey produced two rosters. The one this module governs is the
13
+ * deceptive half: nine guards that WERE executed by passing tests where every
14
+ * case exercised the allow path. Replace any of them with `exit 0` and its
15
+ * suite still passed. A guard with no test is visibly unproven; a guard with a
16
+ * green allows-only suite reads as covered.
17
+ *
18
+ * CodySwannGT/lisa#3054 is why the class is not hypothetical: every verdict
19
+ * site in `parity-safety-net.sh` reached its answer through `printf … | grep -q
20
+ * …`, and a `grep` exiting 2 made the shipped hook ALLOW catastrophic deletes,
21
+ * silently. An allows-only suite cannot detect that by construction.
22
+ *
23
+ * ## Why this reports on observations rather than on assertions
24
+ *
25
+ * The population comes from {@link module:scripts/lib/shell-guard-trace} — what
26
+ * the suites actually EXECUTED — and the coverage question is asked of the exit
27
+ * statuses observed. This module therefore proves "the guard was seen refusing
28
+ * under test", which is one step weaker than "an `expect` named the refusal".
29
+ * Stated plainly rather than papered over: the discriminating evidence for the
30
+ * latter is neutering the guard to an unconditional `exit 0` and watching the
31
+ * named case fail, which is a thing a human or a mutation gate does, not a
32
+ * thing a scan can assert. What this control buys is that the *class* cannot
33
+ * come back silently — a new driven guard with no refusal observation is named
34
+ * on the next run.
35
+ *
36
+ * ## Byte-identical copies are one guard, and drift creates a new one
37
+ *
38
+ * Matching is by content hash, so the thirteen tracked copies of
39
+ * `lisa-edit-gate.sh` are the same program and one execution covers all of
40
+ * them. That is not a shortcut around the copy problem, it is the correct
41
+ * answer to it: the copies are proven together precisely while they are
42
+ * identical, and the moment one DRIFTS it hashes differently, becomes its own
43
+ * unobserved guard, and this check names it.
44
+ *
45
+ * ## Two rules, and why `set -e` is not one of them
46
+ *
47
+ * A guard is required to be seen refusing only if its source contains a literal
48
+ * non-zero `exit`. A script that merely runs under `set -e` is NOT counted:
49
+ * dying because a tool it called died is a crash path, not a verdict, and
50
+ * requiring a "refusal case" for it would turn this control into a demand for
51
+ * fault injection on every setup script in the tree — which is how a guard
52
+ * earns an exemption list, and an exemption list added to harden a guard has
53
+ * already become the way around one here.
54
+ *
55
+ * A guard at the agent TOOL BOUNDARY — one installed as a hook or reading a
56
+ * tool payload, WHOSE OWN SOURCE contains a literal `exit 2` — is held to the
57
+ * sharper rule: it must be seen exiting exactly 2. Both halves are needed. A
58
+ * `SessionStart` hook that only ever exits 1 is not making a deny decision, and
59
+ * demanding a 2 from it would be this check inventing a contract; a hook that
60
+ * DOES spell `exit 2` has declared the deny contract itself.
61
+ *
62
+ * Why the sharper rule is worth the extra clause: exit 2 is the `PreToolUse`
63
+ * deny contract and exit 1 is a guard that crashed, so evidence that accepts
64
+ * either cannot tell a working guard from a broken one — and
65
+ * CodySwannGT/lisa#3188 was precisely a guard that, when it could not run,
66
+ * permitted everything.
67
+ *
68
+ * @module scripts/lib/shell-guard-refusal-coverage
69
+ */
70
+ import { createHash } from "node:crypto";
71
+ import { execFileSync } from "node:child_process";
72
+ import { readFileSync } from "node:fs";
73
+ import path from "node:path";
74
+
75
+ /** Wall-clock ceiling for the one `git` call this module makes. */
76
+ const GIT_TIMEOUT_MS = 60_000;
77
+
78
+ /** The exit status an agent tool-boundary guard uses to refuse. */
79
+ export const REFUSED = 2;
80
+
81
+ /**
82
+ * Path prefixes whose `.sh` files are not shipped guards.
83
+ *
84
+ * `tests/` holds pinned pre-change snapshots and fixture scripts, which are
85
+ * executed constantly and are not the subject; `dist/` is this repository's own
86
+ * source a second time, from its build output.
87
+ */
88
+ const NOT_GUARDS = Object.freeze(["tests/", "dist/", ".husky/_/"]);
89
+
90
+ /**
91
+ * Guards that cannot be driven to refuse hermetically, with the reason.
92
+ *
93
+ * Deliberately checked in and deliberately EMPTY. An omission recorded here is
94
+ * visible; an omission left out of the roster is absent, and CodySwannGT/lisa#3190
95
+ * is a ticket about the difference. An entry must name a guard the trace
96
+ * actually observed — an exclusion for a guard nothing runs is reported as
97
+ * stale rather than quietly kept.
98
+ *
99
+ * @type {readonly {script: string, reason: string}[]}
100
+ */
101
+ export const HERMETIC_EXCLUSIONS = Object.freeze([]);
102
+
103
+ /**
104
+ * Every tracked shell guard in a checkout, with what its source can do.
105
+ * @param {string} root - Repository root.
106
+ * @returns {{path: string, sha256: string, size: number, canRefuse: boolean, toolBoundary: boolean}[]}
107
+ * One entry per tracked guard, sorted by path.
108
+ */
109
+ export function guardPopulation(root) {
110
+ const tracked = execFileSync("git", ["ls-files", "-z", "*.sh"], {
111
+ cwd: root,
112
+ encoding: "utf8",
113
+ maxBuffer: 64 * 1024 * 1024,
114
+ timeout: GIT_TIMEOUT_MS,
115
+ })
116
+ .split("\0")
117
+ .filter(file => file.length > 0)
118
+ .filter(file => !NOT_GUARDS.some(prefix => file.startsWith(prefix)))
119
+ .sort((left, right) => left.localeCompare(right));
120
+ return tracked.flatMap(file => {
121
+ let bytes;
122
+ try {
123
+ bytes = readFileSync(path.join(root, file));
124
+ } catch {
125
+ return [];
126
+ }
127
+ const source = bytes.toString("utf8");
128
+ return [
129
+ {
130
+ path: file,
131
+ sha256: createHash("sha256").update(bytes).digest("hex"),
132
+ size: bytes.length,
133
+ canRefuse: /\bexit[ \t]+[1-9]\d*\b/u.test(source),
134
+ toolBoundary:
135
+ (file.includes("/hooks/") ||
136
+ /tool_input|"file_path"/u.test(source)) &&
137
+ /\bexit[ \t]+2\b/u.test(source),
138
+ },
139
+ ];
140
+ });
141
+ }
142
+
143
+ /**
144
+ * The index the tracer consults, derived from the population.
145
+ * @param {ReturnType<typeof guardPopulation>} population - Tracked guards.
146
+ * @returns {{byHash: Record<string, string[]>, sizes: number[]}} Tracer index.
147
+ */
148
+ export function tracerIndex(population) {
149
+ /** @type {Record<string, string[]>} */
150
+ const byHash = {};
151
+ for (const guard of population) {
152
+ byHash[guard.sha256] = [...(byHash[guard.sha256] ?? []), guard.path];
153
+ }
154
+ return { byHash, sizes: [...new Set(population.map(g => g.size))] };
155
+ }
156
+
157
+ /**
158
+ * Parse a JSONL trace into the statuses observed per guard.
159
+ * @param {string} jsonl - Raw trace content.
160
+ * @returns {Map<string, {statuses: Set<number>, origins: Set<string>}>} Observations.
161
+ */
162
+ export function parseTrace(jsonl) {
163
+ /** @type {Map<string, {statuses: Set<number>, origins: Set<string>}>} */
164
+ const observed = new Map();
165
+ for (const line of jsonl.split("\n")) {
166
+ if (line.trim().length === 0) continue;
167
+ /** @type {{script?: unknown, status?: unknown, origin?: unknown}} */
168
+ let record;
169
+ try {
170
+ record = JSON.parse(line);
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (typeof record.script !== "string") continue;
175
+ const entry = observed.get(record.script) ?? {
176
+ statuses: new Set(),
177
+ origins: new Set(),
178
+ };
179
+ if (typeof record.status === "number") entry.statuses.add(record.status);
180
+ if (typeof record.origin === "string" && record.origin.length > 0) {
181
+ entry.origins.add(record.origin);
182
+ }
183
+ observed.set(record.script, entry);
184
+ }
185
+ return observed;
186
+ }
187
+
188
+ /**
189
+ * Judge one run's observations against the population.
190
+ * @param {object} input - Everything the verdict depends on.
191
+ * @param {ReturnType<typeof guardPopulation>} input.population - Tracked guards.
192
+ * @param {ReturnType<typeof parseTrace>} input.observed - Trace observations.
193
+ * @param {readonly {script: string, reason: string}[]} [input.exclusions] - Recorded omissions.
194
+ * @returns {{driven: number, findings: {script: string, kind: string, detail: string}[], excluded: readonly {script: string, reason: string}[]}}
195
+ * The report.
196
+ */
197
+ export function judge({
198
+ population,
199
+ observed,
200
+ exclusions = HERMETIC_EXCLUSIONS,
201
+ }) {
202
+ const excludedScripts = new Set(exclusions.map(entry => entry.script));
203
+ const byPath = new Map(population.map(guard => [guard.path, guard]));
204
+ /** @type {{script: string, kind: string, detail: string}[]} */
205
+ const findings = [];
206
+ const driven = [...observed.keys()]
207
+ .filter(script => byPath.has(script))
208
+ .sort();
209
+
210
+ for (const script of driven) {
211
+ const guard = /** @type {NonNullable<ReturnType<typeof byPath.get>>} */ (
212
+ byPath.get(script)
213
+ );
214
+ const statuses = /** @type {{statuses: Set<number>}} */ (
215
+ observed.get(script)
216
+ ).statuses;
217
+ const seen = [...statuses].sort((a, b) => a - b).join(", ");
218
+ if (!statuses.has(0)) {
219
+ findings.push({
220
+ script,
221
+ kind: "no-allow-control",
222
+ detail: `never observed allowing ordinary work (statuses seen: ${seen || "none"})`,
223
+ });
224
+ }
225
+ if (excludedScripts.has(script) || !guard.canRefuse) continue;
226
+ const refusals = [...statuses].filter(status => status !== 0);
227
+ if (refusals.length === 0) {
228
+ findings.push({
229
+ script,
230
+ kind: "no-refusal-case",
231
+ detail: `driven ${statuses.size} time(s), only ever exit 0 — nothing proves it can refuse`,
232
+ });
233
+ continue;
234
+ }
235
+ if (guard.toolBoundary && !statuses.has(REFUSED)) {
236
+ findings.push({
237
+ script,
238
+ kind: "no-refusal-case",
239
+ detail: `refuses only with exit ${refusals.join("/")}; the tool boundary contract is exit ${REFUSED}, and exit 1 is a guard that crashed`,
240
+ });
241
+ }
242
+ }
243
+
244
+ for (const entry of exclusions) {
245
+ if (!byPath.has(entry.script)) {
246
+ findings.push({
247
+ script: entry.script,
248
+ kind: "unknown-exclusion",
249
+ detail: "recorded as un-drivable but is not a tracked guard",
250
+ });
251
+ continue;
252
+ }
253
+ if (!observed.has(entry.script)) {
254
+ findings.push({
255
+ script: entry.script,
256
+ kind: "stale-exclusion",
257
+ detail: "recorded as un-drivable but no test drives it at all",
258
+ });
259
+ continue;
260
+ }
261
+ const statuses = /** @type {{statuses: Set<number>}} */ (
262
+ observed.get(entry.script)
263
+ ).statuses;
264
+ if ([...statuses].some(status => status !== 0)) {
265
+ findings.push({
266
+ script: entry.script,
267
+ kind: "stale-exclusion",
268
+ detail: "recorded as un-drivable, yet it was observed refusing",
269
+ });
270
+ }
271
+ }
272
+
273
+ return { driven: driven.length, findings, excluded: exclusions };
274
+ }
275
+
276
+ /**
277
+ * Render one report for a terminal.
278
+ * @param {ReturnType<typeof judge>} report - The verdict.
279
+ * @returns {string} Human-readable text.
280
+ */
281
+ export function formatReport(report) {
282
+ const lines = [
283
+ `shell-guard refusal coverage: ${report.driven} guard(s) observed running under test`,
284
+ ];
285
+ if (report.driven === 0) {
286
+ lines.push(
287
+ "",
288
+ " ✖ NOTHING was observed. No guard ran, so no guard was measured, and",
289
+ " nothing passed — this is the inert-control shape the check exists to",
290
+ " refuse. Check that the trace file and the tracer import both reached",
291
+ " the vitest workers."
292
+ );
293
+ return lines.join("\n");
294
+ }
295
+ for (const entry of report.excluded) {
296
+ lines.push(` ⚪ ${entry.script} — omitted on purpose: ${entry.reason}`);
297
+ }
298
+ if (report.findings.length === 0) {
299
+ lines.push(" ✔ Every driven guard was observed refusing AND allowing.");
300
+ return lines.join("\n");
301
+ }
302
+ for (const finding of report.findings) {
303
+ lines.push(` ✖ ${finding.script} — ${finding.detail}`);
304
+ }
305
+ lines.push(
306
+ "",
307
+ "Fix: add a case that drives the guard onto a refusal path and asserts the",
308
+ "exit status EXACTLY — `expect(result.status).toBe(2)` for a tool-boundary",
309
+ "guard. `not.toBe(0)` is not enough: it cannot tell a guard that refused",
310
+ "from one that crashed. Keep the allow control beside it.",
311
+ "A guard that genuinely cannot be driven hermetically belongs in",
312
+ "HERMETIC_EXCLUSIONS with its reason, so the omission is visible."
313
+ );
314
+ return lines.join("\n");
315
+ }