Files
graphify/tests/test_install_hook_identity.py
N. Hariharan c3d537ae4a fix(install): remove only graphify's own hook entries, keep others (#4359)
Hook install/uninstall identified graphify's hooks with
`"graphify" in str(group)` over the whole group dict. That removed a user's
own command sharing graphify's `hooks` array (with the whole group), and any
unrelated group whose command path merely contained the word "graphify" (e.g.
a script under `graphify-notes/`). Uninstall rewrote the settings file with no
backup, and `_strip_graphify_hook` raised AttributeError on a non-dict entry.

Match the individual `hooks[]` entries instead. A graphify entry is a command
hook whose command invokes graphify's own `hook-guard`/`hook-check` subcommands
(current installs) or references `graphify-out/` (the pre-#522 inline bash
guard). Only those entries are removed; a group is dropped only when nothing is
left in it, and non-dict/non-list shapes are passed through. Applies to the
Claude, CodeBuddy, Codex and Gemini install and uninstall filters, keeping them
in step. Uninstall now goes through `_write_settings_with_backup`, so it keeps
a rolling `.graphify-bak` like install already did.

Adds tests covering a user command sharing graphify's group, an unrelated group
whose path contains "graphify", a non-dict entry, and the legacy-bash replacement
plus idempotency for each host.

(cherry picked from commit 41310c256d)
2026-10-12 22:37:54 +01:00

204 lines
7.1 KiB
Python

"""Hook install/uninstall must remove only graphify's own hook entries (#4359).
Install and uninstall used to identify graphify's hooks with
``"graphify" in str(group)`` over the whole group dict. That removed:
- a user's own command sharing graphify's ``hooks`` array, along with the group;
- an unrelated group whose command path merely contained the word ``graphify``
(e.g. a script under a folder named ``graphify-notes``);
- and, on uninstall, wrote the settings file back with no backup while
``_strip_graphify_hook`` raised ``AttributeError`` on a non-dict entry.
These tests pin the entry-scoped contract: only entries graphify installed go,
a group is dropped only when nothing is left in it, other tools' commands
survive, and uninstall keeps a rolling backup.
"""
from __future__ import annotations
import json
import pytest
from graphify import install as install_mod
# id -> (install, uninstall, settings file relative to project, section key)
_INSTALLERS = {
"claude": (
lambda project: install_mod._install_claude_hook(project),
lambda project: install_mod._uninstall_claude_hook(project),
".claude/settings.json",
"PreToolUse",
),
"codebuddy": (
lambda project: install_mod._install_codebuddy_hook(project),
lambda project: install_mod._uninstall_codebuddy_hook(project),
".codebuddy/settings.json",
"PreToolUse",
),
"codex": (
lambda project: install_mod._install_codex_hook(project),
lambda project: install_mod._uninstall_codex_hook(project),
".codex/hooks.json",
"PreToolUse",
),
"gemini": (
lambda project: install_mod._install_gemini_hook(project),
lambda project: install_mod._uninstall_gemini_hook(project),
".gemini/settings.json",
"BeforeTool",
),
}
def _groups(settings: dict, section: str) -> list:
return settings["hooks"][section]
def _has_graphify(entry: object) -> bool:
return install_mod._is_graphify_hook_entry(entry)
@pytest.mark.parametrize("name", sorted(_INSTALLERS))
def test_uninstall_keeps_user_commands_and_unrelated_groups(tmp_path, name):
install, uninstall, rel, section = _INSTALLERS[name]
project = tmp_path / "proj"
project.mkdir()
install(project)
settings_path = project / rel
settings = json.loads(settings_path.read_text(encoding="utf-8"))
groups = _groups(settings, section)
assert any(
_has_graphify(e) for g in groups if isinstance(g, dict) for e in g.get("hooks", [])
), f"{name} install registered no graphify hook"
# 1) the user adds their own command to graphify's own group;
# 2) an unrelated guard group whose command path contains "graphify".
for group in groups:
if isinstance(group, dict) and any(_has_graphify(e) for e in group.get("hooks", [])):
group["hooks"].append({"type": "command", "command": "python /tools/my-linter.py"})
break
groups.append(
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "python /tools/graphify-notes/check.py"}],
}
)
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
uninstall(project)
after = settings_path.read_text(encoding="utf-8")
assert "my-linter" in after, f"{name}: the user's command in graphify's group was removed"
assert "graphify-notes" in after, f"{name}: the unrelated group was removed"
assert "hook-guard" not in after and "hook-check" not in after, (
f"{name}: graphify's own hook commands were not removed"
)
backup = settings_path.with_name(settings_path.name + ".graphify-bak")
assert backup.exists(), f"{name}: uninstall wrote no backup"
@pytest.mark.parametrize("name", sorted(_INSTALLERS))
def test_uninstall_survives_a_non_dict_entry(tmp_path, name):
"""#4359: a plain string in the hook array must not crash uninstall, and must
be preserved (it is not graphify's)."""
install, uninstall, rel, section = _INSTALLERS[name]
project = tmp_path / "proj"
project.mkdir()
install(project)
settings_path = project / rel
settings = json.loads(settings_path.read_text(encoding="utf-8"))
_groups(settings, section).append("a-legacy-string-entry")
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
uninstall(project) # must not raise
after = json.loads(settings_path.read_text(encoding="utf-8"))
assert "a-legacy-string-entry" in _groups(after, section)
def test_install_removes_legacy_bash_hook_and_stays_idempotent(tmp_path):
"""The pre-#522 inline bash guard is still replaced, without duplicating
graphify's groups on a re-run."""
project = tmp_path / "proj"
settings_path = project / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "[ -f graphify-out/graph.json ] && echo x || true",
}
],
}
]
}
}
),
encoding="utf-8",
)
install_mod._install_claude_hook(project)
install_mod._install_claude_hook(project)
groups = json.loads(settings_path.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
assert "graphify-out/graph.json" not in json.dumps(groups), "legacy bash guard survived"
assert len([g for g in groups if "graphify" in json.dumps(g)]) == 2, "not idempotent"
@pytest.mark.parametrize(
"command",
[
"graphify hook-guard search",
'"C:/tools/graphify.EXE" hook-guard read --strict',
"graphify hook-guard gemini",
"graphify hook-check",
"[ -f graphify-out/graph.json ] && echo x || true",
],
)
def test_graphify_commands_are_recognised(command):
assert install_mod._is_graphify_hook_command(command)
@pytest.mark.parametrize(
"command",
[
"python /tools/graphify-notes/check.py",
"python /tools/check.py",
"eslint .",
"",
],
)
def test_other_commands_are_not_recognised(command):
assert not install_mod._is_graphify_hook_command(command)
def test_strip_drops_only_emptied_groups_and_passes_malformed_through():
groups = [
{
"matcher": "Bash|Grep",
"hooks": [
{"type": "command", "command": "graphify hook-guard search"},
{"type": "command", "command": "my-own-command"},
],
},
{"matcher": "Read|Glob", "hooks": [{"type": "command", "command": "graphify hook-guard read"}]},
"not-a-dict",
{"matcher": "Bash", "hooks": "not-a-list"},
]
out = install_mod._strip_graphify_hooks(groups)
assert out == [
{"matcher": "Bash|Grep", "hooks": [{"type": "command", "command": "my-own-command"}]},
"not-a-dict",
{"matcher": "Bash", "hooks": "not-a-list"},
]