SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hooktestingadvisorystable

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.