import importlib.util import io import json import subprocess import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from unittest import mock PROJECT_ROOT = Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location( "rotator_syntax_hook", PROJECT_ROOT / ".claude" / "hooks" / "check_syntax.py" ) syntax_hook = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(syntax_hook) class TestSyntaxHook(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-test-") self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() def fixture(self, relative, content): path = self.root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return path def invoke(self, payload, raw=False): stdout = io.StringIO() stderr = io.StringIO() data = payload if raw else json.dumps(payload) with mock.patch.object(syntax_hook, "PROJECT_ROOT", self.root): with mock.patch("sys.stdin", io.StringIO(data)): with redirect_stdout(stdout), redirect_stderr(stderr): result = syntax_hook.main() return result, stdout.getvalue(), stderr.getvalue() def test_python_is_compiled_without_execution_or_bytecode(self): path = self.fixture("scripts/a file.py", "raise RuntimeError('must not run')\n") result = self.invoke({"tool_name": "Write", "tool_input": {"file_path": str(path)}}) self.assertEqual(result, (0, "", "")) self.assertEqual(list(self.root.rglob("__pycache__")), []) def test_python_syntax_errors_are_reported_for_write_and_edit(self): path = self.fixture("scripts/broken.py", "def broken(:\n") for tool in ("Write", "Edit"): with self.subTest(tool=tool): code, stdout, stderr = self.invoke( {"tool_name": tool, "tool_input": {"file_path": str(path)}} ) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn("[syntax-check]", stderr) self.assertIn("broken.py", stderr) def test_relative_paths_resolve_from_project_root(self): self.fixture("scripts/relative.py", "value = 1\n") result = self.invoke( {"tool_name": "Edit", "tool_input": {"file_path": "scripts/relative.py"}} ) self.assertEqual(result, (0, "", "")) def test_shell_and_javascript_use_expected_checkers(self): cases = ( ("scripts/server/a file.sh", ["bash", "-n"]), ("scripts/router/a file.sh", ["sh", "-n"]), ("build_router_package.sh", ["sh", "-n"]), ("substore/a file.js", ["node", "--check"]), ) for relative, checker in cases: with self.subTest(path=relative): path = self.fixture(relative, "content is not executed\n") command = checker + [str(path)] completed = subprocess.CompletedProcess(command, 0, "", "") with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed) as run: result = self.invoke( {"tool_name": "Edit", "tool_input": {"file_path": str(path)}} ) self.assertEqual(result, (0, "", "")) run.assert_called_once_with( command, cwd=str(self.root), capture_output=True, text=True, timeout=10, ) def test_unsupported_files_and_tools_are_skipped(self): for relative in ("README.md", "configs/config.json", "other.sh", "other.js"): with self.subTest(path=relative): path = self.fixture(relative, "not valid source code (\n") with mock.patch.object(syntax_hook.subprocess, "run") as run: result = self.invoke( {"tool_name": "Write", "tool_input": {"file_path": str(path)}} ) self.assertEqual(result, (0, "", "")) run.assert_not_called() path = self.fixture("scripts/broken.py", "def broken(:\n") result = self.invoke({"tool_name": "Read", "tool_input": {"file_path": str(path)}}) self.assertEqual(result, (0, "", "")) def test_outside_paths_are_skipped(self): temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-") self.addCleanup(temporary.cleanup) outside = Path(temporary.name).resolve() / "outside.py" outside.write_text("def broken(:\n", encoding="utf-8") result = self.invoke( {"tool_name": "Write", "tool_input": {"file_path": str(outside)}} ) self.assertEqual(result, (0, "", "")) def test_symlink_escapes_are_skipped(self): temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-") self.addCleanup(temporary.cleanup) outside = Path(temporary.name).resolve() / "outside.py" outside.write_text("def broken(:\n", encoding="utf-8") link = self.root / "linked.py" try: link.symlink_to(outside) except (OSError, NotImplementedError) as exc: self.skipTest(f"Symlinks unavailable: {exc}") result = self.invoke({"tool_name": "Edit", "tool_input": {"file_path": str(link)}}) self.assertEqual(result, (0, "", "")) def test_external_syntax_failures_are_reported(self): path = self.fixture("substore/broken.js", "const value = ;\n") completed = subprocess.CompletedProcess([], 1, "", "syntax failure") with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed): code, stdout, stderr = self.invoke( {"tool_name": "Edit", "tool_input": {"file_path": str(path)}} ) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn("syntax failure", stderr) def test_missing_tools_and_timeouts_are_reported(self): path = self.fixture("substore/operator.js", "const value = 1;\n") failures = ( FileNotFoundError("node unavailable"), subprocess.TimeoutExpired(["node", "--check", str(path)], 10), ) for failure in failures: with self.subTest(failure=type(failure).__name__): with mock.patch.object(syntax_hook.subprocess, "run", side_effect=failure): code, stdout, stderr = self.invoke( {"tool_name": "Write", "tool_input": {"file_path": str(path)}} ) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn(str(failure), stderr) def test_missing_python_file_is_reported(self): code, stdout, stderr = self.invoke( {"tool_name": "Edit", "tool_input": {"file_path": "scripts/missing.py"}} ) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn("missing.py", stderr) def test_invalid_payloads_are_reported(self): payloads = ( "not json", "[]", json.dumps({"tool_name": "Write", "tool_input": None}), json.dumps({"tool_name": "Edit", "tool_input": {"file_path": 42}}), json.dumps({"tool_name": "Edit", "tool_input": {"file_path": ""}}), ) for payload in payloads: with self.subTest(payload=payload): code, stdout, stderr = self.invoke(payload, raw=True) self.assertEqual(code, 2) self.assertEqual(stdout, "") self.assertIn("[syntax-check]", stderr) if __name__ == "__main__": unittest.main()