* 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>
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
$VARpaths ($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).