Test Coverage Gate
Fires on Stop/SubagentStop. Compares changed source files on the current branch against changed test files. If code files changed but no corresponding test files changed, emits a TEST-COVERAGE-VIOLATION sentinel — non-blocking.
id hook/test-coverage-gatev1.0.0by convergent-systems-key
- Event
Stop- Trigger
always- Language
python- Side effects
- emits TEST-COVERAGE-VIOLATION sentinel lines to stdout and stderr
- writes violation record to ~/.ai/audit/violations/
- Platforms
linuxmacoswindows- Notes
- Requires git. Uses git diff against origin/main (or the configured base branch). Falls back to HEAD~1 when no upstream is present. Cross-platform via 'ai hooks run'.
- Depends on
- hook/lib
Script · test-coverage-gate.py
#!/usr/bin/env python3
"""hooks/test-coverage-gate.py — detect source changes without corresponding test changes.
Fires on Stop and SubagentStop events. Inspects `git diff --name-only`
between HEAD and the upstream base branch. If source files changed but
no test files changed, emits TEST-COVERAGE-VIOLATION — non-blocking.
Test file detection heuristics (covers the common conventions):
- Path segment is 'test', 'tests', '__tests__', 'spec', or 'specs'
- Filename contains 'test_', '_test.', '.test.', '.spec.', '_spec.'
- Filename ends with Test.kt / Test.java / Spec.rb / _test.go
Source file detection: any code file (.py, .ts, .tsx, .js, .jsx, .go,
.rs, .java, .kt, .rb, .cs, .cpp, .c, .h, .swift, .sh, .bash) that
does NOT match the test heuristics above.
Self-check:
--self-check exits 0 if git is reachable, 1 otherwise.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib # noqa: E402
SOURCE_EXTENSIONS = {
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".rb", ".cs",
".cpp", ".cc", ".cxx", ".c", ".h", ".hpp",
".swift", ".sh", ".bash",
}
TEST_PATH_SEGMENTS = {"test", "tests", "__tests__", "spec", "specs"}
TEST_NAME_PATTERNS = (
"test_", "_test.", ".test.", ".spec.", "_spec.",
"Test.kt", "Test.java", "Spec.rb", "_test.go",
)
def _ai_root() -> Path:
return Path(os.environ.get("AI_ROOT", str(Path.home() / ".ai")))
def _utc_timestamp() -> str:
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H%M%S-%fZ")
def _git_root(cwd: Path) -> Path | None:
try:
r = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=False, cwd=str(cwd),
)
if r.returncode == 0:
return Path(r.stdout.strip())
except FileNotFoundError:
pass
return None
def _base_ref(repo: Path) -> str:
"""Return the best available base ref to diff against.
Priority: origin/main, origin/master, HEAD~1 (no remote).
"""
for ref in ("origin/main", "origin/master"):
r = subprocess.run(
["git", "rev-parse", "--verify", ref],
capture_output=True, check=False, cwd=str(repo),
)
if r.returncode == 0:
return ref
return "HEAD~1"
def _changed_files(repo: Path, base: str) -> list[str]:
"""Return list of files changed between base and HEAD."""
r = subprocess.run(
["git", "diff", "--name-only", base, "HEAD"],
capture_output=True, text=True, check=False, cwd=str(repo),
)
if r.returncode != 0:
return []
return [f.strip() for f in r.stdout.splitlines() if f.strip()]
def is_test_file(path: str) -> bool:
"""Return True if path looks like a test/spec file."""
p = Path(path)
# Check path segments
if any(seg.lower() in TEST_PATH_SEGMENTS for seg in p.parts):
return True
# Check filename patterns
name = p.name
return any(pat in name for pat in TEST_NAME_PATTERNS)
def is_source_file(path: str) -> bool:
"""Return True if path is a tracked source file (non-test code)."""
p = Path(path)
if p.suffix.lower() not in SOURCE_EXTENSIONS:
return False
return not is_test_file(path)
def write_violation_record(repo: Path, source_files: list[str]) -> None:
vdir = _ai_root() / "audit" / "violations"
try:
vdir.mkdir(parents=True, exist_ok=True)
ts = _utc_timestamp()
fpath = vdir / f"{ts}-test-coverage-gate.md"
listed = "\n".join(f" - {f}" for f in source_files[:20])
if len(source_files) > 20:
listed += f"\n ... and {len(source_files) - 20} more"
content = (
f"# Violation — {ts}\n\n"
f"- **Repo:** {repo}\n"
f"- **What happened:** {len(source_files)} source file(s) changed with no corresponding test file changes.\n"
f"- **Changed source files:**\n{listed}\n"
f"- **Remediation:** Add or update tests before marking this work done.\n"
)
fpath.write_text(content, encoding="utf-8")
except Exception as e: # noqa: BLE001
_lib.log(f"warning: could not write violation record: {e}")
def check_repo(cwd: Path) -> list[str]:
"""Return violation lines for the repo at cwd, or empty list if clean."""
repo = _git_root(cwd)
if repo is None:
return []
base = _base_ref(repo)
changed = _changed_files(repo, base)
if not changed:
return []
source_files = [f for f in changed if is_source_file(f)]
test_files = [f for f in changed if is_test_file(f)]
if source_files and not test_files:
write_violation_record(repo, source_files)
summary = ", ".join(source_files[:3])
if len(source_files) > 3:
summary += f" (+{len(source_files) - 3} more)"
return [
f"TEST-COVERAGE-VIOLATION: {len(source_files)} source file(s) changed with no test changes in {repo}. Changed: {summary}"
]
return []
def main() -> None:
if "--self-check" in sys.argv:
try:
subprocess.run(["git", "--version"], capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
_lib.log(f"self-check FAIL: {e}")
sys.exit(1)
_lib.log("self-check OK")
sys.exit(0)
try:
event = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
event = {}
raw_cwd = (
event.get("cwd")
or event.get("workingDirectory")
or os.getcwd()
)
cwd = Path(raw_cwd)
violations = check_repo(cwd)
if violations:
for v in violations:
_lib.log(v)
print(v, flush=True)
else:
_lib.log("test-coverage-gate: no violation")
if __name__ == "__main__":
main()
testingtddgovernanceclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.