Files
zernie__vigiles/docs/rules/hook-script-exists.md
zernie 7afa00f00a fix(refs): 17 false "file is missing" accusations, none of them true (#157)
* fix(scan): the reference extractor accused files that were present

Three false-accusation defects found by a 32-repo OSS dogfood sweep, all the
same shape — vigiles telling a maintainer a file they have is missing. For a
tool whose pitch is deterministic precision, a false positive costs more than a
miss: it is the failure mode that gets the tool uninstalled.

Measured on the corpus (91 audit units): 74 reference findings before, 57
after. All 17 removed are verified false; zero true positives lost, zero new
findings anywhere. Eight units change grade, every one upward — rsmdt/the-startup
F(54)→C(70), ayghri/i-have-adhd B(85)→A(100), fcakyon fable-advisor B(82)→A(90),
jjanczur/tyran D(64)→C(72), and Truthfulness 47→100 / 25→100 on two Microsoft
plugins.

One extractor was being run over five grammars. It now reads three, each through
that grammar's parser, and deliberately stops reading the other two:

  markdown  markdownRefs() in core/markdown.ts — a link's DESTINATION, and code
            spans that are NOT inside link text. The old line-scanner reported a
            link's LABEL as a missing resource while its destination resolved.
  shell     commandWords() in core/bash-effects.ts — the words a shell would
            resolve, with inline program text (`node -e`, `python -c`) removed
            and a nested `sh -c` parsed rather than discarded. The old regex cut
            a character run out of a JavaScript payload and reported it as the
            hook's script name.
  source    core/source-refs.ts — the one place a path-token pattern is built,
            with both boundaries un-omittable. `js` could claim the head of
            `json`; `(?:agents)/` could claim the tail of `claude-agents/`. Both
            were live.

  NOT READ: frontmatter (already stripped) and full-line comments in every
  language, not only shell — both remaining corpus false positives here came out
  of JSDoc.

Subtracted: four hand-written extension alternations (two of them duplicated
across the disk detector and its browser twin), a hand-rolled markdown link
regex, a hand-rolled inline-code regex, a whitespace split standing in for shell
words, and `stripShellComments`.

No new CLI verb, no new flag, no public API change (`api:check`: no drift).

Tests: 10 mutations, each proven applied by grep before running and each killed
by its own assertion, across the whole relevant suite. Every new case carries
both halves — fires on the planted defect, silent on the clean shape beside it.

* fix(scan): drop the redundant start-of-content guard in isPluginRooted

`startsAtSeparator` already answers idx <= 0 with true, and the next line
reaches it for idx === 0 (content[-1] is undefined, so never "/"). The guard
was a second copy of a boundary rule this module deliberately does not own —
the same duplication the module comment warns about — and it was the one
statement the 100% gate could not reach.

Also regenerates .vigiles/generated.d.ts for the new source files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 20:13:34 +05:00

3.2 KiB

hook-script-exists

Flag a hook command that references a script file which doesn't exist on disk. A PreToolUse/SessionStart/… hook pointing at ${CLAUDE_PLUGIN_ROOT}/hooks/x.sh when that file isn't there silently never runs — the protection or automation the author wired up simply doesn't happen, with no error. Same detector vigiles audit uses (the hook resolver in src/scan.ts); one detector, two callers.

This matches Anthropic's own claude plugin validate — adopting it makes vigiles a superset of the official validator on the hook surface.

What it flags

"hooks": {
  "PreToolUse": [{
    "matcher": "Bash",
    "hooks": [{ "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }]
  }]
}

→ flagged if hooks/guard.sh doesn't exist at the plugin root.

FP-safety

The shared resolver already excludes the cases that aren't real misses:

  • Unresolved $VAR paths ($CLAUDE_PROJECT_DIR/..., a runtime var) → reported as "unresolved", never "missing" (can't be checked, so not flagged).
  • Existence-guarded one-liners ([ ! -f x ] || x, test -f x && x) → treated as optional, not a hard reference.
  • Inline commands with no script file (echo …, npx …) → counted as inline, not path-checked.

A relative hook path (./hooks/x.sh) resolves against the plugin root, not the scanner's cwd.

The command is parsed as shell, not scanned as text. Script operands come from a real shell parse (mvdan-sh), so the standard portable-plugin idiom

"command": "node -e \"(async()=>{ await import(join(root,'hooks','always-on.mjs')) })()\""

is read as what it is — an inline one-liner whose -e argument is a program, not a path. Scanning the raw string instead cut a character run out of that JavaScript and reported it as the hook's missing script, under a name the author never wrote; that produced nine false "MISSING" findings across a 32-repo sweep (2026-08-17) and contributed to two F/0 grades. A script named on the right of && (cd "$ROOT" && node hooks/x.mjs) is still checked: it has to exist whether or not it runs.

Configuration

{ "rules": { "hook-script-exists": "warn" } }

Severity

Value Behavior
"error" vigiles lint exits non-zero (2) on a missing hook script
"warn" (default) Prints a warning, exits 0
false Skip the check

Scope

Hook commands in .claude/settings.json and the plugin manifest (.claude-plugin/plugin.json), across braced/unbraced ${CLAUDE_PLUGIN_ROOT}.

Why

A hook is deterministic harness machinery; a hook whose script is missing is a silent no-op — the worst kind of failure, because the author believes the guard is in place. Catching it before it ships closes a real gap (the sweep found broken hook refs in the wild).

See also

  • untested-hook — a hook script that ships without a test/eval (a different concern: the script exists but is unverified).