The Hooks That Get Rejected Are the Cheap Ones: Measuring Matcher Reachability
Hook configurations that can never execute are now rejected at load time. But when I checked my own config against all 21 tool names, the real problems were on the side that passed. A static reachability checker, plus measured numbers on what over-matching actually costs.
Formatting was supposed to run on every save. It wasn't running.
No error. Nothing in the logs. The hook was registered, the config file said exactly what I meant it to say. It simply never fired.
IDE 2.6.0 started rejecting hook configurations that can never execute, with a clear error at load time instead of silently ignoring them. That is a genuine improvement, and I took it as a prompt to audit everything I had.
The audit hurt. Just not on the side that got rejected.
There are two kinds of hooks that don't work
The first kind never fires. The matcher hits no tool name, the event is misspelled, the command isn't on PATH. Load-time rejection catches roughly this family.
The second kind fires too much. Nothing about it is broken — the regex compiles, the command exists, the hook runs happily. It just also runs on tools the author never had in mind.
The first kind silently does nothing. The second kind silently does something. The second one is clearly the worse neighbor.
So I measured mine.
Cross-checking matchers against every tool name
Whether a matcher hits anything is not a property of the matcher. It's a property of the matcher paired with the set of tool names it will be tested against.
I wrote out the 21 tool names that actually appear in my sessions — the built-ins plus the names my connected MCP servers expose, like mcp__github__create_issue.
Then I took the matchers I and the developers around me actually write, and tested each one under both semantics: substring matching (re.search) and full matching (re.fullmatch).
matcher
status
substring
full match
delta
"" (empty)
ok
21
0
21
*
compile error
—
—
—
.*
ok
21
21
0
Bash
ok
2
1
1
Edit
ok
3
1
2
Write|Edit
ok
5
2
3
^Bash$
ok
1
1
0
Bash.*
ok
2
2
0
mcp__.*
ok
5
5
0
Read|Glob|Grep
ok
3
3
0
Notebook
ok
1
0
1
edit
ok
0
0
0
(Write|Edit
compile error
—
—
—
Of the 12 that compiled, 5 — 41.7% — changed how many tools they hit when the semantics changed.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦An empty matcher hits all 21 tools under substring semantics and zero tools under full-match semantics — the same empty string flips meaning depending on the engine
✦A dead hook costs 0ms because it never runs. The expensive class is over-matching, which passes validation cleanly and adds 31.6ms per unintended tool call with a Node hook
✦A 126-line checker for reachability, event names, and command resolution in hooks.json — 48.28ms of check time even at 1,000 groups, small enough for a CI step
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Under substring matching, an empty string hits all 21 tools. Under full matching, it hits none. The same string containing nothing at all swings between "everything" and "nothing" purely on the engine's choice.
People write an empty matcher to mean "don't filter." Under substring semantics that intent survives. Under full-match semantics the entire hook goes quiet. And because the config is not malformed in any way, load-time validation has nothing to complain about.
Writing * turns out to be the more honest mistake. It's a glob habit, and as a regular expression it fails outright with nothing to repeat at position 0. Failing loudly is a courtesy.
Notebook is the sneaky one. It hits NotebookEdit under substring matching and nothing under full matching — exactly the shape that convinces you it works.
I had matcher: "Edit" on PostToolUse running Prettier. I meant Edit. In practice it also fired on MultiEdit and NotebookEdit. Two out of every three invocations were unintended.
Notebooks were being handed to Prettier. Nothing broke, thankfully, but that isn't a comfortable place to have been sitting.
As an indie developer working on one machine, nobody was ever going to point this out. The person who wrote the config and the person inconvenienced by it are the same person, which makes the inconvenience invisible.
hookcheck.py — checking reachability statically
Eyeballing doesn't scale, so I wrote a checker. Hand it a config and a list of tool names and it reports unreachable hooks (DEAD) and over-matching ones (WIDE).
It looks at four things: whether the event name is in the known set, whether the matcher compiles, whether it hits at least one tool name, and whether the command's first token resolves to something executable.
That last one I ended up rewriting, for reasons I get to further down. Writing it myself was the single biggest mistake in this whole exercise.
#!/usr/bin/env python3"""hookcheck.py - statically check hook configurations for reachability"""import json, os, re, shlex, shutil, sys, timeKNOWN_EVENTS = { "PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "Notification",}TOOL_EVENTS = {"PreToolUse", "PostToolUse"}def load_tool_universe(path): with open(path, encoding="utf-8") as fp: return [line.strip() for line in fp if line.strip() and not line.startswith("#")]def resolve_command(command): try: argv = shlex.split(command, posix=(os.name != "nt")) except ValueError as exc: return None, f"shell syntax error: {exc}" if not argv: return None, "empty command" head = argv[0].strip('"') found = shutil.which(head) if found is None: if os.sep in head or (os.altsep and os.altsep in head): return None, f"not an executable file: {head}" return None, f"not found on PATH: {head}" return found, Nonedef check(config, tools, semantics="search"): findings = [] for event, groups in config.get("hooks", {}).items(): if event not in KNOWN_EVENTS: findings.append(("DEAD", event, None, f"unknown event name: {event}")) continue for index, group in enumerate(groups): where = f"{event}[{index}]" matcher = group.get("matcher") if event in TOOL_EVENTS: if matcher is None: findings.append(("DEAD", where, None, "matcher is undefined")) else: try: rx = re.compile(matcher) except re.error as exc: findings.append(("DEAD", where, matcher, f"invalid regex: {exc}")) rx = None if rx is not None: test = rx.fullmatch if semantics == "fullmatch" else rx.search hits = [t for t in tools if test(t)] if not hits: findings.append(("DEAD", where, matcher, "matches no tool name")) elif matcher != ".*" and len(hits) > 1 and not matcher.startswith("^"): findings.append(("WIDE", where, matcher, f"matches {len(hits)}: {', '.join(hits)}")) elif matcher not in (None, "", ".*"): findings.append(("DEAD", where, matcher, f"{event} carries no tool name, so matcher is never evaluated")) for hook in group.get("hooks", []): command = hook.get("command", "") _, error = resolve_command(command) if error: findings.append(("DEAD", where, command, error)) return findingsdef hit_counts(config, tools, semantics): counts = {} for event, groups in config.get("hooks", {}).items(): if event not in TOOL_EVENTS: continue for index, group in enumerate(groups): matcher = group.get("matcher") if matcher is None: continue try: rx = re.compile(matcher) except re.error: continue test = rx.fullmatch if semantics == "fullmatch" else rx.search counts[f"{event}[{index}]"] = (matcher, sum(1 for t in tools if test(t))) return countsdef report_divergence(config, tools): left = hit_counts(config, tools, "search") right = hit_counts(config, tools, "fullmatch") diverged = [(w, m, a, right[w][1]) for w, (m, a) in left.items() if right[w][1] != a] for where, matcher, a, b in diverged: print(f"~ {where} [{matcher}] search={a} fullmatch={b} depends on semantics") print(f"\nDIVERGENT={len(diverged)}/{len(left)}") return 1 if diverged else 0def main(): if len(sys.argv) < 3: print("usage: hookcheck.py <hooks.json> <tools.txt> [--fullmatch|--both]", file=sys.stderr) return 2 with open(sys.argv[1], encoding="utf-8") as fp: config = json.load(fp) tools = load_tool_universe(sys.argv[2]) if "--both" in sys.argv: return report_divergence(config, tools) semantics = "fullmatch" if "--fullmatch" in sys.argv else "search" started = time.perf_counter() findings = check(config, tools, semantics) elapsed_ms = (time.perf_counter() - started) * 1000 dead = sum(1 for f in findings if f[0] == "DEAD") for level, where, subject, message in findings: mark = "x" if level == "DEAD" else "!" subject_text = f" [{subject}]" if subject else "" print(f"{mark} {where}{subject_text} {message}") print(f"\nDEAD={dead} WIDE={len(findings) - dead} semantics={semantics} {elapsed_ms:.2f}ms") return 1 if dead else 0if __name__ == "__main__": sys.exit(main())
The tool list is just one name per line. If writing it out by hand feels tedious, copy the tool listing from the start of a session and save it.
The not matcher.startswith("^") condition on the over-match warning exists because a matcher anchored at the front reads as a deliberate attempt to cast a wide net. Warning about intentional breadth is the fastest way to teach people to ignore warnings.
Running it against my own config
Here is what my config produced.
$ python3 hookcheck.py hooks.json tools.txt
! PreToolUse[0] [Bash] matches 2: Bash, BashOutput
x PreToolUse[1] [*] invalid regex: nothing to repeat at position 0
x PreToolUse[2] [edit] matches no tool name
x PreToolUse[2] [prettier --write] not found on PATH: prettier
! PostToolUse[0] [Edit] matches 3: Edit, MultiEdit, NotebookEdit
! PostToolUse[1] matches 21: Bash, BashOutput, KillShell, Read, Write, ...
x PostToolUse[1] [notify-send done] not found on PATH: notify-send
x UserPromptSubmit[0] [Bash] UserPromptSubmit carries no tool name, so matcher is never evaluated
x PreCompact unknown event name: PreCompact
DEAD=6 WIDE=3 semantics=search 0.62ms
And with --fullmatch, changing only the semantics:
DEAD went from 6 to 7, WIDE from 3 to 0. Not a single character of the config changed.
I keep both invocations around permanently and read only the diff between them. Any matcher whose verdict moves is a matcher leaning on an assumption about the engine. Since the semantics aren't spelled out in the documentation, leaning on them is a bet I'd rather not place.
Subtracting DEAD counts by eye every morning turned out to be a sloppy way to read that, so I added --both, which prints the divergence directly.
Three of the four groups that compiled changed their hit count on semantics alone. That's the number worth tracking — not the DEAD total, but how many matchers change meaning depending on the engine's mood.
The rejected ones are the cheap ones
This was the part that surprised me.
A dead hook costs 0ms. It never runs, so it consumes nothing. Its only damage is structural — a quality check you believed was running isn't. Load-time rejection makes that visible immediately, which is exactly the right treatment.
Over-matching is a different animal. It runs. It runs every time. A hook is a process spawn, so real wall-clock time attaches to it.
I timed four no-op commands, 30 launches each, same machine.
hook body
median
min
max
sh -c echo ok
1.12ms
1.06ms
1.23ms
/usr/bin/true
1.95ms
1.83ms
2.23ms
python3 -c pass
17.72ms
17.16ms
19.05ms
node -e 0
31.61ms
27.98ms
35.89ms
A Node process that does nothing costs 31.61ms. A real hook adds its own work on top of that.
With matcher: "Edit" on a Node-based formatter, two of every three firings were unintended. An empty matcher under substring semantics rides along on every tool call: 1.6 seconds across a 50-call session, 6.3 seconds across 200. Simple arithmetic, but it lands in your day as "things feel sluggish today" rather than as a number you can point at.
I suspected the matching itself might be the cost. It isn't.
matcher count
match time per tool call (median)
10
0.0025ms
100
0.0193ms
1,000
0.1914ms
Even a thousand matchers costs 0.19ms. Every bit of the expense sits on the process-spawn side.
Which means load-time validation catches the cheap failure. The expensive one walks straight past it. A config being valid and a config being what you meant turn out to be entirely separate claims.
Cheap enough for a CI step
A checker only survives if it's fast. I varied the group count.
hook groups
check time
full process (median)
10
0.98ms
23.6ms
50
3.12ms
26.2ms
200
10.06ms
34.7ms
1,000
48.28ms
76.1ms
At 1,000 groups it's 48.28ms of checking and 76.1ms including Python startup. At any size a human will actually write by hand, treat it as free.
It exits 1 when there is at least one DEAD finding, so it stops a pipeline on its own.
I run the reachability check and the divergence check back to back. A config that only passes under one set of semantics will quietly change behavior the day the environment does.
WIDE findings deliberately don't affect the exit code. How much over-matching a repository tolerates is a local decision. Having them in front of me on every run has been enough.
The pass I make once per repository
Every time I pick up a new repository, I go through this. It takes under five minutes.
Dump the tool listing from the start of a session into tools.txt, one name per line. Do it after the MCP servers have connected, or their names won't be there
Run hookcheck.py under substring semantics and drive DEAD to zero. Most of what surfaces here is spelling and event names
Run it again with --both and confirm DIVERGENT=0. Wrap any matcher it names in ^ and $
Read the tool names on each WIDE line one by one and check that nothing unintended snuck in. This is the step to slow down on
Rewrite hook commands to use absolute paths or an explicit npx / env, then repeat steps 2 and 3
Wire both invocations into CI so the next person to touch the config doesn't have to remember any of this
Step 4 is the one a machine can't do for you. Only the author knows what was intended.
"It's on PATH" was too generous a test
This part is about rewriting the checker itself.
My first resolve_command walked the PATH directories in order and returned the first candidate where os.access(candidate, os.X_OK) came back true. It felt like the straightforward implementation.
It was too straightforward. Files aren't the only things carrying an execute bit. Directories carry one too — on a directory it means "you may traverse this" — so an ordinary directory answers true.
I reproduced it locally by dropping a directory named prettier at the front of PATH. Nothing else.
command
old (os.access)
new (shutil.which)
prettier --write
resolved: /tmp/hc/fakebin/prettier
not found on PATH
/tmp/hc --check
resolved: /tmp/hc
not an executable file
The old implementation was reporting directories as runnable commands. Worse, that error pushes DEAD counts down, not up. The checker says all clear and the hook fails to launch — the exact direction of wrongness a checker must never have.
Adding a parent directory to PATH when you meant node_modules/.bin is a common enough slip. I saw DEAD=0, relaxed, and found a week later that the hook had never run. The config was fine. The thing checking it was not.
A false negative in a checker is worse than having no checker. When you know nothing is watching, you look yourself. When you think something is watching, nobody does.
The rewrite hands the job to shutil.which, which also tests that the candidate is a file, so the directory above gets rejected. On Windows it consults PATHEXT to fill in extensions, so prettier can resolve to prettier.cmd. My hand-rolled PATH walk dropped that branch entirely.
shlex.split needed the same treatment. Its default POSIX mode consumes backslashes as escape characters.
A path with its separators eaten resolves nowhere, so every Windows hook lines up as DEAD. That direction is at least visible — but real DEAD findings get buried in the noise, which is its own kind of damage.
I measured what the rewrite costs. Per command, the os.access walk runs at a median of 0.032ms and shutil.which at 0.046ms. The difference is 0.014ms. Even across 1,000 groups that's 14ms on top, which disappears into the check times measured above. If correctness costs 14ms, there is no argument against buying it.
What the documentation doesn't say
Three things I wish I'd known before editing any of this.
The matching semantics are frequently unspecified. Substring versus full match changes the meaning of an empty matcher, of Edit, and of Notebook. When in doubt, check under both and avoid any matcher where the verdict moves. Wrapping the pattern in ^ and $ makes both engines agree.
Command resolution depends on PATH — and on how you test it.prettier resolved on my machine and not in CI. The reverse happens too. Use absolute paths in hook commands, or put npx or env in front explicitly, and let shutil.which do the lookup rather than walking PATH by hand. The checker reads the live PATH, which is precisely why running it inside CI is worth the step.
Unknown event names can fail quietly. A plausible-looking PreCompact does nothing if it isn't in the known set. Comparing against a known-event list is the least interesting check in the script, and it's the one that actually caught me.
For how registration timing relates to firing conditions, I wrote up the move to declarative session definitions in the hooks that went from flaky to failing every time. Reading both makes it easier to see where each decision gets made.
I changed what I suspect first
I used to suspect the config whenever a hook didn't fire. Now I start with the hooks that do fire.
The range of problems caught at load time has genuinely widened. Still, not being rejected is not evidence of being correct. Counting how many tools a matcher actually hits was the one step that closed that gap.
Run your own hooks.json through both semantics once. Even with DEAD at zero, if the WIDE lines list tool names you don't recognize, that's where you're paying 31ms a call.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.