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