Repository navigation
Nothing asserts a declared hook actually fires — an unfired hook is indistinguishable from a passing one #15997
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea: git-hygieneWave 0 · cluster Y — Git hygiene & stranded workWave 0 · cluster Y — Git hygiene & stranded work
on Sep 7, 2026 👋 Auto-triage could not confidently classify this issue (no confident signal). Could a maintainer add the appropriate labels (
frontend,backend,infrastructure,docs,testingandgood-first-issue,intermediate,advanced)?- added a commit that references this issue
on Sep 17, 2026 Closed by PR #16890, merged to
main. Each criterion verified against the merged tree.AC Evidence on merged mainEnumerate every hook entry, assert each is reachable repo_tests/hook_declarations_are_reachable_test.py::test_every_declared_hook_can_be_selected, over all 6 entries in.claude/settings.jsonUnmatchable matcher fails, naming entry and reason findings read hooks.<event>[<i>]: matcher '…' <reason>, with two distinct reasons — is not a valid pattern vs matches none of the N known tool namesProve the check can fail four parametrized mutations of a real matcher (typo, malformed pattern, empty string, absent tool), plus a synthetic missing script and a matcher on a matcherless event Reach proven by a positive sentinel, not absence of error test_reach_is_proven_by_a_named_witness_not_by_silencerequires a named witness tool per entrymatchervsifdocumented where hooks are declared.claude/hooks/README.mdDead-hook findings filed separately all four referenced scripts exist; nothing to file The mutation control earned its place on the first run — it failed, and caught a real defect in this guard. The original
matched_toolstried the text before any(whenever the whole matcher selected nothing, silently repairing the malformedBash|Nonexistent(into a reachableBashand reporting it healthy. A checker that repairs its input reports health it has not verified — this file's own subject, one level up. The fallback now matches the documentedName(...)shape strictly.One divergence recorded rather than resolved, in the README and unchanged by the merge:
docs/developer/INSIGHTS_IMPROVEMENTS.mdshows aBash(git commit*)specifier form that no live entry uses and that I could not verify fires. It is written down as an open question rather than offered as an alternative, because a reader copying it could get a hook that never runs — which is this issue exactly. Anyone who knows the harness's behaviour can settle it in a line.
Problem
Nothing asserts that a declared hook actually runs. Every hook guard we have checks what a hook
does once invoked; none checks that it is invoked at all. A hook that never fires and a hook
that fires and passes are indistinguishable from the outside — both produce silence.
That is not hypothetical. The open hook bugs cluster on exactly this shape:
core.hooksPathoverride silently disables every hook in a worktreeprotect-files.shask()exits 2, so the decision is discardedA live instance of the same class, found this session outside the repo: a Claude Code
PreToolUsehook declared withmatcher: "Bash(git commit)". Thematcherfield is a tool-namepattern — permission-rule syntax belongs in the separate
iffield — so as a regex it wants theliteral string
Bashgit commitand never matches the tool nameBash. The hook had been dead.Verified by observation, not by reading the config: a
git commitrun in the main workingtree on
Dev_new_gui— which that hook blocks withexit 1and the messageCommits from the main working tree are blocked— was not blocked. Git answerednothing to commitinstead. A guard whose whole purpose is to stop commits outside a worktreehad been silently absent.
This repo's own
.claude/settings.jsondeclares six hooks. If one of them acquired the samedefect tomorrow, nothing would report it.
Scope
A liveness check for declared hooks: for each hook entry in
.claude/settings.json, assert thata matching tool call actually reaches it. The mechanism that matters is a sentinel — invoke
the matching shape and require positive evidence the hook ran, rather than inferring it from the
absence of a failure.
Explicitly a reach check, not a behaviour check: whether each hook's own logic is correct is
what the existing per-hook issues cover. Presence of the expected signal, never absence of an
error — an unfired hook and a passing hook look identical otherwise.
Acceptance criteria
.claude/settings.jsonand asserts each is reachable by at least one matching tool callmatchercannot match any tool name fails the test, naming the entry and the reasonmatchervsifsemantics are documented once, where hooks are declared, so the next author does not repeat the confusionBlast radius
Test-only plus a doc line; no runtime or product code path. The risk is a flaky reach check
becoming noise, which the "prove the check can fail" criterion is there to bound.
Related
git -C $VAR switch <branch>because it cannot resolve the variable #15446, bug(guards): the gitignore-shadow hook reads one section of .gitignore, so 44 tracked-but-ignored paths are invisible to it #15512 — hooks that stopped enforcing by different mechanismsprotect-files.sh ask()exits 2); worth deduplicating