Skip to content

fix: isolate Claude hook installs#248

Merged
Gradata merged 2 commits into
mainfrom
gra-108-hook-test-isolation-dedupe
Jun 3, 2026
Merged

fix: isolate Claude hook installs#248
Gradata merged 2 commits into
mainfrom
gra-108-hook-test-isolation-dedupe

Conversation

@Gradata

@Gradata Gradata commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Isolate Claude Code hook adapter install tests from real user config by using tmp HOME/USERPROFILE paths.
  • Replace stale same-event Gradata module hooks before writing new Claude Code hooks, preventing temp BRAIN_DIR accumulation even when a current hook already exists.
  • Add doctor hook-brain-dir check that warns and auto-removes Gradata hooks pointing at missing BRAIN_DIR targets.

Verification

  • python3 -m pytest Gradata/tests/test_hook_adapters.py Gradata/tests/test_doctor_hooks.py -q → 19 passed
  • /home/olive/.local/bin/uvx ruff check Gradata/src/gradata/_doctor.py Gradata/src/gradata/hooks/adapters/claude_code.py Gradata/src/gradata/hooks/stale_hook_check.py Gradata/tests/test_hook_adapters.py Gradata/tests/test_doctor_hooks.py → All checks passed
  • /home/olive/.local/bin/uvx ruff format --check Gradata/src/gradata/_doctor.py Gradata/src/gradata/hooks/adapters/claude_code.py Gradata/src/gradata/hooks/stale_hook_check.py Gradata/tests/test_hook_adapters.py Gradata/tests/test_doctor_hooks.py → 5 files already formatted

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR improves Gradata's Claude hook lifecycle by centralizing settings file parsing, detecting and removing hook entries with missing BRAIN_DIR targets, integrating those checks into the doctor diagnostics system, and refining hook installation to avoid duplicate entries through exact-set matching rather than broad substring checks.

Changes

Hook lifecycle management and cleanup

Layer / File(s) Summary
Settings parsing and stale hook removal infrastructure
Gradata/src/gradata/hooks/stale_hook_check.py
Adds _read_settings() to safely load Claude settings JSON with type checking; refactors _missing_hook_brain_dirs() to use it; implements _remove_missing_hook_brain_dirs() to traverse hook structures, identify and deduplicate entries with missing BRAIN_DIR paths, and persist the cleaned settings.
Hook installation exact-set matching and deduplication
Gradata/src/gradata/hooks/adapters/claude_code.py, Gradata/tests/test_hook_adapters.py
Replaces broad substring-based idempotency checks in install() with _has_exact_installed_hook_set() to verify all configured event/module hooks match the desired signature; adds _is_gradata_module_hook() to identify Gradata-owned entries and _remove_existing_module_hook() to prune stale entries while preserving user-created hooks; tests verify stale entries are replaced rather than duplicated.
Doctor health check for missing hooks
Gradata/src/gradata/_doctor.py, Gradata/tests/test_doctor_hooks.py
Adds _check_missing_hook_brain_dirs() to detect stale hook targets and optionally auto-remove them, returning structured ok/warn/error status; integrates into diagnose() as a local-only check; test verifies detection, status reporting, and filtering of commands after auto-removal.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Gradata/gradata#242: Main PR adds a new doctor check (_check_missing_hook_brain_dirs) that reuses the stale-hook logic for detecting/removing Claude BRAIN_DIR targets, directly tying it to the retrieved PR's enhancements in hooks/stale_hook_check.py for missing BRAIN_DIR handling.

Suggested labels

bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: isolate Claude hook installs' accurately captures the main change—improving Claude hook installation by isolating tests and deduplicating hooks to prevent stale BRAIN_DIR accumulation.
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.
Description check ✅ Passed The pull request description clearly outlines the changes: isolating Claude Code hook adapter tests, deduplicating hooks to prevent BRAIN_DIR accumulation, and adding a doctor check for missing BRAIN_DIR targets. These match the actual code changes in the summary.

✏️ 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 gra-108-hook-test-isolation-dedupe

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

@Gradata Gradata force-pushed the gra-108-hook-test-isolation-dedupe branch from 6044f45 to 8597b76 Compare June 3, 2026 06:13

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai coderabbitai Bot added the bug Something isn't working label Jun 3, 2026

@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: 4

🤖 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 `@Gradata/src/gradata/hooks/adapters/claude_code.py`:
- Around line 101-114: The _has_exact_installed_hook_set function currently only
verifies there's one Gradata module entry per (event,module) and that the
signature (sig) appears in it; update it to also validate that the configured
matcher equals the expected _matcher for that (event,module). After building
module_entries (the single matching entry), extract the matcher from
module_entries[0] (the same structure used by _is_gradata_module_hook) and
compare it to _matcher; if they differ, return False so install() won't treat a
wrong-matcher-but-same-id hook as already_present. Ensure you reference HOOKS,
_has_exact_installed_hook_set, _is_gradata_module_hook, module_entries, event,
module, sig and _matcher when making this check.

In `@Gradata/src/gradata/hooks/stale_hook_check.py`:
- Around line 200-207: The current _read_settings(path: Path) swallows all
read/parse errors and returns None, making callers like
_missing_hook_brain_dirs() and _remove_missing_hook_brain_dirs() treat
unreadable settings as "no hooks" — change _read_settings to only return None
when the file doesn't exist, but for read/parse failures catch specific
exceptions (json.JSONDecodeError, OSError) and either raise a small custom
exception (e.g., UnreadableSettingsError) or return a distinctive sentinel value
(e.g., {"__unreadable__": True}) so callers can differentiate this case; also
log a warning with logger.warning(...) including the path and exc_info=True to
avoid silent failures and reference the function name _read_settings as the
change location and update callers (_missing_hook_brain_dirs,
_remove_missing_hook_brain_dirs) to handle the new error/sentinel path
accordingly.
- Around line 210-215: The function _remove_missing_hook_brain_dirs should
respect the isolated-hook-root guard like _missing_hook_brain_dirs: detect if
either GRADATA_HOOK_ROOT or GRADATA_HOOK_ROOT_POST environment variables are set
and, if so, short-circuit and return an empty list instead of reading/mutating
the real Claude settings; update _remove_missing_hook_brain_dirs to perform this
check before calling _settings_path()/_read_settings() (and note that diagnose()
now calls _remove_missing_hook_brain_dirs by default, so the guard must run
early to prevent touching the real ~/.claude/settings.json).
- Around line 255-261: When removing hooks and persisting the updated settings
(the block that currently calls path.write_text(json.dumps(settings,...))),
replace the direct write with the repository's atomic JSON writer to avoid
truncation — e.g., call the project's atomic_write_json helper (or
write_json_atomic) to write the settings dict to path; keep the same logic
around settings, hooks, removed and path.parent.mkdir but route the final write
through the atomic helper instead of path.write_text.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e55104d-7e78-4181-912e-00f512d8f855

📥 Commits

Reviewing files that changed from the base of the PR and between 9daf9e5 and 8597b76.

📒 Files selected for processing (5)
  • Gradata/src/gradata/_doctor.py
  • Gradata/src/gradata/hooks/adapters/claude_code.py
  • Gradata/src/gradata/hooks/stale_hook_check.py
  • Gradata/tests/test_doctor_hooks.py
  • Gradata/tests/test_hook_adapters.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: pytest windows-latest / py3.11
  • GitHub Check: pytest ubuntu-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.12
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest (py3.12)
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/tests/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/tests/**/*.py: Set BRAIN_DIR environment variable via tmp_path in conftest.py for test isolation — ensure _paths.py module cache refreshes when calling Brain.init() directly inside tests
Add unit tests in tests/test_*.py for every CI push without LLM calls (deterministic); mark integration tests with @pytest.mark.integration and skip them by default (they hit real LLM APIs)

Files:

  • Gradata/tests/test_hook_adapters.py
  • Gradata/tests/test_doctor_hooks.py
Gradata/src/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/src/**/*.py: Prefer sentence-transformers for local embeddings, google-genai for Gemini embeddings, cryptography for AES-GCM encrypted system.db, bm25s for BM25 rule ranking, and mem0ai for external memory adapters — guard all optional dependency imports with try / except ImportError at the call site, never at module level
Maintain strict layering: Layer 0 (Primitives: _types.py, _db.py, _events.py, _paths.py, _file_lock.py; Patterns: contrib/patterns/) must never import from Layer 1 (Enhancements: enhancements/, rules/) or Layer 2 (Public API: brain.py, cli.py, daemon.py, mcp_server.py)
Never use bare except: pass — use typed exceptions or at minimum logger.warning(...) with exc_info=True to avoid silent failure in a memory product
Never import from out-of-scope sibling directories ../Sprites/ or ../Hausgem/ within gradata/* code — that is a layering bug
Never leak private-sibling paths into public docs/code — no references to ../Sprites/, ../Hausgem/, email addresses, OneDrive paths, or Sprites-specific examples from inside gradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes

Files:

  • Gradata/src/gradata/_doctor.py
  • Gradata/src/gradata/hooks/adapters/claude_code.py
  • Gradata/src/gradata/hooks/stale_hook_check.py
🧠 Learnings (3)
📓 Common learnings
Learnt from: Gradata
Repo: Gradata/gradata PR: 0
File: :0-0
Timestamp: 2026-04-17T17:18:07.439Z
Learning: In PR `#102` (gradata/gradata), Round 2 addressed: cli.py env-first brain resolution (GRADATA_BRAIN > --brain-dir > cwd), _tenant.py corrupt .tenant_id overwrite, _env_int default clamping to minimum, and _events.py tenant-scoped fallback SELECT for dedup. All ruff and 99 tests green after these fixes.
📚 Learning: 2026-05-01T15:50:32.772Z
Learnt from: CR
Repo: Gradata/gradata PR: 0
File: Gradata/AGENTS.md:0-0
Timestamp: 2026-05-01T15:50:32.772Z
Learning: Applies to Gradata/tests/**/*.py : Set `BRAIN_DIR` environment variable via `tmp_path` in conftest.py for test isolation — ensure `_paths.py` module cache refreshes when calling `Brain.init()` directly inside tests

Applied to files:

  • Gradata/tests/test_hook_adapters.py
  • Gradata/tests/test_doctor_hooks.py
  • Gradata/src/gradata/hooks/stale_hook_check.py
📚 Learning: 2026-05-01T15:50:32.772Z
Learnt from: CR
Repo: Gradata/gradata PR: 0
File: Gradata/AGENTS.md:0-0
Timestamp: 2026-05-01T15:50:32.772Z
Learning: Use `from gradata import Brain` as the public entry point — `brain.correct()` is THE entry point for the headline product promise

Applied to files:

  • Gradata/src/gradata/hooks/stale_hook_check.py
🔇 Additional comments (2)
Gradata/tests/test_hook_adapters.py (1)

135-214: LGTM!

Gradata/tests/test_doctor_hooks.py (1)

1-60: LGTM!

Comment on lines +101 to +114
def _has_exact_installed_hook_set(hooks: dict, sig: str) -> bool:
"""Return true only when every Gradata module hook is the desired one."""
for event, _matcher, module in HOOKS:
entries = hooks.get(event)
if not isinstance(entries, list):
return False
module_entries = [
entry for entry in entries if _is_gradata_module_hook(entry, event, module)
]
if len(module_entries) != 1:
return False
if f"{sig}:{event}:{module}" not in str(module_entries[0]):
return False
return True

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

Validate the configured matcher in the “exact set” check.

Right now this returns True as long as each (event, module) has one Gradata entry with the expected signature. If a group was edited to the wrong matcher but kept the same id, install() will incorrectly return already_present and never repair the hook behavior.

Suggested fix
 def _has_exact_installed_hook_set(hooks: dict, sig: str) -> bool:
     """Return true only when every Gradata module hook is the desired one."""
-    for event, _matcher, module in HOOKS:
+    for event, matcher, module in HOOKS:
         entries = hooks.get(event)
         if not isinstance(entries, list):
             return False
         module_entries = [
             entry for entry in entries if _is_gradata_module_hook(entry, event, module)
         ]
         if len(module_entries) != 1:
             return False
-        if f"{sig}:{event}:{module}" not in str(module_entries[0]):
+        entry = module_entries[0]
+        if f"{sig}:{event}:{module}" not in str(entry):
             return False
+        if not isinstance(entry, dict):
+            return False
+        if matcher is None:
+            if "matcher" in entry:
+                return False
+        elif entry.get("matcher") != matcher:
+            return False
     return True
🤖 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 `@Gradata/src/gradata/hooks/adapters/claude_code.py` around lines 101 - 114,
The _has_exact_installed_hook_set function currently only verifies there's one
Gradata module entry per (event,module) and that the signature (sig) appears in
it; update it to also validate that the configured matcher equals the expected
_matcher for that (event,module). After building module_entries (the single
matching entry), extract the matcher from module_entries[0] (the same structure
used by _is_gradata_module_hook) and compare it to _matcher; if they differ,
return False so install() won't treat a wrong-matcher-but-same-id hook as
already_present. Ensure you reference HOOKS, _has_exact_installed_hook_set,
_is_gradata_module_hook, module_entries, event, module, sig and _matcher when
making this check.

Comment on lines +200 to +207
def _read_settings(path: Path) -> dict | None:
if not path.is_file():
return None
try:
settings = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return settings if isinstance(settings, dict) else None

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

Don't collapse unreadable settings.json into a clean state.

Returning None for every read/parse failure makes _missing_hook_brain_dirs() and _remove_missing_hook_brain_dirs() behave as if no hooks exist. A malformed or permission-denied Claude settings file will therefore suppress both the warning path and the doctor remediation path.

As per coding guidelines, "Never use bare except: pass — use typed exceptions or at minimum logger.warning(...) with exc_info=True to avoid silent failure in a memory product".

🤖 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 `@Gradata/src/gradata/hooks/stale_hook_check.py` around lines 200 - 207, The
current _read_settings(path: Path) swallows all read/parse errors and returns
None, making callers like _missing_hook_brain_dirs() and
_remove_missing_hook_brain_dirs() treat unreadable settings as "no hooks" —
change _read_settings to only return None when the file doesn't exist, but for
read/parse failures catch specific exceptions (json.JSONDecodeError, OSError)
and either raise a small custom exception (e.g., UnreadableSettingsError) or
return a distinctive sentinel value (e.g., {"__unreadable__": True}) so callers
can differentiate this case; also log a warning with logger.warning(...)
including the path and exc_info=True to avoid silent failures and reference the
function name _read_settings as the change location and update callers
(_missing_hook_brain_dirs, _remove_missing_hook_brain_dirs) to handle the new
error/sentinel path accordingly.

Comment on lines +210 to +215
def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]:
"""Remove Gradata hook commands whose BRAIN_DIR targets no longer exist."""
path = settings_path or _settings_path()
settings = _read_settings(path)
if not settings:
return []

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

Carry over the isolated-hook-root guard before mutating real Claude settings.

_missing_hook_brain_dirs() already short-circuits when GRADATA_HOOK_ROOT / GRADATA_HOOK_ROOT_POST are set so isolated test/dev runs do not touch the user's real ~/.claude/settings.json. _remove_missing_hook_brain_dirs() lacks that guard, and diagnose() now calls it by default, so the doctor path can still rewrite real user config in those isolated contexts.

Suggested fix
 def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]:
     """Remove Gradata hook commands whose BRAIN_DIR targets no longer exist."""
+    if settings_path is None and (
+        env_str("GRADATA_HOOK_ROOT") or env_str("GRADATA_HOOK_ROOT_POST")
+    ):
+        return []
     path = settings_path or _settings_path()
     settings = _read_settings(path)
     if not settings:
         return []
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]:
"""Remove Gradata hook commands whose BRAIN_DIR targets no longer exist."""
path = settings_path or _settings_path()
settings = _read_settings(path)
if not settings:
return []
def _remove_missing_hook_brain_dirs(settings_path: Path | None = None) -> list[Path]:
"""Remove Gradata hook commands whose BRAIN_DIR targets no longer exist."""
if settings_path is None and (
env_str("GRADATA_HOOK_ROOT") or env_str("GRADATA_HOOK_ROOT_POST")
):
return []
path = settings_path or _settings_path()
settings = _read_settings(path)
if not settings:
return []
🤖 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 `@Gradata/src/gradata/hooks/stale_hook_check.py` around lines 210 - 215, The
function _remove_missing_hook_brain_dirs should respect the isolated-hook-root
guard like _missing_hook_brain_dirs: detect if either GRADATA_HOOK_ROOT or
GRADATA_HOOK_ROOT_POST environment variables are set and, if so, short-circuit
and return an empty list instead of reading/mutating the real Claude settings;
update _remove_missing_hook_brain_dirs to perform this check before calling
_settings_path()/_read_settings() (and note that diagnose() now calls
_remove_missing_hook_brain_dirs by default, so the guard must run early to
prevent touching the real ~/.claude/settings.json).

Comment on lines +255 to +261
if removed:
if hooks:
settings["hooks"] = hooks
else:
settings.pop("hooks", None)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(settings, indent=2, sort_keys=True) + "\n", encoding="utf-8")

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

Write settings.json atomically.

This direct write_text() can leave Claude's config truncated or partially rewritten on crash/interruption. Please route this through the repo's atomic JSON write helper instead of overwriting the file in place.

As per coding guidelines, "Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes".

🤖 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 `@Gradata/src/gradata/hooks/stale_hook_check.py` around lines 255 - 261, When
removing hooks and persisting the updated settings (the block that currently
calls path.write_text(json.dumps(settings,...))), replace the direct write with
the repository's atomic JSON writer to avoid truncation — e.g., call the
project's atomic_write_json helper (or write_json_atomic) to write the settings
dict to path; keep the same logic around settings, hooks, removed and
path.parent.mkdir but route the final write through the atomic helper instead of
path.write_text.

@Gradata Gradata merged commit 07799d7 into main Jun 3, 2026
9 checks passed
@Gradata Gradata deleted the gra-108-hook-test-isolation-dedupe branch June 3, 2026 07:00
@Gradata Gradata restored the gra-108-hook-test-isolation-dedupe branch June 8, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant