doctor: make hook sprawl + duplicate checks matcher-aware#77
Conversation
Dogfooding on a real settings.json exposed two false positives in the Stream-B hook-safety checks: - sprawl counted ALL hooks per event, but a matcher-scoped hook (e.g. bound to mcp__cal__create) does not fork a process on unrelated calls. Severity now keys on always-fire hooks (matcher "" or "*") — the real per-call cost — so 12 PreToolUse hooks each scoped to a distinct tool no longer FAIL. - duplicate detection keyed on (event, command) only, so the same guard under different matchers was wrongly flagged. The dedup key now includes the matcher; only true copy-paste (same matcher + command) is a duplicate. Net: on a clean-but-matcher-heavy config, doctor now reports "All critical checks passed" instead of a spurious FAIL + duplicate warnings. The inventory SCAN line still shows totals. TDD: added matcher-discriminating tests (scoped≠sprawl, different-matcher≠dup). Unit + contract + arch gates green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughHook sprawl and duplicate detection in ChangesHook analysis matcher-aware refactor
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/hooks/analyze.go`:
- Around line 302-322: Canonicalize matcher aliases before building the dedup
key: in the loop over entries (the one that creates key{matcher, command} and
updates cmdCount/cmdOrder), normalize e.Matcher to a canonical value (e.g.,
treat "" and "*" the same by mapping "" -> "*" or vice‑versa) and use that
normalized value when constructing key; no other structural changes to key,
cmdCount, or cmdOrder are needed. Also add a regression test that constructs
entries with the same command and event but mixed matchers ("" and "*") and
asserts that a Finding with Code "hook-duplicate" is produced, ensuring the new
normalization catches the duplicate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0e9c09ea-d26b-4dbd-bc17-05e1edfb7404
📒 Files selected for processing (2)
internal/hooks/analyze.gointernal/hooks/analyze_test.go
| // Exact duplicates: same (matcher, command) appearing >1 times. The same | ||
| // command under DIFFERENT matchers is intentional per-tool protection, | ||
| // not a duplicate, so the matcher is part of the dedup key. | ||
| type key struct{ matcher, command string } | ||
| cmdCount := map[key]int{} | ||
| cmdOrder := []key{} | ||
| for _, e := range entries { | ||
| if _, ok := cmdCount[e.Command]; !ok { | ||
| cmdOrder = append(cmdOrder, e.Command) | ||
| k := key{e.Matcher, e.Command} | ||
| if _, ok := cmdCount[k]; !ok { | ||
| cmdOrder = append(cmdOrder, k) | ||
| } | ||
| cmdCount[e.Command]++ | ||
| cmdCount[k]++ | ||
| } | ||
| for _, cmd := range cmdOrder { | ||
| if cmdCount[cmd] > 1 { | ||
| for _, k := range cmdOrder { | ||
| cmd := k.command | ||
| if cmdCount[k] > 1 { | ||
| out = append(out, Finding{ | ||
| Level: LevelWarn, | ||
| Code: "hook-duplicate", | ||
| Message: fmt.Sprintf("%q appears %d times on %s", cmd, cmdCount[cmd], event), | ||
| Message: fmt.Sprintf("%q appears %d times on %s", cmd, cmdCount[k], event), | ||
| Details: []string{"Identical hooks waste resources. Remove the duplicates."}, |
There was a problem hiding this comment.
Canonicalize matcher aliases before duplicate-key counting.
Line [309] uses raw e.Matcher in the dedup key. That misses true duplicates when one hook uses "" and another uses "*" with the same command/event, even though this codebase already treats both as always-fire semantics.
🔧 Suggested fix
func DuplicateFindings(inv *Inventory) []Finding {
+ canonicalMatcher := func(m string) string {
+ m = strings.TrimSpace(m)
+ if m == "*" {
+ return ""
+ }
+ return m
+ }
+
// Group entries by event.
byEvent := map[string][]HookEntry{}
@@
type key struct{ matcher, command string }
cmdCount := map[key]int{}
cmdOrder := []key{}
for _, e := range entries {
- k := key{e.Matcher, e.Command}
+ k := key{canonicalMatcher(e.Matcher), e.Command}
if _, ok := cmdCount[k]; !ok {
cmdOrder = append(cmdOrder, k)
}
cmdCount[k]++
}Please also add a regression test for mixed "" + "*" matchers on the same command/event.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/hooks/analyze.go` around lines 302 - 322, Canonicalize matcher
aliases before building the dedup key: in the loop over entries (the one that
creates key{matcher, command} and updates cmdCount/cmdOrder), normalize
e.Matcher to a canonical value (e.g., treat "" and "*" the same by mapping "" ->
"*" or vice‑versa) and use that normalized value when constructing key; no other
structural changes to key, cmdCount, or cmdOrder are needed. Also add a
regression test that constructs entries with the same command and event but
mixed matchers ("" and "*") and asserts that a Finding with Code
"hook-duplicate" is produced, ensuring the new normalization catches the
duplicate.
Summary
Dogfooding the Stream-B hook-safety suite (#33-36) on a real
~/.claude/settings.jsonimmediately exposed two false positives — fixing them here.The bug: on a config where guards are scoped to specific tool matchers (e.g.
calendar_guard.pyundergcal_create_event,gcal_delete_event, …), doctor reported a spuriousFAIL: PreToolUse has 12 hooksand fourhook-duplicatewarnings. But:Bashcall, 0 of those 12 fire. Counting them toward per-call sprawl is wrong.The fix:
""/"*") — the real per-call process cost. The inventory SCAN line still shows totals.(matcher, command); only true copy-paste (same matcher + command) flags.Result: a clean-but-matcher-heavy config now reports "All critical checks passed" instead of a false FAIL.
Verification
task test:go:unit+ contract + arch green.Follow-up fix to the doctor suite shipped in #75.
🤖 Generated with Claude Code
Summary by CodeRabbit