Skip to content

doctor: make hook sprawl + duplicate checks matcher-aware#77

Merged
vishnujayvel merged 1 commit into
mainfrom
fix/doctor-matcher-aware
Jun 13, 2026
Merged

doctor: make hook sprawl + duplicate checks matcher-aware#77
vishnujayvel merged 1 commit into
mainfrom
fix/doctor-matcher-aware

Conversation

@vishnujayvel

@vishnujayvel vishnujayvel commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

Dogfooding the Stream-B hook-safety suite (#33-36) on a real ~/.claude/settings.json immediately exposed two false positives — fixing them here.

The bug: on a config where guards are scoped to specific tool matchers (e.g. calendar_guard.py under gcal_create_event, gcal_delete_event, …), doctor reported a spurious FAIL: PreToolUse has 12 hooks and four hook-duplicate warnings. But:

  • A matcher-scoped hook does not fire on unrelated calls — on a Bash call, 0 of those 12 fire. Counting them toward per-call sprawl is wrong.
  • The same script under different matchers is intentional per-tool protection, not a copy-paste duplicate.

The fix:

  • Sprawl severity now keys on always-fire hooks (matcher ""/"*") — the real per-call process cost. The inventory SCAN line still shows totals.
  • Duplicate detection keys on (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

  • TDD: matcher-discriminating tests (scoped ≠ sprawl alarm; different-matcher ≠ duplicate; always-fire still alarms; same-matcher still flags).
  • Guarded task test:go:unit + contract + arch green.
  • Confirmed against the maintainer's real settings.json: doctor went from FAIL → "All critical checks passed".

Follow-up fix to the doctor suite shipped in #75.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Refined hook sprawl detection to identify only unconditional "always-on" hooks that trigger on every event.
    • Enhanced duplicate hook detection to recognize duplicates based on both the matcher and command, treating same commands under different matchers as distinct.

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>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Hook sprawl and duplicate detection in internal/hooks/analyze.go are refactored to be matcher-aware. Sprawl now counts only always-firing hooks (empty or * matchers) per event, changing thresholds and messaging. Duplicates are now keyed by (matcher, command) pairs instead of command alone. Test infrastructure and cases added to validate matcher-scoped behavior.

Changes

Hook analysis matcher-aware refactor

Layer / File(s) Summary
Always-firing hook detection and sprawl findings
internal/hooks/analyze.go, internal/hooks/analyze_test.go
New helper functions identify unconditional hooks (empty or * matchers) and compute per-event always-firing counts. Sprawl threshold logic rewritten to use these counts instead of all hooks, with updated Finding messages describing always-on hooks. Test helper invM enables matcher-aware scenarios; sprawl tests verify matcher-scoped hooks do not alarm while always-fire hooks trigger WARN/FAIL, and HotPathThresholds test updated with new message wording.
Matcher-based duplicate detection
internal/hooks/analyze.go, internal/hooks/analyze_test.go
Duplicate detection key refined from command string to (matcher, command) pair per event. Same command under different matchers is no longer flagged as duplicate; same command under same matcher is. Test cases verify both scenarios and validate Finding count and content.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • vishnujayvel/hookwise#75: Directly refactors the same internal/hooks/analyze.go analysis functions—updating SprawlFindings always-on counting and DuplicateFindings deduplication semantics, with corresponding test infrastructure and matcher-focused test cases.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: making hook sprawl and duplicate checks matcher-aware, which is the core focus of the PR.
Description check ✅ Passed The PR description comprehensively covers all required sections: Summary (with clear bullet-point explanation of the fix), Motivation (problem statement with context), and Verification (testing approach). It follows the template structure and provides sufficient detail.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/doctor-matcher-aware

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2705283 and 5c2d24a.

📒 Files selected for processing (2)
  • internal/hooks/analyze.go
  • internal/hooks/analyze_test.go

Comment thread internal/hooks/analyze.go
Comment on lines +302 to 322
// 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."},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@vishnujayvel
vishnujayvel merged commit f07d582 into main Jun 13, 2026
4 checks passed
@vishnujayvel
vishnujayvel deleted the fix/doctor-matcher-aware branch June 13, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant