test_syntax_hook.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import importlib.util
  2. import io
  3. import json
  4. import subprocess
  5. import tempfile
  6. import unittest
  7. from contextlib import redirect_stderr, redirect_stdout
  8. from pathlib import Path
  9. from unittest import mock
  10. PROJECT_ROOT = Path(__file__).resolve().parents[1]
  11. SPEC = importlib.util.spec_from_file_location(
  12. "rotator_syntax_hook", PROJECT_ROOT / ".claude" / "hooks" / "check_syntax.py"
  13. )
  14. syntax_hook = importlib.util.module_from_spec(SPEC)
  15. SPEC.loader.exec_module(syntax_hook)
  16. class TestSyntaxHook(unittest.TestCase):
  17. def setUp(self):
  18. temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-test-")
  19. self.addCleanup(temporary.cleanup)
  20. self.root = Path(temporary.name).resolve()
  21. def fixture(self, relative, content):
  22. path = self.root / relative
  23. path.parent.mkdir(parents=True, exist_ok=True)
  24. path.write_text(content, encoding="utf-8")
  25. return path
  26. def invoke(self, payload, raw=False):
  27. stdout = io.StringIO()
  28. stderr = io.StringIO()
  29. data = payload if raw else json.dumps(payload)
  30. with mock.patch.object(syntax_hook, "PROJECT_ROOT", self.root):
  31. with mock.patch("sys.stdin", io.StringIO(data)):
  32. with redirect_stdout(stdout), redirect_stderr(stderr):
  33. result = syntax_hook.main()
  34. return result, stdout.getvalue(), stderr.getvalue()
  35. def test_python_is_compiled_without_execution_or_bytecode(self):
  36. path = self.fixture("scripts/a file.py", "raise RuntimeError('must not run')\n")
  37. result = self.invoke({"tool_name": "Write", "tool_input": {"file_path": str(path)}})
  38. self.assertEqual(result, (0, "", ""))
  39. self.assertEqual(list(self.root.rglob("__pycache__")), [])
  40. def test_python_syntax_errors_are_reported_for_write_and_edit(self):
  41. path = self.fixture("scripts/broken.py", "def broken(:\n")
  42. for tool in ("Write", "Edit"):
  43. with self.subTest(tool=tool):
  44. code, stdout, stderr = self.invoke(
  45. {"tool_name": tool, "tool_input": {"file_path": str(path)}}
  46. )
  47. self.assertEqual(code, 2)
  48. self.assertEqual(stdout, "")
  49. self.assertIn("[syntax-check]", stderr)
  50. self.assertIn("broken.py", stderr)
  51. def test_relative_paths_resolve_from_project_root(self):
  52. self.fixture("scripts/relative.py", "value = 1\n")
  53. result = self.invoke(
  54. {"tool_name": "Edit", "tool_input": {"file_path": "scripts/relative.py"}}
  55. )
  56. self.assertEqual(result, (0, "", ""))
  57. def test_shell_and_javascript_use_expected_checkers(self):
  58. cases = (
  59. ("scripts/server/a file.sh", ["bash", "-n"]),
  60. ("scripts/router/a file.sh", ["sh", "-n"]),
  61. ("build_router_package.sh", ["sh", "-n"]),
  62. ("substore/a file.js", ["node", "--check"]),
  63. )
  64. for relative, checker in cases:
  65. with self.subTest(path=relative):
  66. path = self.fixture(relative, "content is not executed\n")
  67. command = checker + [str(path)]
  68. completed = subprocess.CompletedProcess(command, 0, "", "")
  69. with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed) as run:
  70. result = self.invoke(
  71. {"tool_name": "Edit", "tool_input": {"file_path": str(path)}}
  72. )
  73. self.assertEqual(result, (0, "", ""))
  74. run.assert_called_once_with(
  75. command,
  76. cwd=str(self.root),
  77. capture_output=True,
  78. text=True,
  79. timeout=10,
  80. )
  81. def test_unsupported_files_and_tools_are_skipped(self):
  82. for relative in ("README.md", "configs/config.json", "other.sh", "other.js"):
  83. with self.subTest(path=relative):
  84. path = self.fixture(relative, "not valid source code (\n")
  85. with mock.patch.object(syntax_hook.subprocess, "run") as run:
  86. result = self.invoke(
  87. {"tool_name": "Write", "tool_input": {"file_path": str(path)}}
  88. )
  89. self.assertEqual(result, (0, "", ""))
  90. run.assert_not_called()
  91. path = self.fixture("scripts/broken.py", "def broken(:\n")
  92. result = self.invoke({"tool_name": "Read", "tool_input": {"file_path": str(path)}})
  93. self.assertEqual(result, (0, "", ""))
  94. def test_outside_paths_are_skipped(self):
  95. temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-")
  96. self.addCleanup(temporary.cleanup)
  97. outside = Path(temporary.name).resolve() / "outside.py"
  98. outside.write_text("def broken(:\n", encoding="utf-8")
  99. result = self.invoke(
  100. {"tool_name": "Write", "tool_input": {"file_path": str(outside)}}
  101. )
  102. self.assertEqual(result, (0, "", ""))
  103. def test_symlink_escapes_are_skipped(self):
  104. temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-")
  105. self.addCleanup(temporary.cleanup)
  106. outside = Path(temporary.name).resolve() / "outside.py"
  107. outside.write_text("def broken(:\n", encoding="utf-8")
  108. link = self.root / "linked.py"
  109. try:
  110. link.symlink_to(outside)
  111. except (OSError, NotImplementedError) as exc:
  112. self.skipTest(f"Symlinks unavailable: {exc}")
  113. result = self.invoke({"tool_name": "Edit", "tool_input": {"file_path": str(link)}})
  114. self.assertEqual(result, (0, "", ""))
  115. def test_external_syntax_failures_are_reported(self):
  116. path = self.fixture("substore/broken.js", "const value = ;\n")
  117. completed = subprocess.CompletedProcess([], 1, "", "syntax failure")
  118. with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed):
  119. code, stdout, stderr = self.invoke(
  120. {"tool_name": "Edit", "tool_input": {"file_path": str(path)}}
  121. )
  122. self.assertEqual(code, 2)
  123. self.assertEqual(stdout, "")
  124. self.assertIn("syntax failure", stderr)
  125. def test_missing_tools_and_timeouts_are_reported(self):
  126. path = self.fixture("substore/operator.js", "const value = 1;\n")
  127. failures = (
  128. FileNotFoundError("node unavailable"),
  129. subprocess.TimeoutExpired(["node", "--check", str(path)], 10),
  130. )
  131. for failure in failures:
  132. with self.subTest(failure=type(failure).__name__):
  133. with mock.patch.object(syntax_hook.subprocess, "run", side_effect=failure):
  134. code, stdout, stderr = self.invoke(
  135. {"tool_name": "Write", "tool_input": {"file_path": str(path)}}
  136. )
  137. self.assertEqual(code, 2)
  138. self.assertEqual(stdout, "")
  139. self.assertIn(str(failure), stderr)
  140. def test_missing_python_file_is_reported(self):
  141. code, stdout, stderr = self.invoke(
  142. {"tool_name": "Edit", "tool_input": {"file_path": "scripts/missing.py"}}
  143. )
  144. self.assertEqual(code, 2)
  145. self.assertEqual(stdout, "")
  146. self.assertIn("missing.py", stderr)
  147. def test_invalid_payloads_are_reported(self):
  148. payloads = (
  149. "not json",
  150. "[]",
  151. json.dumps({"tool_name": "Write", "tool_input": None}),
  152. json.dumps({"tool_name": "Edit", "tool_input": {"file_path": 42}}),
  153. json.dumps({"tool_name": "Edit", "tool_input": {"file_path": ""}}),
  154. )
  155. for payload in payloads:
  156. with self.subTest(payload=payload):
  157. code, stdout, stderr = self.invoke(payload, raw=True)
  158. self.assertEqual(code, 2)
  159. self.assertEqual(stdout, "")
  160. self.assertIn("[syntax-check]", stderr)
  161. if __name__ == "__main__":
  162. unittest.main()