Files
Agentic-AI-Template/Codex/Coding/Codex-cs/.codex/hooks/tdd-guard.py
T
2026-09-04 17:00:34 +09:00

184 lines
4.8 KiB
Python

import json
import subprocess
import sys
from pathlib import Path
SOURCE_SUFFIXES = {".cs"}
TEST_SUFFIXES = {".cs"}
def _repo_root(cwd: Path) -> Path:
try:
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return cwd
return Path(root)
def _extract_patch_paths(command: str) -> list[str]:
prefixes = (
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
)
paths: list[str] = []
for raw_line in command.splitlines():
line = raw_line.strip()
for prefix in prefixes:
if line.startswith(prefix):
paths.append(line[len(prefix) :].strip())
break
return paths
def _touched_paths(payload: dict) -> list[str]:
tool_input = payload.get("tool_input", {})
if not isinstance(tool_input, dict):
return []
file_path = tool_input.get("file_path")
if isinstance(file_path, str) and file_path:
return [file_path]
command = tool_input.get("command")
if isinstance(command, str):
return _extract_patch_paths(command)
return []
def _normalize(path_text: str) -> str:
return path_text.replace("\\", "/").lower()
def _path_segments(path_text: str) -> list[str]:
return [segment for segment in _normalize(path_text).split("/") if segment]
def _is_ignored_path(path_text: str) -> bool:
return any(segment in {"bin", "obj"} for segment in _path_segments(path_text))
def _is_test_path(path_text: str) -> bool:
path = Path(path_text)
if path.suffix.lower() not in TEST_SUFFIXES:
return False
segments = _path_segments(path_text)
name = segments[-1] if segments else ""
directories = segments[:-1]
return (
any(
segment in {"test", "tests"}
or segment.endswith(".test")
or segment.endswith(".tests")
for segment in directories
)
or name.endswith("test.cs")
or name.endswith("tests.cs")
)
def _token(text: str) -> str:
return "".join(ch for ch in text.lower() if ch.isalnum())
def _candidate_test_paths(paths: list[str], cwd: Path, root: Path) -> list[Path]:
candidates: list[Path] = []
for path_text in paths:
resolved = _resolve_path(path_text, cwd)
if _is_test_path(str(resolved)) and not _is_ignored_path(str(resolved)):
candidates.append(resolved)
for candidate in root.rglob("*"):
if (
candidate.is_file()
and candidate.suffix.lower() in TEST_SUFFIXES
and _is_test_path(str(candidate))
and not _is_ignored_path(str(candidate))
):
candidates.append(candidate)
return candidates
def _has_related_test(path: Path, candidate_tests: list[Path]) -> bool:
source_token = _token(path.stem)
for test_path in candidate_tests:
test_token = _token(test_path.stem)
if source_token and source_token in test_token:
return True
return False
def _is_exempt(path_text: str) -> bool:
return _is_test_path(path_text)
def _resolve_path(path_text: str, cwd: Path) -> Path:
path = Path(path_text)
if path.is_absolute():
return path
return (cwd / path).resolve()
def _base_name(path: Path) -> str:
return path.stem
def _guarded_paths(paths: list[str], cwd: Path, root: Path) -> list[str]:
missing_tests: list[str] = []
candidate_tests = _candidate_test_paths(paths, cwd, root)
for path_text in paths:
if _is_exempt(path_text):
continue
path = _resolve_path(path_text, cwd)
if path.suffix.lower() not in SOURCE_SUFFIXES:
continue
if not _has_related_test(path, candidate_tests):
missing_tests.append(_base_name(path))
return missing_tests
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
cwd = Path(payload.get("cwd") or Path.cwd())
root = _repo_root(cwd)
missing_tests = _guarded_paths(_touched_paths(payload), cwd, root)
if not missing_tests:
return 0
names = ", ".join(sorted(set(missing_tests)))
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"TDD GUARD: missing C# test file for "
f"{names}. Write or add the test first."
),
}
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())