{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/test-coverage-gate",
  "version": "1.0.0",
  "name": "Test Coverage Gate",
  "description": "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.",
  "event": "Stop",
  "events": [
    "Stop",
    "SubagentStop"
  ],
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": false,
  "side_effects": [
    "emits TEST-COVERAGE-VIOLATION sentinel lines to stdout and stderr",
    "writes violation record to ~/.ai/audit/violations/"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "testing",
    "tdd",
    "governance",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_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'.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/test-coverage-gate.py — detect source changes without corresponding test changes.\n\nFires on Stop and SubagentStop events. Inspects `git diff --name-only`\nbetween HEAD and the upstream base branch. If source files changed but\nno test files changed, emits TEST-COVERAGE-VIOLATION — non-blocking.\n\nTest file detection heuristics (covers the common conventions):\n  - Path segment is 'test', 'tests', '__tests__', 'spec', or 'specs'\n  - Filename contains 'test_', '_test.', '.test.', '.spec.', '_spec.'\n  - Filename ends with Test.kt / Test.java / Spec.rb / _test.go\n\nSource file detection: any code file (.py, .ts, .tsx, .js, .jsx, .go,\n  .rs, .java, .kt, .rb, .cs, .cpp, .c, .h, .swift, .sh, .bash) that\n  does NOT match the test heuristics above.\n\nSelf-check:\n  --self-check  exits 0 if git is reachable, 1 otherwise.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nimport sys\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\nSOURCE_EXTENSIONS = {\n    \".py\", \".ts\", \".tsx\", \".js\", \".jsx\", \".mjs\", \".cjs\",\n    \".go\", \".rs\", \".java\", \".kt\", \".rb\", \".cs\",\n    \".cpp\", \".cc\", \".cxx\", \".c\", \".h\", \".hpp\",\n    \".swift\", \".sh\", \".bash\",\n}\n\nTEST_PATH_SEGMENTS = {\"test\", \"tests\", \"__tests__\", \"spec\", \"specs\"}\n\nTEST_NAME_PATTERNS = (\n    \"test_\", \"_test.\", \".test.\", \".spec.\", \"_spec.\",\n    \"Test.kt\", \"Test.java\", \"Spec.rb\", \"_test.go\",\n)\n\n\ndef _ai_root() -> Path:\n    return Path(os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\")))\n\n\ndef _utc_timestamp() -> str:\n    return datetime.now(tz=timezone.utc).strftime(\"%Y-%m-%dT%H%M%S-%fZ\")\n\n\ndef _git_root(cwd: Path) -> Path | None:\n    try:\n        r = subprocess.run(\n            [\"git\", \"rev-parse\", \"--show-toplevel\"],\n            capture_output=True, text=True, check=False, cwd=str(cwd),\n        )\n        if r.returncode == 0:\n            return Path(r.stdout.strip())\n    except FileNotFoundError:\n        pass\n    return None\n\n\ndef _base_ref(repo: Path) -> str:\n    \"\"\"Return the best available base ref to diff against.\n\n    Priority: origin/main, origin/master, HEAD~1 (no remote).\n    \"\"\"\n    for ref in (\"origin/main\", \"origin/master\"):\n        r = subprocess.run(\n            [\"git\", \"rev-parse\", \"--verify\", ref],\n            capture_output=True, check=False, cwd=str(repo),\n        )\n        if r.returncode == 0:\n            return ref\n    return \"HEAD~1\"\n\n\ndef _changed_files(repo: Path, base: str) -> list[str]:\n    \"\"\"Return list of files changed between base and HEAD.\"\"\"\n    r = subprocess.run(\n        [\"git\", \"diff\", \"--name-only\", base, \"HEAD\"],\n        capture_output=True, text=True, check=False, cwd=str(repo),\n    )\n    if r.returncode != 0:\n        return []\n    return [f.strip() for f in r.stdout.splitlines() if f.strip()]\n\n\ndef is_test_file(path: str) -> bool:\n    \"\"\"Return True if path looks like a test/spec file.\"\"\"\n    p = Path(path)\n    # Check path segments\n    if any(seg.lower() in TEST_PATH_SEGMENTS for seg in p.parts):\n        return True\n    # Check filename patterns\n    name = p.name\n    return any(pat in name for pat in TEST_NAME_PATTERNS)\n\n\ndef is_source_file(path: str) -> bool:\n    \"\"\"Return True if path is a tracked source file (non-test code).\"\"\"\n    p = Path(path)\n    if p.suffix.lower() not in SOURCE_EXTENSIONS:\n        return False\n    return not is_test_file(path)\n\n\ndef write_violation_record(repo: Path, source_files: list[str]) -> None:\n    vdir = _ai_root() / \"audit\" / \"violations\"\n    try:\n        vdir.mkdir(parents=True, exist_ok=True)\n        ts = _utc_timestamp()\n        fpath = vdir / f\"{ts}-test-coverage-gate.md\"\n        listed = \"\\n\".join(f\"  - {f}\" for f in source_files[:20])\n        if len(source_files) > 20:\n            listed += f\"\\n  ... and {len(source_files) - 20} more\"\n        content = (\n            f\"# Violation — {ts}\\n\\n\"\n            f\"- **Repo:** {repo}\\n\"\n            f\"- **What happened:** {len(source_files)} source file(s) changed with no corresponding test file changes.\\n\"\n            f\"- **Changed source files:**\\n{listed}\\n\"\n            f\"- **Remediation:** Add or update tests before marking this work done.\\n\"\n        )\n        fpath.write_text(content, encoding=\"utf-8\")\n    except Exception as e:  # noqa: BLE001\n        _lib.log(f\"warning: could not write violation record: {e}\")\n\n\ndef check_repo(cwd: Path) -> list[str]:\n    \"\"\"Return violation lines for the repo at cwd, or empty list if clean.\"\"\"\n    repo = _git_root(cwd)\n    if repo is None:\n        return []\n    base = _base_ref(repo)\n    changed = _changed_files(repo, base)\n    if not changed:\n        return []\n\n    source_files = [f for f in changed if is_source_file(f)]\n    test_files = [f for f in changed if is_test_file(f)]\n\n    if source_files and not test_files:\n        write_violation_record(repo, source_files)\n        summary = \", \".join(source_files[:3])\n        if len(source_files) > 3:\n            summary += f\" (+{len(source_files) - 3} more)\"\n        return [\n            f\"TEST-COVERAGE-VIOLATION: {len(source_files)} source file(s) changed with no test changes in {repo}. Changed: {summary}\"\n        ]\n    return []\n\n\ndef main() -> None:\n    if \"--self-check\" in sys.argv:\n        try:\n            subprocess.run([\"git\", \"--version\"], capture_output=True, check=True)\n        except (FileNotFoundError, subprocess.CalledProcessError) as e:\n            _lib.log(f\"self-check FAIL: {e}\")\n            sys.exit(1)\n        _lib.log(\"self-check OK\")\n        sys.exit(0)\n\n    try:\n        event = json.load(sys.stdin)\n    except (json.JSONDecodeError, EOFError):\n        event = {}\n\n    raw_cwd = (\n        event.get(\"cwd\")\n        or event.get(\"workingDirectory\")\n        or os.getcwd()\n    )\n    cwd = Path(raw_cwd)\n\n    violations = check_repo(cwd)\n\n    if violations:\n        for v in violations:\n            _lib.log(v)\n            print(v, flush=True)\n    else:\n        _lib.log(\"test-coverage-gate: no violation\")\n\n\nif __name__ == \"__main__\":\n    main()\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "testing"
}