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
package/dist/cli.js CHANGED
@@ -66,7 +66,11 @@ const merge_conflict_js_1 = require("./core/merge-conflict.js");
66
66
  const hook_install_js_1 = require("./hook-install.js");
67
67
  const hook_providers_js_1 = require("./core/hook-providers.js");
68
68
  const toml_1 = require("@iarna/toml");
69
- const hash_js_1 = require("./core/hash.js");
69
+ // The named-state STORE. Lifted out of this file so a TEST can seed a fact
70
+ // through the SAME writer the runtime uses (`experimental_hookState`) — the
71
+ // private path a stateful hook's test used to hard-code is what kept the hook
72
+ // vocabulary experimental. Same move as `loadHook`, for the same reason.
73
+ const hook_state_store_js_1 = require("./hook-state-store.js");
70
74
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
71
75
  const observe_js_1 = require("./observe.js");
72
76
  const effect_region_js_1 = require("./adapters/claude-code/effect-region.js");
@@ -137,10 +141,21 @@ let host = null;
137
141
  function hostEntry() {
138
142
  return (0, node_path_1.resolve)(__dirname, "spec-host.mjs");
139
143
  }
144
+ /**
145
+ * This package's own root — `dist/`'s parent, since this file compiles to
146
+ * `dist/cli.js`. Handed to the host so its resolve hook can serve the spec's
147
+ * `vigiles/spec` import from OUR install (see `src/self-resolve.mts`), which is
148
+ * what lets a repo with no `node_modules/vigiles` — a Python or Rust repo has
149
+ * no `package.json` to install into at all — compile a spec.
150
+ */
151
+ function selfRoot() {
152
+ return (0, node_path_1.resolve)(__dirname, "..");
153
+ }
140
154
  function startHost() {
141
155
  const child = (0, node_child_process_1.spawn)(process.execPath, [hostEntry()], {
142
156
  cwd: process.cwd(),
143
157
  stdio: ["pipe", "pipe", "pipe"],
158
+ env: { ...process.env, VIGILES_SELF_ROOT: selfRoot() },
144
159
  });
145
160
  const h = { child, pending: new Map(), started: null, buffered: "" };
146
161
  child.stdout.setEncoding("utf-8");
@@ -2973,56 +2988,24 @@ async function setupPillar1(detected, targetValue, harnesses) {
2973
2988
  // user reviews the generated spec and runs `vigiles compile` themselves to
2974
2989
  // switch it to spec-managed (non-destructive by default; the compile is
2975
2990
  // byte-faithful, but it's the user's call to make, with a diff to review).
2976
- // And we only compile when `vigiles` actually resolves — a fresh repo hasn't
2977
- // run `npm install` yet, so compiling would just error; defer it with a clear
2978
- // next step instead of a scary stack-traceless "failed to load".
2979
- const canCompile = canResolveVigiles(cwd);
2991
+ //
2992
+ // There is no longer an "only if vigiles resolves" gate here. The spec host
2993
+ // serves a spec's `vigiles/spec` import from the CLI's OWN install (the
2994
+ // resolve hook in src/self-resolve.mts), so compiling needs no
2995
+ // `node_modules/vigiles` in the user's repo — and a repo with no
2996
+ // `package.json` at all (Python, Rust, Go) has no install to run.
2980
2997
  const initConfig = (0, validate_js_1.loadConfig)();
2981
2998
  const excludes = (0, exclude_js_1.excludeSet)(cwd, initConfig.exclude);
2982
- const specs = canCompile
2983
- ? findSpecs(excludes).filter((s) => {
2984
- const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
2985
- return !(0, node_fs_1.existsSync)(tf) || targetHasHash(tf);
2986
- })
2987
- : [];
2999
+ const specs = findSpecs(excludes).filter((s) => {
3000
+ const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
3001
+ return !(0, node_fs_1.existsSync)(tf) || targetHasHash(tf);
3002
+ });
2988
3003
  if (specs.length > 0) {
2989
3004
  console.log("\nCompiling specs...");
2990
3005
  await compile(specs, initConfig, excludes);
2991
3006
  }
2992
- else if (!canCompile) {
2993
- // Honest, project-type-aware guidance. A JS repo just needs `npm install`
2994
- // (init already added the devDep). A repo with NO package.json (Python, Rust,
2995
- // …) can't resolve the npm package at all, so point at the no-install paths
2996
- // instead of a misleading `npm install`.
2997
- if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, "package.json"))) {
2998
- console.log("\n Skipping compile — run `npm install` (to fetch the vigiles dep just added), then `npx vigiles compile`.");
2999
- }
3000
- else {
3001
- console.log("\n No package.json here, so the typed-spec compile isn't available yet.\n" +
3002
- " • `npx vigiles lint` verifies your instruction files right now — no install needed.\n" +
3003
- " • To spec-manage them, add a package.json first: `npm init -y && npm i -D vigiles`, then `npx vigiles compile`.");
3004
- }
3005
- }
3006
3007
  return { specTargets: targets, written, adopted };
3007
3008
  }
3008
- /**
3009
- * Whether `vigiles/spec` will resolve for a spec compiled from `cwd` — true when
3010
- * vigiles is installed locally (`node_modules/vigiles`) or `cwd` IS the vigiles
3011
- * package itself (the in-repo dogfood / a monorepo workspace). A fresh user repo
3012
- * that hasn't run `npm install` yet returns false, so `init` defers the compile
3013
- * instead of emitting a resolution error.
3014
- */
3015
- function canResolveVigiles(cwd) {
3016
- if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, "node_modules", "vigiles")))
3017
- return true;
3018
- try {
3019
- const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, "package.json"), "utf-8"));
3020
- return pkg.name === "vigiles";
3021
- }
3022
- catch {
3023
- return false;
3024
- }
3025
- }
3026
3009
  /** Whether a harness binary (`claude`, `codex`) is on PATH. */
3027
3010
  function harnessBinaryPresent(bin) {
3028
3011
  try {
@@ -3278,22 +3261,16 @@ function printDetection(detected, harnesses) {
3278
3261
  function printSetupSummary(opts) {
3279
3262
  const { plan, strict, targets, adopted, written } = opts;
3280
3263
  const specPathsList = targets.map((t) => `${t}.spec.ts`);
3281
- // A repo with no package.json (Python/Rust/…) can't resolve the npm package,
3282
- // so the typed-spec compile path needs an install first — give honest steps.
3283
- const hasPkg = (0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), "package.json"));
3284
3264
  console.log("\n---\nSetup complete.\n");
3285
- // Next steps in DEPENDENCY order: install the dep first, then compile (which
3286
- // needs it), then the optional hardening / test / CI steps.
3265
+ // Next steps in the order a reader should do them. `npm install` is NOT a
3266
+ // prerequisite of `compile` any more (the spec host resolves `vigiles/spec`
3267
+ // from the CLI's own install) — it is listed because we just declared the
3268
+ // devDep, so installing it is what gives the editor the spec's types.
3287
3269
  const nextSteps = [];
3288
3270
  if (written.includes("package.json")) {
3289
3271
  nextSteps.push("Run `npm install` to fetch the vigiles dev dependency");
3290
3272
  }
3291
- if (adopted.length > 0 && !hasPkg) {
3292
- // Non-JS repo: compile needs a local install. Point at the no-install verify
3293
- // path + how to enable specs, instead of a compile that would fail.
3294
- nextSteps.push(`Verify now with \`npx vigiles lint\` (no install). To spec-manage ${adopted.join(", ")}, add a package.json first (\`npm init -y && npm i -D vigiles\`), then \`npx vigiles compile\` and review the diff`);
3295
- }
3296
- else if (adopted.length > 0) {
3273
+ if (adopted.length > 0) {
3297
3274
  // Adoption is NON-DESTRUCTIVE: the file is untouched until you compile, so
3298
3275
  // the diff to review is what compile WOULD produce (byte-faithful).
3299
3276
  nextSteps.push(`Run \`npx vigiles compile\` to put ${adopted.join(", ")} under spec management — it reproduces the file + adds an integrity header, so review the diff (\`vigiles eject\` reverses it)`);
@@ -5726,64 +5703,6 @@ async function compileProviders() {
5726
5703
  function hookStampPath(file) {
5727
5704
  return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
5728
5705
  }
5729
- /**
5730
- * The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
5731
- *
5732
- * Derived from the hook's own location and never from anything the hook said, so
5733
- * a key cannot address another owner's store: hooks shipped in the same directory
5734
- * share their facts (the requirement — one hook records, another reads), a
5735
- * vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
5736
- * rather than slugging it, which keeps it injective and lets a human debugging a
5737
- * hook find the fact by walking the path they already know:
5738
- *
5739
- * .claude/hooks/calendar-sync-record.hook.ts
5740
- * → .vigiles/state/.claude/hooks/calendar.synced.json
5741
- *
5742
- * A hook outside the project (an absolute path elsewhere) falls back to a hash of
5743
- * its directory: still stable and still isolated, just not readable — which is the
5744
- * right trade for a case that should not happen in a project's own harness.
5745
- */
5746
- function hookStateDir(file) {
5747
- const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), file));
5748
- const rel = (0, node_path_1.relative)(process.cwd(), dir);
5749
- const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
5750
- return (0, node_path_1.resolve)(process.cwd(), ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
5751
- }
5752
- /** Read one recorded fact for a hook, or `null` if it was never recorded. */
5753
- function readHookState(file, key) {
5754
- try {
5755
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(hookStateDir(file), key + ".json"), "utf-8");
5756
- const parsed = JSON.parse(raw);
5757
- return typeof parsed.value === "string" && typeof parsed.at === "string"
5758
- ? parsed
5759
- : null;
5760
- }
5761
- catch {
5762
- // Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
5763
- // turns into an infinite age, so the reading hook SPEAKS. Failing toward
5764
- // noise is the whole point; a store problem must never look like freshness.
5765
- return null;
5766
- }
5767
- }
5768
- /**
5769
- * Record one fact. Atomic: written to a temp file in the same directory and
5770
- * `rename()`d over, so a concurrent reader sees the whole old entry or the whole
5771
- * new one — never one write's value with another's timestamp. Distinct keys are
5772
- * distinct files and never interact at all.
5773
- */
5774
- function writeHookState(file, w) {
5775
- const dir = hookStateDir(file);
5776
- const target = (0, node_path_1.resolve)(dir, w.name + ".json");
5777
- const entry = {
5778
- value: w.value,
5779
- at: new Date().toISOString(),
5780
- by: (0, hook_install_js_1.normalizeHookRef)(file),
5781
- };
5782
- (0, node_fs_1.mkdirSync)(dir, { recursive: true });
5783
- const tmp = `${target}.${String(process.pid)}.tmp`;
5784
- (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
5785
- (0, node_fs_1.renameSync)(tmp, target);
5786
- }
5787
5706
  /**
5788
5707
  * Perform the state writes a hook declared, after its output has been emitted.
5789
5708
  * A refused write (a hand-built record object with a key `record()` would have
@@ -5797,7 +5716,7 @@ function applyHookWrites(file, outcome) {
5797
5716
  }
5798
5717
  for (const w of ok) {
5799
5718
  try {
5800
- writeHookState(file, w);
5719
+ (0, hook_state_store_js_1.writeHookState)(file, w);
5801
5720
  }
5802
5721
  catch (e) {
5803
5722
  console.error(`vigiles: could not record ${w.name} from ${file}: ${String(e)}`);
@@ -5856,7 +5775,19 @@ async function installHookFile(file, adapter, registeredProviders = []) {
5856
5775
  const injectable = adapter.hookProtocol?.injectableEvents ?? [];
5857
5776
  const matcher = (0, hook_program_js_1.hookRouting)(program).matcher;
5858
5777
  let warning;
5859
- if (adapter.name !== "claude-code") {
5778
+ // A react on an event this harness does NOT inject can still call `notice()`,
5779
+ // and that text would reach NOBODY (stderr at exit 0 is the debug log only).
5780
+ // Say so at INSTALL time: a runtime warning would go to the same stderr the
5781
+ // notice is stuck in, which is the defect one level up.
5782
+ if (role === "react" && !injectable.includes(event)) {
5783
+ warning =
5784
+ `a react on "${event}" may emit notice(...), but ${adapter.name} does not ` +
5785
+ `honor additionalContext for that event — the text would reach NOBODY ` +
5786
+ `(stderr at exit 0 goes to the debug log, not the transcript, and the model ` +
5787
+ `never sees it). Injectable here: ${injectable.join(", ") || "(none)"}. ` +
5788
+ `Move the hook to one of those events, or use run() if you meant an action.`;
5789
+ }
5790
+ else if (adapter.name !== "claude-code") {
5860
5791
  if (role === "inject" && !injectable.includes(event)) {
5861
5792
  warning =
5862
5793
  `this inject hook targets "${event}", which ${adapter.name} does not ` +
@@ -5975,7 +5906,7 @@ async function gatherHookContext(program, file) {
5975
5906
  isCI,
5976
5907
  // The namespace is bound HERE, from the hook's own path — core never sees
5977
5908
  // it, so no key a hook can spell reaches another owner's store.
5978
- readState: (key) => readHookState(file, key),
5909
+ readState: (key) => (0, hook_state_store_js_1.readHookState)(file, key),
5979
5910
  now: Date.now(),
5980
5911
  }, registry);
5981
5912
  }
@@ -6311,6 +6242,27 @@ async function runHookProgramCommand(file) {
6311
6242
  const ctx = await gatherHookContext(program, file);
6312
6243
  warnIfPathUndecidable(event, projectRoot);
6313
6244
  const reaction = (0, hook_program_js_1.runReact)(program, event, ctx, projectRoot);
6245
+ // A notice has to REACH someone. stderr at exit 0 goes to the debug log
6246
+ // and nothing else (the host's docs are explicit: "Claude never sees it"),
6247
+ // and a react always exits 0 because its type has no `deny` — so stderr
6248
+ // alone delivered nowhere. Emit the same `additionalContext` shape the
6249
+ // shipped refs/eval-lock nudges use, gated on the ACTIVE adapter's
6250
+ // `injectableEvents` so this is per-harness fact, not a CC literal.
6251
+ const injectable = (0, adapter_registry_js_1.resolveAdapter)(projectRoot ?? process.cwd()).hookProtocol
6252
+ ?.injectableEvents ?? [];
6253
+ const delivery = (0, hook_program_js_1.noticeDelivery)(reaction, program.on, injectable);
6254
+ if (delivery.kind === "inject") {
6255
+ process.stdout.write(JSON.stringify({
6256
+ hookSpecificOutput: {
6257
+ hookEventName: program.on,
6258
+ additionalContext: delivery.context,
6259
+ },
6260
+ }) + "\n");
6261
+ }
6262
+ // The stderr copy STAYS, deliberately. It is what the debug log and every
6263
+ // `runHook`-based probe already read, it costs nothing, and on an event
6264
+ // this harness does not inject it is the only trace that exists at all.
6265
+ // Removing it would break existing consumers to gain nothing.
6314
6266
  if (reaction.kind === "notice")
6315
6267
  console.error(reaction.message);
6316
6268
  applyHookWrites(file, { kind: "reaction", reaction });
@@ -144,6 +144,28 @@ export declare const SHORT_TO_LONG: Readonly<Record<string, string>>;
144
144
  export declare const LONG_TO_SHORT: Readonly<Record<string, string>>;
145
145
  /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
146
146
  export declare const WRAPPER_HEADS: Set<string>;
147
+ /**
148
+ * Resolve a wrapped command through one or more wrapper layers. Given a leaf's
149
+ * full normalized argv (`[head, ...args]`), returns the effective argv of the
150
+ * REAL command plus any `NAME=value` words an `env` wrapper consumed (so the
151
+ * caller can fold them into the leaf's assignment map). If the head is not a
152
+ * wrapper, or the wrapper has no following command, the argv is returned
153
+ * unchanged with an empty assignment set.
154
+ *
155
+ * @internal — the SINGLE answer to "which word is the real command head". Read
156
+ * by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
157
+ * `core/command-files.ts` (to find the interpreter behind a wrapper, so
158
+ * `env python3 guard` names its script). That second reader had its own idea of
159
+ * what a wrapper was — namely none — so it reported no script at all for the
160
+ * wrapped form, and a missing script that is never named is a hook that runs,
161
+ * exits 2, and is scored as a block. One list, or the next wrapper is missed in
162
+ * whichever copy nobody remembered.
163
+ */
164
+ export declare function stripWrappers(argv: readonly string[]): {
165
+ argv: readonly string[];
166
+ envAssigns: Map<string, string | null>;
167
+ chdir: string | null;
168
+ };
147
169
  /**
148
170
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
149
171
  * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
@@ -28,6 +28,7 @@ exports.WRAPPER_HEADS = exports.LONG_TO_SHORT = exports.SHORT_TO_LONG = void 0;
28
28
  exports.classifyBashCommand = classifyBashCommand;
29
29
  exports.isReadOnlyBash = isReadOnlyBash;
30
30
  exports.leafCommands = leafCommands;
31
+ exports.stripWrappers = stripWrappers;
31
32
  exports.leafCommandsNormalized = leafCommandsNormalized;
32
33
  exports.leafArgvSource = leafArgvSource;
33
34
  exports.commandWords = commandWords;
@@ -698,6 +699,15 @@ function splitAssignmentWord(word) {
698
699
  * caller can fold them into the leaf's assignment map). If the head is not a
699
700
  * wrapper, or the wrapper has no following command, the argv is returned
700
701
  * unchanged with an empty assignment set.
702
+ *
703
+ * @internal — the SINGLE answer to "which word is the real command head". Read
704
+ * by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by
705
+ * `core/command-files.ts` (to find the interpreter behind a wrapper, so
706
+ * `env python3 guard` names its script). That second reader had its own idea of
707
+ * what a wrapper was — namely none — so it reported no script at all for the
708
+ * wrapped form, and a missing script that is never named is a hook that runs,
709
+ * exits 2, and is scored as a block. One list, or the next wrapper is missed in
710
+ * whichever copy nobody remembered.
701
711
  */
702
712
  function stripWrappers(argv) {
703
713
  const envAssigns = new Map();
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Which FILES a shell command hands to a program to execute — the parser-backed
3
+ * answer to "is the guard this hook names even on disk?".
4
+ *
5
+ * 🔴 WHY THIS EXISTS, and it is the loudest lie the sweep could tell. A hook
6
+ * registered as `python3 .claude/hooks/guard.py` whose script is not there does
7
+ * not fail in a way anyone can see: `python3` exits **2** on a file it cannot
8
+ * open, and 2 is Claude Code's DENY code. So `decideHook` reads a block, and
9
+ * `experimental_verifyPluginGuards` reports the hook as blocking every disaster
10
+ * in the battery — a perfect score for a guard that does not exist. Measured on
11
+ * this repo's own build:
12
+ *
13
+ * ```
14
+ * hook `python3 .claude/hooks/guard.py`, script absent
15
+ * → MEASURED blocks=7/7 exits=2,2,2,2,2,2,2
16
+ * ```
17
+ *
18
+ * No malformed config is needed to reach it — a relative script path is the
19
+ * commonest shape in the wild — which makes it strictly worse than the
20
+ * uncompilable-matcher lie fixed alongside it, and it is the same false
21
+ * confidence the product sells against, produced by the product.
22
+ *
23
+ * THE EXIT CODE CANNOT DECIDE IT, and that is not a guess: `run-script.ts`
24
+ * already writes down the same fact for its own coverage attribution — "`sh
25
+ * <missing>` exits **2** under dash, which is Claude Code's BLOCK code —
26
+ * indistinguishable from a gate legitimately denying, so it cannot be encoded".
27
+ * The shell's own 126/127 catch a program that never launched; nothing catches a
28
+ * program that launched and could not find its script. That is what this module
29
+ * is for, and why the two halves are both needed rather than either alone.
30
+ *
31
+ * ## What counts as a file reference (MEASURED, not assumed)
32
+ *
33
+ * A word is only reported when it is UNAMBIGUOUSLY a script the command runs:
34
+ * fully resolved (no unexpanded `$`, no substitution, no glob, no whitespace),
35
+ * not a flag, not a `NAME=value`, not a URL, not the inline program text of a
36
+ * `-c`/`-e`, and either the head is a known interpreter or the word carries a
37
+ * script extension UNDER A HEAD THAT COULD EXECUTE IT. The OR is what keeps
38
+ * either list from being load-bearing on its own: an interpreter missing from
39
+ * the table is still caught by the extension, and an extensionless script is
40
+ * still caught by the interpreter.
41
+ *
42
+ * The `UNDER A HEAD` clause is the correction: an extension is evidence about
43
+ * the FILE, not about the command, so on its own it read `rm -f /tmp/stale.sh`
44
+ * — an ordinary cleanup hook whose file is meant to be absent — as a missing
45
+ * script. {@link DATA_ONLY_HEADS} is the set of heads that never execute an
46
+ * operand, and it silences the extension branch for them.
47
+ *
48
+ * The narrowing is measured, not taste. Over the 107 hook registrations in
49
+ * `davila7/claude-code-templates` (the corpus the defect was found in):
50
+ *
51
+ * | rule | hits | wrong |
52
+ * | ------------------------------------------- | ---: | ----: |
53
+ * | any path-shaped operand | 31 | 8 |
54
+ * | interpreter head OR script extension | 23 | 0 |
55
+ *
56
+ * The eight the wide rule got wrong are files a hook WRITES or later reads
57
+ * (`rm ~/.claude/session_start.tmp`, `mv ~/.claude/performance.csv`) and one
58
+ * outright absurdity (`echo N/A`, which contains a slash). Reporting those would
59
+ * be crying wolf on hooks that are perfectly fine, and a check read once and
60
+ * distrusted is a check that is off.
61
+ *
62
+ * ⚠️ THE `wrong: 0` ROW IS WHAT THE `DATA_ONLY_HEADS` CLAUSE CORRECTS, and the
63
+ * two measurements are of different corpora — say so rather than quoting one
64
+ * number. `rm -f /tmp/stale.sh` is exactly the eighth shape above wearing a
65
+ * script extension, and it was not in the pinned 107. Re-measured 2026-09-08
66
+ * against today's `davila7/claude-code-templates` (134 registrations, 120
67
+ * distinct commands — the repo has moved since the pin, so this is a second
68
+ * sample, not a reproduction of the first):
69
+ *
70
+ * | rule | hits | lost to the clause |
71
+ * | ------------------------------------------- | ---: | -----------------: |
72
+ * | interpreter head OR script extension | 5 | 0 |
73
+ * | …with the `DATA_ONLY_HEADS` clause | 5 | 0 |
74
+ *
75
+ * All five are true positives reached by an INTERPRETER head or a head that is
76
+ * itself a path, so the extension-under-an-unknown-head branch contributes zero
77
+ * hits on this sample and narrowing it costs nothing measurable. That is the
78
+ * evidence the clause does not trade a real detection for a false-positive fix;
79
+ * it is not evidence the branch is dead, which is why it is kept.
80
+ *
81
+ * CONSERVATIVE IN THE OTHER DIRECTION THAN {@link shellVarReads}, on purpose.
82
+ * Over-reporting a missing FILE costs a real guard its measurement AND accuses
83
+ * its author of shipping a broken hook, so here silence is the safe error: a
84
+ * word this module cannot fully resolve is skipped, never guessed at. The
85
+ * missing-program half of the same question is answered after the fact by the
86
+ * shell's 126/127, so a false negative here is not the last line of defence.
87
+ */
88
+ /** What a command would execute, as far as this module can resolve it. */
89
+ export interface CommandFileRefs {
90
+ /**
91
+ * Files the command hands to a program to run, in first-seen order, exactly
92
+ * as the shell would spell them (relative stays relative — the caller resolves
93
+ * against the cwd the hook will run in).
94
+ */
95
+ readonly refs: readonly string[];
96
+ /** Whether the shell parser accepted the command. `false` ⇒ `refs` is empty. */
97
+ readonly parsed: boolean;
98
+ }
99
+ /**
100
+ * The files `command` would hand to a program to execute.
101
+ *
102
+ * @param command - the shell command, exactly as the hook registers it.
103
+ * @param values - variables whose value is known at run time, for expansion. A
104
+ * word naming anything absent from this map is skipped, not guessed.
105
+ */
106
+ export declare function commandFileRefs(command: string, values?: Readonly<Record<string, string>>): CommandFileRefs;
107
+ //# sourceMappingURL=command-files.d.ts.map