Skip to content

Commit 737a0b8

Browse files
safishamsiclaude
andcommitted
test: rigorous edge cases for the hook-guard subcommand (#522)
Adds a wide matrix over _run_hook_guard covering the search/read detection boundaries and the gemini BeforeTool contract: - search: grep-family variants (grep/pgrep/egrep/fgrep/ripgrep), token matches (rg/find/fd/ack/ag with trailing space), piped commands; and silence for non-search commands, empty/missing/non-string command, 'find' without a trailing space, 'ag' mid-word, top-level vs nested tool_input, non-dict tool_input, and no-graph. - read: source/framework extensions, uppercase and multi-dot names, Windows backslash paths, glob patterns; and silence for .json (not .js), lockfiles, images, extensionless files, an extension on a directory segment, targets under the (default and custom-named) output dir, and no-graph. - fail-open: malformed/empty/binary stdin and a throwing graph-existence check never crash or block. - gemini: always returns {"decision":"allow"}, nudges only with a graph, and stays "allow" even if the check throws. - dispatch/exit/encoding via real subprocess: missing/unknown mode exits 0 silently, every mode exits 0 (never blocks), and the read nudge's em dash round-trips as valid UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 85f9fac commit 737a0b8

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

tests/test_hook_guard.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Rigorous edge-case coverage for the `graphify hook-guard` subcommand (#522).
2+
3+
Covers the shell-agnostic PreToolUse/BeforeTool guard that replaced the inline
4+
bash hooks: the search/read detection matrix, the gemini BeforeTool contract,
5+
fail-open behavior, output-dir overrides, subcommand dispatch, exit codes, and
6+
UTF-8 (em dash) byte fidelity. Detection is exercised by calling _run_hook_guard
7+
directly (hermetic, fast); dispatch/exit/encoding go through a real subprocess.
8+
"""
9+
import io
10+
import json
11+
import os
12+
import subprocess
13+
import sys
14+
15+
import pytest
16+
17+
from graphify import __main__ as m
18+
19+
20+
# --------------------------------------------------------------------------- #
21+
# Direct-call harness: hermetic w.r.t. the ambient GRAPHIFY_OUT env.
22+
# --------------------------------------------------------------------------- #
23+
def _invoke(kind, payload, tmp_path, monkeypatch, *, graph=True, out_name="graphify-out"):
24+
monkeypatch.setattr("graphify.paths.GRAPHIFY_OUT", out_name)
25+
monkeypatch.setattr("graphify.paths.GRAPHIFY_OUT_NAME", out_name)
26+
monkeypatch.chdir(tmp_path)
27+
if graph:
28+
(tmp_path / out_name).mkdir(parents=True, exist_ok=True)
29+
(tmp_path / out_name / "graph.json").write_text("{}", encoding="utf-8")
30+
31+
if isinstance(payload, (bytes, bytearray)):
32+
data = bytes(payload)
33+
elif payload is None:
34+
data = b""
35+
else:
36+
data = json.dumps(payload).encode("utf-8")
37+
38+
class _Stdin:
39+
def __init__(self, b):
40+
self.buffer = io.BytesIO(b)
41+
42+
monkeypatch.setattr(sys, "stdin", _Stdin(data))
43+
buf = io.StringIO()
44+
monkeypatch.setattr(sys, "stdout", buf)
45+
m._run_hook_guard(kind)
46+
return buf.getvalue()
47+
48+
49+
# --------------------------------------------------------------------------- #
50+
# search: commands that MUST nudge (mirror the old *grep*/token globs)
51+
# --------------------------------------------------------------------------- #
52+
@pytest.mark.parametrize("command", [
53+
"grep -rn foo .",
54+
"pgrep -f server", # contains 'grep'
55+
"egrep pattern file", # contains 'grep'
56+
"fgrep lit file", # contains 'grep'
57+
"ls -la | grep foo", # piped
58+
"ripgrep thing",
59+
"rg pattern src/",
60+
"find . -name '*.py'",
61+
"fd bar",
62+
"ack needle",
63+
"ag needle",
64+
])
65+
def test_search_nudges(command, tmp_path, monkeypatch):
66+
out = _invoke("search", {"tool_input": {"command": command}}, tmp_path, monkeypatch)
67+
assert "graphify query" in out, f"{command!r} should nudge"
68+
assert json.loads(out)["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
69+
70+
71+
# --------------------------------------------------------------------------- #
72+
# search: commands / inputs that MUST stay silent
73+
# --------------------------------------------------------------------------- #
74+
@pytest.mark.parametrize("command", [
75+
"", # empty
76+
"ls -la",
77+
"git status",
78+
"cat README.md",
79+
"python app.py",
80+
"cd findings && ls", # 'find' without a trailing space is not a match
81+
"manage db migrate", # 'ag' mid-word, no 'ag ' token
82+
"echo hello",
83+
])
84+
def test_search_silent(command, tmp_path, monkeypatch):
85+
out = _invoke("search", {"tool_input": {"command": command}}, tmp_path, monkeypatch)
86+
assert out.strip() == "", f"{command!r} should be silent"
87+
88+
89+
def test_search_silent_without_graph(tmp_path, monkeypatch):
90+
out = _invoke("search", {"tool_input": {"command": "grep x"}}, tmp_path, monkeypatch, graph=False)
91+
assert out.strip() == ""
92+
93+
94+
def test_search_missing_command_key(tmp_path, monkeypatch):
95+
out = _invoke("search", {"tool_input": {}}, tmp_path, monkeypatch)
96+
assert out.strip() == ""
97+
98+
99+
def test_search_non_string_command_is_silent(tmp_path, monkeypatch):
100+
out = _invoke("search", {"tool_input": {"command": 123}}, tmp_path, monkeypatch)
101+
assert out.strip() == ""
102+
103+
104+
def test_search_top_level_command_without_tool_input(tmp_path, monkeypatch):
105+
# Some hosts pass the tool payload flat (no "tool_input" wrapper).
106+
out = _invoke("search", {"command": "grep x"}, tmp_path, monkeypatch)
107+
assert "graphify query" in out
108+
109+
110+
def test_search_non_dict_tool_input_is_silent(tmp_path, monkeypatch):
111+
out = _invoke("search", {"tool_input": "grep foo"}, tmp_path, monkeypatch)
112+
assert out.strip() == ""
113+
114+
115+
# --------------------------------------------------------------------------- #
116+
# read: file targets that MUST nudge
117+
# --------------------------------------------------------------------------- #
118+
@pytest.mark.parametrize("tool_input", [
119+
{"file_path": "src/app.py"},
120+
{"file_path": "pkg/mod.ts"},
121+
{"file_path": "src/App.vue"},
122+
{"file_path": "src/Hero.astro"},
123+
{"file_path": "src/Card.svelte"},
124+
{"file_path": "SRC/APP.PY"}, # uppercase extension
125+
{"file_path": "src/a.test.tsx"}, # multi-dot -> .tsx
126+
{"file_path": "lib/foo.min.js"}, # multi-dot -> .js
127+
{"file_path": r"src\components\app.py"}, # windows backslashes
128+
{"pattern": "**/*.py", "path": "src"}, # glob pattern
129+
{"pattern": "**/*.astro"},
130+
])
131+
def test_read_nudges(tool_input, tmp_path, monkeypatch):
132+
out = _invoke("read", {"tool_input": tool_input}, tmp_path, monkeypatch)
133+
assert "graphify query" in out, f"{tool_input!r} should nudge"
134+
135+
136+
# --------------------------------------------------------------------------- #
137+
# read: targets that MUST stay silent
138+
# --------------------------------------------------------------------------- #
139+
@pytest.mark.parametrize("tool_input", [
140+
{"file_path": "package.json"}, # .json must not match .js
141+
{"file_path": "tsconfig.json"},
142+
{"file_path": "data.geojson"},
143+
{"file_path": "uv.lock"},
144+
{"file_path": "logo.png"},
145+
{"file_path": "data.bin"},
146+
{"file_path": ".gitignore"},
147+
{"file_path": "Makefile"}, # no extension
148+
{"file_path": "my.ts/file"}, # extension on a directory segment
149+
{"file_path": "graphify-out/GRAPH_REPORT.md"}, # the graph's own output
150+
{"file_path": ""},
151+
{}, # nothing at all
152+
])
153+
def test_read_silent(tool_input, tmp_path, monkeypatch):
154+
out = _invoke("read", {"tool_input": tool_input}, tmp_path, monkeypatch)
155+
assert out.strip() == "", f"{tool_input!r} should be silent"
156+
157+
158+
def test_read_silent_without_graph(tmp_path, monkeypatch):
159+
out = _invoke("read", {"tool_input": {"file_path": "src/app.py"}}, tmp_path, monkeypatch, graph=False)
160+
assert out.strip() == ""
161+
162+
163+
def test_read_non_dict_tool_input_is_silent(tmp_path, monkeypatch):
164+
out = _invoke("read", {"tool_input": ["src/app.py"]}, tmp_path, monkeypatch)
165+
assert out.strip() == ""
166+
167+
168+
def test_read_respects_custom_output_dir_name(tmp_path, monkeypatch):
169+
# A source file living under a CUSTOM output dir name must be suppressed too,
170+
# not just the literal 'graphify-out/'.
171+
out = _invoke("read", {"tool_input": {"file_path": "build-out/report.py"}},
172+
tmp_path, monkeypatch, graph=True, out_name="build-out")
173+
assert out.strip() == ""
174+
175+
176+
def test_read_nudges_source_outside_custom_output_dir(tmp_path, monkeypatch):
177+
out = _invoke("read", {"tool_input": {"file_path": "src/app.py"}},
178+
tmp_path, monkeypatch, graph=True, out_name="build-out")
179+
assert "graphify query" in out
180+
181+
182+
# --------------------------------------------------------------------------- #
183+
# fail-open: malformed / empty stdin never crashes or blocks
184+
# --------------------------------------------------------------------------- #
185+
@pytest.mark.parametrize("kind", ["search", "read"])
186+
@pytest.mark.parametrize("raw", [b"not json at all", b"", b"[1,2,3]", b"\xff\xfe\x00bad"])
187+
def test_fail_open_on_bad_stdin(kind, raw, tmp_path, monkeypatch):
188+
out = _invoke(kind, raw, tmp_path, monkeypatch)
189+
assert out.strip() == ""
190+
191+
192+
def test_search_out_path_error_is_swallowed(tmp_path, monkeypatch):
193+
# If the graph-existence check itself throws, the guard stays silent (never
194+
# blocks the tool).
195+
def _boom(*a, **k):
196+
raise OSError("boom")
197+
monkeypatch.setattr("graphify.paths.out_path", _boom)
198+
out = _invoke("search", {"tool_input": {"command": "grep x"}}, tmp_path, monkeypatch)
199+
assert out.strip() == ""
200+
201+
202+
# --------------------------------------------------------------------------- #
203+
# gemini: BeforeTool contract (always allow; nudge only when a graph exists)
204+
# --------------------------------------------------------------------------- #
205+
def test_gemini_allow_with_nudge(tmp_path, monkeypatch):
206+
out = _invoke("gemini", None, tmp_path, monkeypatch, graph=True)
207+
payload = json.loads(out)
208+
assert payload["decision"] == "allow"
209+
assert "graphify query" in payload["additionalContext"]
210+
211+
212+
def test_gemini_allow_without_graph(tmp_path, monkeypatch):
213+
out = _invoke("gemini", None, tmp_path, monkeypatch, graph=False)
214+
payload = json.loads(out)
215+
assert payload == {"decision": "allow"}
216+
217+
218+
def test_gemini_always_allows_even_when_check_throws(tmp_path, monkeypatch):
219+
def _boom(*a, **k):
220+
raise OSError("boom")
221+
monkeypatch.setattr("graphify.paths.out_path", _boom)
222+
out = _invoke("gemini", None, tmp_path, monkeypatch, graph=True)
223+
assert json.loads(out) == {"decision": "allow"}
224+
225+
226+
# --------------------------------------------------------------------------- #
227+
# subcommand dispatch, exit codes, and UTF-8 fidelity (real subprocess)
228+
# --------------------------------------------------------------------------- #
229+
def _env():
230+
e = dict(os.environ)
231+
e.pop("GRAPHIFY_OUT", None)
232+
return e
233+
234+
235+
def _cli(args, tmp_path, stdin=""):
236+
return subprocess.run(
237+
[sys.executable, "-m", "graphify", *args],
238+
input=stdin, capture_output=True, text=True, cwd=tmp_path, env=_env(),
239+
)
240+
241+
242+
def test_dispatch_missing_mode_exits_zero_silent(tmp_path):
243+
r = _cli(["hook-guard"], tmp_path, stdin="{}")
244+
assert r.returncode == 0
245+
assert r.stdout.strip() == ""
246+
247+
248+
def test_dispatch_unknown_mode_exits_zero_silent(tmp_path):
249+
r = _cli(["hook-guard", "bogus"], tmp_path, stdin="{}")
250+
assert r.returncode == 0
251+
assert r.stdout.strip() == ""
252+
253+
254+
@pytest.mark.parametrize("args,stdin", [
255+
(["hook-guard", "search"], '{"tool_input":{"command":"grep x"}}'),
256+
(["hook-guard", "read"], '{"tool_input":{"file_path":"a.py"}}'),
257+
(["hook-guard", "gemini"], ""),
258+
])
259+
def test_dispatch_always_exits_zero(args, stdin, tmp_path):
260+
# even with a graph present (nudge path), exit code must be 0 (never blocks)
261+
(tmp_path / "graphify-out").mkdir()
262+
(tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
263+
r = _cli(args, tmp_path, stdin=stdin)
264+
assert r.returncode == 0
265+
266+
267+
def test_read_nudge_em_dash_survives_utf8(tmp_path):
268+
# The read nudge contains an em dash; the emitted bytes must be valid UTF-8
269+
# and parse back cleanly (guards the ensure_ascii=False + stdout reconfigure).
270+
(tmp_path / "graphify-out").mkdir()
271+
(tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
272+
r = subprocess.run(
273+
[sys.executable, "-m", "graphify", "hook-guard", "read"],
274+
input=b'{"tool_input":{"file_path":"src/app.py"}}',
275+
capture_output=True, cwd=tmp_path, env=_env(),
276+
)
277+
assert r.returncode == 0
278+
text = r.stdout.decode("utf-8") # raises if not valid UTF-8
279+
payload = json.loads(text)
280+
assert "—" in payload["hookSpecificOutput"]["additionalContext"] # em dash preserved

0 commit comments

Comments
 (0)