vigiles 15.0.0 → 15.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,21 @@
1
- export type ScriptStatus = "pass" | "skip" | "fail";
1
+ /**
2
+ * The outcome of running one script.
3
+ *
4
+ * `"vacuous"` — the script exited 0 and reported that it made ZERO checks. It
5
+ * neither passed nor failed: nothing was verified. See {@link statusFor}.
6
+ */
7
+ export type ScriptStatus = "pass" | "skip" | "fail" | "vacuous";
2
8
  export interface ScriptRunResult {
3
9
  readonly file: string;
4
10
  readonly code: number;
5
11
  readonly status: ScriptStatus;
12
+ /**
13
+ * How many checks the script reported making, or `undefined` when it reported
14
+ * nothing at all — a script that never imports `vigiles/testing` has no way to
15
+ * report, and that silence is NOT a claim about it. `0` is a claim: the script
16
+ * loaded the library and used none of it.
17
+ */
18
+ readonly checks?: number;
6
19
  }
7
20
  /**
8
21
  * Exit code a harness/eval script uses to report itself SKIPPED (e.g. the
@@ -11,6 +24,32 @@ export interface ScriptRunResult {
11
24
  * skip never fails the run. Scripts call `skip()` (vigiles/testing) to emit it.
12
25
  */
13
26
  export declare const SKIP_EXIT_CODE = 77;
27
+ /**
28
+ * Classify one script's run from its exit code and its reported check count.
29
+ *
30
+ * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
31
+ * do anything?". Measured 2026-08-08: a file whose whole body is
32
+ * `export default { "never runs": () => assert.equal(1, 2) }` imports fine,
33
+ * exits 0, and printed `✓ … 1 passed` — a false assertion, never called,
34
+ * reported as a pass. A consumer repo hit exactly that and now hand-copies a
35
+ * warning into every new harness header, because the runner could not enforce
36
+ * it: eight harnesses resting on a comment.
37
+ *
38
+ * So a run that ends clean having recorded ZERO checks is `"vacuous"` — its own
39
+ * visible state, not folded into `passed`, the same way a skip is not.
40
+ *
41
+ * NOT A FAILURE, deliberately. Harnesses in the wild predate the counter, and a
42
+ * tool that turned CI red on the release that taught it a new word would be
43
+ * punishing people for upgrading. It is loud and it is not fatal.
44
+ *
45
+ * AND SILENCE IS NOT ZERO. `checks === undefined` means the script never
46
+ * reported — it may not import `vigiles/testing` at all — so it stays a plain
47
+ * `pass`, exactly as before. Only a script that loaded the library and used
48
+ * none of it says zero. This is the `undefined`-vs-`[]` distinction
49
+ * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
50
+ * happened".
51
+ */
52
+ export declare function statusFor(code: number, checks: number | undefined): ScriptStatus;
14
53
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
15
54
  export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
16
55
  /** Glob suffix matching every accepted script extension, e.g. `harness`. */
@@ -41,10 +80,20 @@ export declare function discoverScripts(patterns: readonly string[], defaultGlob
41
80
  /**
42
81
  * Run each script as `node <file>`, inheriting stdio so the script's own report
43
82
  * streams to the console. `env` is merged over `process.env` for every child
44
- * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
83
+ * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes + check counts.
84
+ *
85
+ * Each child is handed its OWN scratch path in `VIGILES_CHECK_COUNT_ENV`, which
86
+ * `vigiles/testing` writes its check count to on exit — the channel that makes
87
+ * "ran nothing" distinguishable from "ran and passed" (see check-count.ts). It
88
+ * has to be a file: stdio is inherited so the script's report streams live,
89
+ * which leaves no stream to parse.
45
90
  */
46
91
  export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv): ScriptRunResult[];
47
- /** Whether any script FAILED (a skip is not a failure). */
92
+ /**
93
+ * Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
94
+ * declined to run, the second ran and verified nothing, and neither is evidence
95
+ * that anything is broken. Both are visible in the summary instead.
96
+ */
48
97
  export declare function anyFailed(results: readonly ScriptRunResult[]): boolean;
49
98
  /**
50
99
  * What a `test`/`eval` invocation should do about actually RUNNING the discovered
@@ -87,7 +136,8 @@ export interface RunScriptsEnv {
87
136
  * CLI.
88
137
  */
89
138
  export declare function decideRunScripts(o: RunScriptsEnv): RunScriptsDecision;
90
- /** One line per file + an explicit pass/skip/fail tally. Skips are SHOWN, never
91
- * folded into "passed" — a `⊘ SKIPPED` is loud, not a silent green. */
139
+ /** One line per file + an explicit pass/skip/vacuous/fail tally. Skips and
140
+ * vacuous runs are SHOWN, never folded into "passed" — a `⊘ SKIPPED` is loud,
141
+ * not a silent green, and so is a file that verified nothing. */
92
142
  export declare function formatScriptSummary(results: readonly ScriptRunResult[]): string;
93
143
  //# sourceMappingURL=run-scripts.d.ts.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SCRIPT_EXTS = exports.SKIP_EXIT_CODE = void 0;
4
+ exports.statusFor = statusFor;
4
5
  exports.scriptGlob = scriptGlob;
5
6
  exports.interpreterArgs = interpreterArgs;
6
7
  exports.detectNodeCaps = detectNodeCaps;
@@ -24,7 +25,9 @@ exports.formatScriptSummary = formatScriptSummary;
24
25
  const node_child_process_1 = require("node:child_process");
25
26
  const node_path_1 = require("node:path");
26
27
  const node_fs_1 = require("node:fs");
28
+ const node_os_1 = require("node:os");
27
29
  const glob_1 = require("glob");
30
+ const check_count_js_1 = require("../../check-count.js");
28
31
  /**
29
32
  * Exit code a harness/eval script uses to report itself SKIPPED (e.g. the
30
33
  * deterministic tier when `claude` isn't installed) — the autotools convention.
@@ -32,12 +35,37 @@ const glob_1 = require("glob");
32
35
  * skip never fails the run. Scripts call `skip()` (vigiles/testing) to emit it.
33
36
  */
34
37
  exports.SKIP_EXIT_CODE = 77;
35
- function statusForCode(code) {
36
- if (code === 0)
37
- return "pass";
38
+ /**
39
+ * Classify one script's run from its exit code and its reported check count.
40
+ *
41
+ * 🔴 THE FOURTH STATE, AND WHY. Exit codes answer "did it fail?", never "did it
42
+ * do anything?". Measured 2026-08-08: a file whose whole body is
43
+ * `export default { "never runs": () => assert.equal(1, 2) }` imports fine,
44
+ * exits 0, and printed `✓ … 1 passed` — a false assertion, never called,
45
+ * reported as a pass. A consumer repo hit exactly that and now hand-copies a
46
+ * warning into every new harness header, because the runner could not enforce
47
+ * it: eight harnesses resting on a comment.
48
+ *
49
+ * So a run that ends clean having recorded ZERO checks is `"vacuous"` — its own
50
+ * visible state, not folded into `passed`, the same way a skip is not.
51
+ *
52
+ * NOT A FAILURE, deliberately. Harnesses in the wild predate the counter, and a
53
+ * tool that turned CI red on the release that taught it a new word would be
54
+ * punishing people for upgrading. It is loud and it is not fatal.
55
+ *
56
+ * AND SILENCE IS NOT ZERO. `checks === undefined` means the script never
57
+ * reported — it may not import `vigiles/testing` at all — so it stays a plain
58
+ * `pass`, exactly as before. Only a script that loaded the library and used
59
+ * none of it says zero. This is the `undefined`-vs-`[]` distinction
60
+ * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
61
+ * happened".
62
+ */
63
+ function statusFor(code, checks) {
38
64
  if (code === exports.SKIP_EXIT_CODE)
39
65
  return "skip";
40
- return "fail";
66
+ if (code !== 0)
67
+ return "fail";
68
+ return checks === 0 ? "vacuous" : "pass";
41
69
  }
42
70
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
43
71
  exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
@@ -104,35 +132,73 @@ function discoverScripts(patterns, defaultGlob, cwd) {
104
132
  }
105
133
  return [...found].sort();
106
134
  }
135
+ /**
136
+ * The count a script left behind, or `undefined` if it left none (it never
137
+ * imported `vigiles/testing`, or died before its exit handler). Anything that
138
+ * isn't a non-negative integer is treated as no report — a corrupt scratch file
139
+ * must not invent a verdict.
140
+ */
141
+ function readCheckCount(path) {
142
+ if (!(0, node_fs_1.existsSync)(path))
143
+ return undefined;
144
+ let raw;
145
+ try {
146
+ raw = (0, node_fs_1.readFileSync)(path, "utf8").trim();
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
151
+ if (!/^\d+$/.test(raw))
152
+ return undefined;
153
+ return Number(raw);
154
+ }
107
155
  /**
108
156
  * Run each script as `node <file>`, inheriting stdio so the script's own report
109
157
  * streams to the console. `env` is merged over `process.env` for every child
110
- * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
158
+ * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes + check counts.
159
+ *
160
+ * Each child is handed its OWN scratch path in `VIGILES_CHECK_COUNT_ENV`, which
161
+ * `vigiles/testing` writes its check count to on exit — the channel that makes
162
+ * "ran nothing" distinguishable from "ran and passed" (see check-count.ts). It
163
+ * has to be a file: stdio is inherited so the script's report streams live,
164
+ * which leaves no stream to parse.
111
165
  */
112
166
  function runScripts(files, cwd, env = {}) {
113
167
  const caps = detectNodeCaps(cwd);
114
168
  const results = [];
115
- for (const file of files) {
116
- let argv;
117
- try {
118
- argv = interpreterArgs(file, caps);
119
- }
120
- catch (e) {
121
- console.error(`✗ ${file}: ${e.message}`);
122
- results.push({ file, code: 1, status: "fail" });
123
- continue;
124
- }
125
- const res = (0, node_child_process_1.spawnSync)("node", argv, {
126
- cwd,
127
- stdio: "inherit",
128
- env: { ...process.env, ...env },
169
+ const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-checks-"));
170
+ try {
171
+ files.forEach((file, i) => {
172
+ let argv;
173
+ try {
174
+ argv = interpreterArgs(file, caps);
175
+ }
176
+ catch (e) {
177
+ console.error(`✗ ${file}: ${e.message}`);
178
+ results.push({ file, code: 1, status: "fail" });
179
+ return;
180
+ }
181
+ const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
182
+ const res = (0, node_child_process_1.spawnSync)("node", argv, {
183
+ cwd,
184
+ stdio: "inherit",
185
+ env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
186
+ });
187
+ const code = res.status ?? 1;
188
+ const checks = readCheckCount(countFile);
189
+ results.push({ file, code, status: statusFor(code, checks), checks });
129
190
  });
130
- const code = res.status ?? 1;
131
- results.push({ file, code, status: statusForCode(code) });
191
+ }
192
+ finally {
193
+ (0, node_fs_1.rmSync)(countDir, { recursive: true, force: true });
132
194
  }
133
195
  return results;
134
196
  }
135
- /** Whether any script FAILED (a skip is not a failure). */
197
+ /**
198
+ * Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
199
+ * declined to run, the second ran and verified nothing, and neither is evidence
200
+ * that anything is broken. Both are visible in the summary instead.
201
+ */
136
202
  function anyFailed(results) {
137
203
  return results.some((r) => r.status === "fail");
138
204
  }
@@ -165,24 +231,39 @@ const MARK = {
165
231
  pass: "✓",
166
232
  skip: "⊘",
167
233
  fail: "✗",
234
+ vacuous: "∅",
168
235
  };
169
- /** One line per file + an explicit pass/skip/fail tally. Skips are SHOWN, never
170
- * folded into "passed" — a `⊘ SKIPPED` is loud, not a silent green. */
236
+ /** One line per file + an explicit pass/skip/vacuous/fail tally. Skips and
237
+ * vacuous runs are SHOWN, never folded into "passed" — a `⊘ SKIPPED` is loud,
238
+ * not a silent green, and so is a file that verified nothing. */
171
239
  function formatScriptSummary(results) {
172
240
  const lines = results.map((r) => {
173
241
  if (r.status === "skip")
174
242
  return ` ⊘ ${r.file} — SKIPPED`;
175
243
  if (r.status === "fail")
176
244
  return ` ✗ ${r.file} (exit ${String(r.code)})`;
245
+ if (r.status === "vacuous") {
246
+ return ` ${MARK.vacuous} ${r.file} — 0 CHECKS (it ran clean and verified nothing)`;
247
+ }
177
248
  return ` ${MARK.pass} ${r.file}`;
178
249
  });
179
250
  const n = (s) => results.filter((r) => r.status === s).length;
180
251
  const parts = [`${String(n("pass"))} passed`];
181
252
  if (n("skip") > 0)
182
253
  parts.push(`${String(n("skip"))} skipped`);
254
+ if (n("vacuous") > 0)
255
+ parts.push(`${String(n("vacuous"))} with 0 checks`);
183
256
  if (n("fail") > 0)
184
257
  parts.push(`${String(n("fail"))} failed`);
185
258
  lines.push(`\n${parts.join(", ")}.`);
259
+ // Name the remedy where it's read, once — the usual cause is a file that
260
+ // DEFINES tests and never calls them, and the usual second cause is a harness
261
+ // asserting some other way, which the runner cannot see.
262
+ if (n("vacuous") > 0) {
263
+ lines.push(` ∅ = the file loaded vigiles/testing and used none of it. Either nothing ran ` +
264
+ `(an exported test object nobody calls), or it asserts another way — in which ` +
265
+ `case call recordCheck() from vigiles/testing so those count.`);
266
+ }
186
267
  return lines.join("\n");
187
268
  }
188
269
  //# sourceMappingURL=run-scripts.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Env var naming the file a script writes its check count to. Set per-script by
3
+ * the runner (`runScripts`), read once here at import.
4
+ */
5
+ export declare const CHECK_COUNT_ENV = "VIGILES_CHECK_COUNT_FILE";
6
+ /**
7
+ * Record `n` checks against this script's run.
8
+ *
9
+ * The tiers call it themselves, so an ordinary harness never needs to. Call it
10
+ * directly when you assert some OTHER way — `node:assert`, vitest's `expect`, a
11
+ * hand-rolled comparison — and want those visible to `vigiles test` instead of
12
+ * leaving it to conclude the file did nothing.
13
+ */
14
+ export declare function recordCheck(n?: number): void;
15
+ /** How many checks this process has recorded so far. */
16
+ export declare function checksRecorded(): number;
17
+ /**
18
+ * Reset the counter AND the armed flag. For vigiles's own tests, which drive
19
+ * {@link armCheckReport} with fakes several times in one process; a harness
20
+ * script has no use for it.
21
+ */
22
+ export declare function resetCheckCount(): void;
23
+ /** Injection seam for {@link armCheckReport} — the process bits it needs. */
24
+ export interface CheckReportEnv {
25
+ readonly env: NodeJS.ProcessEnv;
26
+ readonly onExit: (fn: () => void) => void;
27
+ readonly write: (path: string, contents: string) => void;
28
+ }
29
+ /**
30
+ * Arm the exit-time report, returning whether it armed (i.e. whether the runner
31
+ * asked for a count). Pure except for the two effects it is handed.
32
+ *
33
+ * It DELETES the env var after reading it. A harness spawns child processes —
34
+ * that is its whole job — and a child inheriting the path would write ITS count
35
+ * over the parent's on exit, reporting a sub-process's activity as the file's.
36
+ * Reading the variable once and dropping it makes that unrepresentable rather
37
+ * than merely unlikely.
38
+ */
39
+ export declare function armCheckReport(deps: CheckReportEnv): boolean;
40
+ //# sourceMappingURL=check-count.d.ts.map
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHECK_COUNT_ENV = void 0;
4
+ exports.recordCheck = recordCheck;
5
+ exports.checksRecorded = checksRecorded;
6
+ exports.resetCheckCount = resetCheckCount;
7
+ exports.armCheckReport = armCheckReport;
8
+ /**
9
+ * vigiles — the CHECK COUNTER: the channel a `*.harness.*` / `*.eval.*` script
10
+ * uses to tell `vigiles test` / `vigiles eval` how much it actually did.
11
+ *
12
+ * 🔴 WHY THIS EXISTS. The runner knew exit codes and nothing else, so a script
13
+ * that ran NOTHING was indistinguishable from one that ran and passed. Measured
14
+ * 2026-08-08 — a file whose entire content is
15
+ *
16
+ * export default { "never runs": () => assert.equal(1, 2) };
17
+ *
18
+ * imports cleanly, exits 0, and the runner printed `✓ … 1 passed`. The assertion
19
+ * inside it is false; it is never called, because nothing calls an exported
20
+ * object. Not hypothetical: a consumer repo hit it, and now hand-copies a warning
21
+ * into the header of every new harness file — "An earlier version exported a
22
+ * `tests` object; nothing ran and the runner printed ✓" — because the runner
23
+ * could not enforce it. Eight harnesses resting on a comment.
24
+ *
25
+ * This is the same distinction `assertNoWrite` already draws between `undefined`
26
+ * and `[]`: "nobody looked" and "nothing happened" must not print the same.
27
+ *
28
+ * HOW IT REPORTS. `vigiles test` puts a scratch path in `VIGILES_CHECK_COUNT_FILE`
29
+ * before spawning each script; on exit, a script that loaded this module writes
30
+ * its count there. A FILE, not stdout, because the runner inherits stdio so the
31
+ * script's own report streams live — there is no stream left to parse. Not an
32
+ * exit code either: those already carry pass/skip/fail, and overloading one would
33
+ * make a real failure ambiguous.
34
+ *
35
+ * WHAT COUNTS AS A CHECK: an observation vigiles can see the script make — a run
36
+ * through one of the tiers (`runHook`, `runHarnessTest`, `runEval`, …), an
37
+ * in-process compiled-hook decision asserted, or an explicit {@link recordCheck}
38
+ * from a harness that asserts some other way (`node:assert`, a runner's
39
+ * `expect`). Deliberately generous: the question this answers is "did this file
40
+ * exercise the harness at all", and a false "0 checks" against a script that
41
+ * genuinely tested something would be exactly the crying wolf the rest of the
42
+ * tool avoids.
43
+ *
44
+ * WHAT A MISSING COUNT MEANS: nothing at all. A script that never imports
45
+ * `vigiles/testing` cannot report, so the runner sees no file and treats it
46
+ * exactly as before — a plain pass. Silence is the legacy branch, never a
47
+ * verdict; only a count of literally zero is a finding. (The alternative —
48
+ * force-loading this module into every child with `node --import` so silence
49
+ * became impossible — was rejected: it would report `0` for a hand-rolled
50
+ * harness that spawns and asserts entirely on its own, which is a real and
51
+ * blameless way to write one.)
52
+ */
53
+ const node_fs_1 = require("node:fs");
54
+ /**
55
+ * Env var naming the file a script writes its check count to. Set per-script by
56
+ * the runner (`runScripts`), read once here at import.
57
+ */
58
+ exports.CHECK_COUNT_ENV = "VIGILES_CHECK_COUNT_FILE";
59
+ /**
60
+ * The counter lives on the global registry, not in module scope, so two copies
61
+ * of vigiles loaded in one child (a global CLI plus a local dependency, say)
62
+ * share ONE count instead of one copy counting while the other reports zero.
63
+ * The cheap version of that bug is a false "this file verified nothing".
64
+ */
65
+ const STATE = Symbol.for("vigiles.check-count");
66
+ function state() {
67
+ const g = globalThis;
68
+ return (g[STATE] ??= { count: 0, armed: false });
69
+ }
70
+ /**
71
+ * Record `n` checks against this script's run.
72
+ *
73
+ * The tiers call it themselves, so an ordinary harness never needs to. Call it
74
+ * directly when you assert some OTHER way — `node:assert`, vitest's `expect`, a
75
+ * hand-rolled comparison — and want those visible to `vigiles test` instead of
76
+ * leaving it to conclude the file did nothing.
77
+ */
78
+ function recordCheck(n = 1) {
79
+ state().count += n;
80
+ }
81
+ /** How many checks this process has recorded so far. */
82
+ function checksRecorded() {
83
+ return state().count;
84
+ }
85
+ /**
86
+ * Reset the counter AND the armed flag. For vigiles's own tests, which drive
87
+ * {@link armCheckReport} with fakes several times in one process; a harness
88
+ * script has no use for it.
89
+ */
90
+ function resetCheckCount() {
91
+ const s = state();
92
+ s.count = 0;
93
+ s.armed = false;
94
+ }
95
+ /**
96
+ * Arm the exit-time report, returning whether it armed (i.e. whether the runner
97
+ * asked for a count). Pure except for the two effects it is handed.
98
+ *
99
+ * It DELETES the env var after reading it. A harness spawns child processes —
100
+ * that is its whole job — and a child inheriting the path would write ITS count
101
+ * over the parent's on exit, reporting a sub-process's activity as the file's.
102
+ * Reading the variable once and dropping it makes that unrepresentable rather
103
+ * than merely unlikely.
104
+ */
105
+ function armCheckReport(deps) {
106
+ const s = state();
107
+ if (s.armed)
108
+ return false;
109
+ const file = deps.env[exports.CHECK_COUNT_ENV];
110
+ if (file === undefined || file === "")
111
+ return false;
112
+ // `Reflect.deleteProperty`, not `delete env[KEY]`: the key is a const, which
113
+ // the lint rules count as a dynamic delete.
114
+ Reflect.deleteProperty(deps.env, exports.CHECK_COUNT_ENV);
115
+ s.armed = true;
116
+ deps.onExit(() => {
117
+ try {
118
+ deps.write(file, String(s.count));
119
+ }
120
+ catch {
121
+ // An unwritable scratch path must never turn a passing harness into a
122
+ // crash on the way out. No count reported = the legacy branch = a pass.
123
+ }
124
+ });
125
+ return true;
126
+ }
127
+ armCheckReport({
128
+ env: process.env,
129
+ onExit: (fn) => {
130
+ process.on("exit", fn);
131
+ },
132
+ write: (path, contents) => {
133
+ (0, node_fs_1.writeFileSync)(path, contents);
134
+ },
135
+ });
136
+ //# sourceMappingURL=check-count.js.map
@@ -86,16 +86,45 @@ export declare function bashGrantIsUnbounded(raw: string): boolean;
86
86
  * by {@link lethalTrifectaIssues}, which knows it grants every leg.
87
87
  */
88
88
  export declare function classifyTrifectaLegs(tools: readonly string[], dialect: HarnessDialect): TrifectaLegs;
89
+ /** Extra facts about HOW the contract was obtained, which change the verdict. */
90
+ export interface TrifectaContext {
91
+ /**
92
+ * The unit's frontmatter block EXISTS but is not valid YAML.
93
+ *
94
+ * 🔴 WHY THE DETECTOR NEEDS TO KNOW. vigiles's frontmatter reader is
95
+ * deliberately lenient: on a block js-yaml rejects it regex-SALVAGES the
96
+ * fields, so the live PreToolUse rail still has something to enforce. Right
97
+ * for a rail, wrong for a score. Measured 2026-08-08:
98
+ * `readFrontmatter(bad).malformed` is `true` — the tool KNOWS the block is
99
+ * broken — while `frontmatterList(…, "allowed-tools")` on it still returns
100
+ * `["Read","Bash"]`, the narrow contract its author MEANT. A unit whose
101
+ * contract a strict loader rejects was therefore graded as though it had
102
+ * declared exactly that list, and scored CLEAN. Presence of a declaration is
103
+ * not enforcement of it — the product's own thesis, turned on the product.
104
+ *
105
+ * With this set, `tools` is read as a SALVAGE, not a contract: it can only make
106
+ * the verdict worse, never better. A salvaged list that names all three legs
107
+ * still fires `"hard"` (both readings of the file agree the unit holds them);
108
+ * anything less falls back to what a strict loader actually yields — no
109
+ * contract at all, i.e. inherits-all, which is the `"advisory"` finding.
110
+ */
111
+ readonly contractUnreadable?: boolean;
112
+ }
89
113
  /**
90
114
  * Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
91
115
  * `null` (≤ 2 legs = safe by the Rule of Two).
92
116
  *
93
- * Two paths:
117
+ * Three paths:
94
118
  * - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
95
119
  * tool → trivially all three legs → an `"advisory"` finding (the inherits-all
96
120
  * stance: a footgun worth surfacing, not a declared exfil path).
97
121
  * - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
98
122
  * three legs is non-empty.
123
+ * - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
124
+ * a salvage of a block a strict loader rejects. They can only make the verdict
125
+ * WORSE — a salvaged all-three still fires `"hard"` — and anything short of
126
+ * that falls back to what a strict loader really yields: no contract, i.e.
127
+ * inherits-all, the `"advisory"` finding. Never the other way round.
99
128
  */
100
- export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect): TrifectaFinding | null;
129
+ export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect, ctx?: TrifectaContext): TrifectaFinding | null;
101
130
  //# sourceMappingURL=lethal-trifecta.d.ts.map
@@ -249,14 +249,19 @@ function classifyTrifectaLegs(tools, dialect) {
249
249
  * Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
250
250
  * `null` (≤ 2 legs = safe by the Rule of Two).
251
251
  *
252
- * Two paths:
252
+ * Three paths:
253
253
  * - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
254
254
  * tool → trivially all three legs → an `"advisory"` finding (the inherits-all
255
255
  * stance: a footgun worth surfacing, not a declared exfil path).
256
256
  * - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
257
257
  * three legs is non-empty.
258
+ * - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
259
+ * a salvage of a block a strict loader rejects. They can only make the verdict
260
+ * WORSE — a salvaged all-three still fires `"hard"` — and anything short of
261
+ * that falls back to what a strict loader really yields: no contract, i.e.
262
+ * inherits-all, the `"advisory"` finding. Never the other way round.
258
263
  */
259
- function lethalTrifectaIssues(tools, dialect) {
264
+ function lethalTrifectaIssues(tools, dialect, ctx = {}) {
260
265
  const hasWildcard = tools.some((t) => isWildcard(baseTool(t)));
261
266
  // Inherits-all is signalled by a WILDCARD (the caller passes `["*"]` for an
262
267
  // absent `tools:` line). An EXPLICIT empty `[]` is the opposite — zero tools,
@@ -271,10 +276,17 @@ function lethalTrifectaIssues(tools, dialect) {
271
276
  return {
272
277
  severity: "advisory",
273
278
  legs,
274
- message: "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
275
- "it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
276
- "exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
277
- "list dropping at least one leg (Meta's Rule of Two).",
279
+ message: ctx.contractUnreadable
280
+ ? "Frontmatter is not valid YAML, so the declared tool list could not be read — a " +
281
+ "strict loader rejects the block, and a regex salvage of it is a guess, not a " +
282
+ "contract. Scored as INHERITS-ALL (every capability), which is what a strict " +
283
+ "loader yields: it therefore holds all three lethal-trifecta legs (read private " +
284
+ "data, ingest untrusted content, exfiltrate). Fix the YAML and the declared list " +
285
+ "counts again — a declaration that does not parse is not an enforcement."
286
+ : "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
287
+ "it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
288
+ "exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
289
+ "list dropping at least one leg (Meta's Rule of Two).",
278
290
  };
279
291
  }
280
292
  const legs = classifyTrifectaLegs(tools, dialect);
@@ -288,9 +300,22 @@ function lethalTrifectaIssues(tools, dialect) {
288
300
  `(${legs.private.join(", ")}), ingest untrusted content ` +
289
301
  `(${legs.untrusted.join(", ")}), AND exfiltrate ` +
290
302
  `(${legs.exfil.join(", ")}) — a prompt-injection exfil path with no exploit code. ` +
291
- "Drop at least one leg (Meta's Rule of Two: allow at most two).",
303
+ "Drop at least one leg (Meta's Rule of Two: allow at most two)." +
304
+ (ctx.contractUnreadable
305
+ ? " (Those tool names were SALVAGED: the frontmatter is not valid YAML, so a strict" +
306
+ " loader reads no contract here at all and the real grant may be wider still." +
307
+ " Fix the YAML — this finding stands either way.)"
308
+ : ""),
292
309
  };
293
310
  }
311
+ // Fewer than three legs — but the names were salvaged from a block a strict
312
+ // loader rejects, so "fewer" is a guess. What that loader actually yields is
313
+ // NOTHING: no contract, which is inherits-all, which holds every leg. This is
314
+ // the branch the defect lived in — a malformed unit scored clean because the
315
+ // salvage happened to read narrow.
316
+ if (ctx.contractUnreadable) {
317
+ return lethalTrifectaIssues(["*"], dialect, ctx);
318
+ }
294
319
  return null;
295
320
  }
296
321
  //# sourceMappingURL=lethal-trifecta.js.map
@@ -33,8 +33,9 @@ exports.skillResourceIssues = skillResourceIssues;
33
33
  * `references/finance.md`", or a markdown-link example demonstrating how to
34
34
  * write a path — e.g. one with a space in the filename). A reference (link OR
35
35
  * inline path) is treated as real ONLY when the line DIRECTS the agent to use
36
- * the file (read/run/see/…) and carries no illustrative cue (example / e.g. /
37
- * such as / would be / template / →). See `inlinePathIsUsed`.
36
+ * the file (read/run/see/…, or the line is a HEADING naming it — see
37
+ * `MD_HEADING`) and carries no illustrative cue (example / e.g. / such as /
38
+ * would be / template / →). See `inlinePathIsUsed`.
38
39
  *
39
40
  * ESCAPE HATCH: a SKILL.md carrying `<!-- vigiles-disable skill-resource-resolves -->`
40
41
  * anywhere in its body opts OUT of this check entirely (mirrors `orphans.ts`'s
@@ -179,18 +180,49 @@ const USE_DIRECTIVE = /\b(run|runs|execute|executes|read|reads|load|loads|open|o
179
180
  // pointing at a shipped file. Includes the `→`/`->` arrow used in "move detail
180
181
  // → `references/x.md`" authoring lists.
181
182
  const ILLUSTRATIVE_CUE = /\b(example|examples|e\.g\.?|i\.e\.?|such as|for instance|would be|helpful|useful|template|boilerplate)\b|→|->/i;
183
+ /**
184
+ * An ATX markdown heading (`## …`, up to three leading spaces per CommonMark).
185
+ *
186
+ * 🔴 WHY A HEADING COUNTS AS A USE-DIRECTIVE. The verb gate below reads PROSE,
187
+ * and a heading is not prose — it is the section's label. So the single most
188
+ * common way a skill points at its own bundled script, naming it in the heading
189
+ * of the section about running it, carried no verb and went unchecked:
190
+ *
191
+ * ## 🏗 START WITH THE MECHANICAL LEG — `scripts/structure.mjs`
192
+ *
193
+ * Measured 2026-08-08 on a real skill whose `structure.mjs` sits at the skill
194
+ * ROOT, not under `scripts/`: that line yielded NOTHING. Rewriting the same
195
+ * heading to "Run the mechanical leg" — same file, same ref, same missing
196
+ * target — correctly yielded the finding. The tool's answer depended on the
197
+ * author's choice of verb, and it stayed silent for three days.
198
+ *
199
+ * The narrow fix, and deliberately not the wide one. The alternative — treat
200
+ * ANY bundle-dir path with an extension as a reference, verb or not — reopens
201
+ * exactly the false positives the gate exists for (a skill TEACHING how to
202
+ * build skills mentions `scripts/rotate.py` constantly as prose). A heading
203
+ * naming a bundle path is a structural claim about what this section is about,
204
+ * not a sentence illustrating what a skill *could* ship, so it earns the same
205
+ * standing as an explicit "run …". The illustrative-cue veto still applies —
206
+ * `## Examples: \`references/finance.md\`` stays skipped — and the check is
207
+ * `warn` severity with a `vigiles-disable` escape hatch, so a slightly wider
208
+ * net is affordable where a blanket one is not.
209
+ */
210
+ const MD_HEADING = /^\s{0,3}#{1,6}\s/;
182
211
  /**
183
212
  * Whether a bundle-path reference on this line reads as a REAL reference (the
184
213
  * agent is told to use the file) rather than an illustrative mention. Requires
185
- * a positive use-directive and the absence of an illustrative cue both
186
- * evaluated over the whole line for simplicity (a tight, precise rule over a
187
- * clever one). BOTH candidate shapes consult this (issue #110) a markdown
188
- * link's `[text](target)` syntax is a stronger signal than a bare inline span,
189
- * but "For example, see [the schema](...)" is still an illustrative mention,
190
- * not a real dead ref.
214
+ * a positive directive a use verb, or the line being a heading (see
215
+ * {@link MD_HEADING}) — and the absence of an illustrative cue, both evaluated
216
+ * over the whole line for simplicity (a tight, precise rule over a clever one).
217
+ * BOTH candidate shapes consult this (issue #110) a markdown link's
218
+ * `[text](target)` syntax is a stronger signal than a bare inline span, but
219
+ * "For example, see [the schema](...)" is still an illustrative mention, not a
220
+ * real dead ref.
191
221
  */
192
222
  function inlinePathIsUsed(line) {
193
- return USE_DIRECTIVE.test(line) && !ILLUSTRATIVE_CUE.test(line);
223
+ if (ILLUSTRATIVE_CUE.test(line))
224
+ return false;
225
+ return USE_DIRECTIVE.test(line) || MD_HEADING.test(line);
194
226
  }
195
227
  /** Collect candidate bundled-resource refs from one body line, skipping fences. */
196
228
  function candidatesInLine(line, lineNo) {
package/dist/eval.js CHANGED
@@ -75,6 +75,7 @@ const harness_test_js_1 = require("./harness-test.js");
75
75
  const eval_cache_js_1 = require("./eval-cache.js");
76
76
  const eval_lock_js_1 = require("./eval-lock.js");
77
77
  const stats_js_1 = require("./stats.js");
78
+ const check_count_js_1 = require("./check-count.js");
78
79
  const tool_intercept_js_1 = require("./tool-intercept.js");
79
80
  const tool_stub_js_1 = require("./tool-stub.js");
80
81
  function writeFiles(cwd, files) {
@@ -1165,6 +1166,9 @@ function evalArmsInputs(spec, cfg) {
1165
1166
  };
1166
1167
  }
1167
1168
  async function runEvalWith(spec, runner) {
1169
+ // Tell the CLI runner this script exercised the harness, so a file that runs
1170
+ // NOTHING can be told apart from one that ran and passed. See check-count.ts.
1171
+ (0, check_count_js_1.recordCheck)();
1168
1172
  const trials = spec.trials ?? 5;
1169
1173
  const spacing = (spec.spacingSec ?? 4) * 1000;
1170
1174
  const concurrency = spec.concurrency ?? 1;
@@ -1637,6 +1641,8 @@ function assertTriggerDiversity(spec) {
1637
1641
  }
1638
1642
  }
1639
1643
  async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError, harness = "claude-code") {
1644
+ // Tell the CLI runner this script exercised the harness (see check-count.ts).
1645
+ (0, check_count_js_1.recordCheck)();
1640
1646
  // Deterministic gate FIRST — before spending a token (or packaging a skillsDir).
1641
1647
  assertTriggerDiversity(spec);
1642
1648
  // Model floor (default Sonnet): trigger-rate under-measures selection on a
@@ -64,6 +64,7 @@ exports.assertTriggerRate = assertTriggerRate;
64
64
  const harness_test_js_1 = require("./harness-test.js");
65
65
  const hook_program_js_1 = require("./core/hook-program.js");
66
66
  const check_js_1 = require("./check.js");
67
+ const check_count_js_1 = require("./check-count.js");
67
68
  const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
68
69
  const stats_js_1 = require("./stats.js");
69
70
  const eval_baseline_js_1 = require("./eval-baseline.js");
@@ -142,6 +143,15 @@ function assertHookAllowed(r) {
142
143
  fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
143
144
  }
144
145
  }
146
+ /**
147
+ * `runHookProgram`, counted. An in-process hook decision is an observation the
148
+ * CLI runner can see — without it, a `*.harness.*` file that only tests compiled
149
+ * hooks would look like it did nothing at all. See check-count.ts.
150
+ */
151
+ function runCountedHookProgram(hook, event) {
152
+ (0, check_count_js_1.recordCheck)();
153
+ return (0, hook_program_js_1.runHookProgram)(hook, event);
154
+ }
145
155
  /** Render a {@link HookProgramOutcome} for an assertion message. */
146
156
  function describeOutcome(o) {
147
157
  if (o.kind === "decision")
@@ -157,14 +167,14 @@ function describeOutcome(o) {
157
167
  * check, use {@link assertHookBlocked} over `runHook`.)
158
168
  */
159
169
  function assertHookDenies(hook, event) {
160
- const o = (0, hook_program_js_1.runHookProgram)(hook, event);
170
+ const o = runCountedHookProgram(hook, event);
161
171
  if (o.kind !== "decision" || o.decision.kind !== "deny") {
162
172
  fail(`expected the hook to deny, got ${describeOutcome(o)}`);
163
173
  }
164
174
  }
165
175
  /** Assert a COMPILED hook allows an event (in-process). The twin of {@link assertHookDenies}. */
166
176
  function assertHookAllows(hook, event) {
167
- const o = (0, hook_program_js_1.runHookProgram)(hook, event);
177
+ const o = runCountedHookProgram(hook, event);
168
178
  if (o.kind !== "decision" || o.decision.kind !== "allow") {
169
179
  fail(`expected the hook to allow, got ${describeOutcome(o)}`);
170
180
  }
@@ -181,7 +191,7 @@ function assertHookAllows(hook, event) {
181
191
  * stdout-vs-stderr never enters into it.
182
192
  */
183
193
  function assertHookNotices(hook, event, matcher) {
184
- const o = (0, hook_program_js_1.runHookProgram)(hook, event);
194
+ const o = runCountedHookProgram(hook, event);
185
195
  if (o.kind !== "reaction" || o.reaction.kind !== "notice") {
186
196
  fail(`expected the hook to notice, got ${describeOutcome(o)}`);
187
197
  }
@@ -204,7 +214,7 @@ function assertHookNotices(hook, event, matcher) {
204
214
  * A `run(…)` reaction is not silent for this purpose: the hook still reacted.
205
215
  */
206
216
  function assertHookSilent(hook, event) {
207
- const o = (0, hook_program_js_1.runHookProgram)(hook, event);
217
+ const o = runCountedHookProgram(hook, event);
208
218
  if (o.kind !== "reaction") {
209
219
  fail(`expected a react hook, got ${describeOutcome(o)}`);
210
220
  }
@@ -46,6 +46,7 @@ const node_fs_1 = require("node:fs");
46
46
  const node_os_1 = require("node:os");
47
47
  const node_path_1 = require("node:path");
48
48
  const adapter_conformance_js_1 = require("./adapter-conformance.js");
49
+ const check_count_js_1 = require("./check-count.js");
49
50
  const runtime_js_1 = require("./adapters/claude-code/runtime.js");
50
51
  const mock_model_js_1 = require("./mock-model.js");
51
52
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
@@ -410,6 +411,9 @@ function makeResult(cwd, out, parsed, turns, modelRequests) {
410
411
  * only — requesting confinement for another harness throws.
411
412
  */
412
413
  async function runHarnessTest(spec, opts = {}) {
414
+ // Tell the CLI runner this script exercised the harness, so a file that runs
415
+ // NOTHING can be told apart from one that ran and passed. See check-count.ts.
416
+ (0, check_count_js_1.recordCheck)();
413
417
  const adapter = opts.adapter;
414
418
  // Default (no adapter): the unchanged Claude Code driver — keeps the
415
419
  // sandbox/confined path and behaviour byte-for-byte identical.
@@ -35,6 +35,7 @@ const node_os_1 = require("node:os");
35
35
  const node_path_1 = require("node:path");
36
36
  const egress_js_1 = require("./egress.js");
37
37
  const sandbox_js_1 = require("./sandbox.js");
38
+ const check_count_js_1 = require("./check-count.js");
38
39
  /**
39
40
  * The run orchestration with injectable spawn seams: pick direct vs. confined
40
41
  * via the safe-by-default policy (`decideSandbox`), then assemble the result.
@@ -42,6 +43,11 @@ const sandbox_js_1 = require("./sandbox.js");
42
43
  * with fake spawners — no real bwrap.
43
44
  */
44
45
  function runScriptWith(command, stdin, opts, deps) {
46
+ // Tell the CLI runner this script exercised the harness, so a `*.harness.*`
47
+ // file that runs NOTHING can be told apart from one that ran and passed. Here,
48
+ // at the primitive, so `runHook` and a bare `runScript` both count. See
49
+ // check-count.ts.
50
+ (0, check_count_js_1.recordCheck)();
45
51
  // Allowlisted egress is its own confined path (bwrap netns + slirp4netns +
46
52
  // nft); it can't run unconfined, so it refuses outright when the tooling is
47
53
  // absent rather than falling back to a direct run that ignores the allowlist.
package/dist/scan-core.js CHANGED
@@ -69,6 +69,44 @@ function frontmatter(md) {
69
69
  color: (0, frontmatter_read_js_1.frontmatterScalar)(fm, "color"),
70
70
  };
71
71
  }
72
+ /**
73
+ * Whether this unit's declared TOOL CONTRACT is unreadable — the frontmatter
74
+ * block exists but is not valid YAML.
75
+ *
76
+ * 🔴 WHY SCORING MUST NOT USE THE SALVAGE. The shared reader is deliberately
77
+ * lenient: on a block js-yaml rejects it falls back to a regex salvage, so the
78
+ * live PreToolUse rail still has *something* to enforce and the other fields
79
+ * keep working. That is right for a rail and wrong for a SCORE. Measured
80
+ * 2026-08-08: `readFrontmatter(bad)` returns `{data: null, malformed: true}` —
81
+ * the tool KNOWS the block is broken — while `frontmatterList(…, "allowed-tools")`
82
+ * on the same block returns `["Read","Bash"]`, the narrow contract the author
83
+ * MEANT. Strict js-yaml on it throws `bad indentation of a mapping entry`. So a
84
+ * unit whose contract a strict loader rejects was graded as though it had
85
+ * declared exactly that narrow contract: the Safety ring read BETTER than the
86
+ * truth, on the optimistic branch, in the tool whose own thesis is that the
87
+ * presence of a declaration is not the enforcement of it.
88
+ *
89
+ * The trifecta detector is therefore told the list is a SALVAGE, and reads it as
90
+ * one: it can only make the verdict worse (a salvaged all-three still convicts),
91
+ * and anything short of that falls back to what a strict loader really yields —
92
+ * no contract, i.e. inherits-all. The finding says which happened, so the author
93
+ * can tell a dropped grade from a real capability. Strictly one-directional, the
94
+ * same shape as the inherits-all monotonicity fix (#119). `frontmatter-valid`
95
+ * reports the broken block itself; this is the half that stops the SCORE
96
+ * disagreeing with it.
97
+ *
98
+ * DELIBERATELY NOT WIDER. The typo / never-available / MCP-server /
99
+ * disallowed-tools cross-references keep using the salvage: they are diagnostics,
100
+ * and suppressing them on a malformed file DELETES findings, which moves the
101
+ * grade the optimistic way — the direction this whole fix exists to close. A real
102
+ * vendored plugin in `test/dogfood` proves the point: `madappgang-frontend`'s
103
+ * `tester.md` has both a malformed description and an explicit all-three-legs
104
+ * tool list, and its `AskUserQuestion` never-available finding is true whether or
105
+ * not the block parses.
106
+ */
107
+ function contractIsUnreadable(md) {
108
+ return (0, frontmatter_read_js_1.readFrontmatter)(md).malformed;
109
+ }
72
110
  function escapeRe(s) {
73
111
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
112
  }
@@ -257,11 +295,17 @@ function scanSkills(files, cls, ctx) {
257
295
  // invocable skill can be hijacked by attacker content, so a user-invoked one is
258
296
  // excluded. A skill with no `allowed-tools` line inherits all → advisory.
259
297
  const skillTools = (0, agent_tools_js_1.parseAgentToolList)(md, "allowed-tools");
298
+ // Whether that list came out of a block a strict loader REJECTS — in which
299
+ // case it is a salvage, and the trifecta detector must read it as one. See
300
+ // `contractIsUnreadable`.
301
+ const contractUnreadable = contractIsUnreadable(md);
260
302
  // No `allowed-tools:` line (null) → inherits all → wildcard sentinel; an
261
303
  // EXPLICIT empty `[]` means zero tools → no trifecta (don't collapse them).
262
304
  const trifecta = userInvoked
263
305
  ? null
264
- : (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect);
306
+ : (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect, {
307
+ contractUnreadable,
308
+ });
265
309
  out.push({
266
310
  name: fm.name ?? skillName(path),
267
311
  // Report the real on-disk path, not the synthetic materialize key (E1).
@@ -323,6 +367,14 @@ ctx) {
323
367
  if (!cls.isAgent(path))
324
368
  continue;
325
369
  const tools = (0, agent_tools_js_1.parseAgentTools)(md);
370
+ // Whether that list came out of a block a strict loader REJECTS — see
371
+ // `contractIsUnreadable`. Scoped to the trifecta (the Safety ring) on
372
+ // purpose: the typo / MCP / disallowed-tools cross-references below are
373
+ // DIAGNOSTICS, and dropping them on a malformed file would delete real
374
+ // findings — moving the grade in the optimistic direction this fix exists to
375
+ // stop. A salvage is too weak to earn a unit a clean bill of health; it is
376
+ // plenty strong enough to convict.
377
+ const contractUnreadable = contractIsUnreadable(md);
326
378
  // An inherits-all agent (no `tools:` line) grants access to every tool
327
379
  // including every side-effecting one — pass the wildcard sentinel so
328
380
  // effectSurface correctly classifies it as `"unrestricted"`.
@@ -359,7 +411,9 @@ ctx) {
359
411
  // inherits-all agent (no `tools:` line → tools === null) is the advisory
360
412
  // case — pass the wildcard sentinel so it's distinguished from an EXPLICIT
361
413
  // empty `tools: []` (zero tools → no trifecta). One detector, no drift.
362
- trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect),
414
+ trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect, {
415
+ contractUnreadable,
416
+ }),
363
417
  });
364
418
  }
365
419
  return out.sort((a, b) => a.name.localeCompare(b.name));
package/dist/testing.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * boundary forbids importing `src/adapters/*` from here. See
11
11
  * `research/adapter-api-design.md`.
12
12
  */
13
+ export { recordCheck } from "./check-count.js";
13
14
  export { runScript } from "./run-script.js";
14
15
  export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
15
16
  export { runHook, propertyHook } from "./run-hook.js";
package/dist/testing.js CHANGED
@@ -30,7 +30,15 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
30
30
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
31
31
  };
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.propertyHook = exports.runHook = exports.runScript = void 0;
33
+ exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.propertyHook = exports.runHook = exports.runScript = exports.recordCheck = void 0;
34
+ // --- reporting: how much did this script actually do? ---
35
+ // `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
36
+ // prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
37
+ // exporting an object of tests nobody calls). The tiers below count themselves;
38
+ // call `recordCheck()` yourself when you assert some OTHER way — `node:assert`,
39
+ // vitest's `expect` — so those are visible to the runner too. See check-count.ts.
40
+ var check_count_js_1 = require("./check-count.js");
41
+ Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
34
42
  // --- unit tier: runScript (the primitive) + runHook (it, plus a decision) ---
35
43
  // `runScript` runs any program and reports what it DID (exit, both streams,
36
44
  // writes, egress). `runHook` is that plus the hook protocol: event to stdin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.0.0",
3
+ "version": "15.0.1",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",