| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- 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()
|