#!/usr/bin/env python3 """ Test Coverage Checker — 6.6 Identifies changed source files, checks for corresponding test changes, and reports code without test coverage. Usage: python3 scripts/test_coverage_checker.py python3 scripts/test_coverage_checker.py --format json python3 scripts/test_coverage_checker.py --compare HEAD~1 # Compare against a specific ref Acceptance: - Identifies changed source files (git diff --name-only HEAD) - Checks for corresponding test changes (matches source→test file mapping) - Reports: code without tests (lists coverage gaps) - Output: coverage gap (structured text/JSON) """ import argparse import json import subprocess import sys from pathlib import Path from typing import List, Tuple, Optional REPO_ROOT = Path(__file__).resolve().parent.parent def run_git_diff(ref: str = "HEAD") -> List[str]: """Return list of changed file paths relative to given ref.""" result = subprocess.run( ["git", "diff", "--name-only", ref], capture_output=True, text=True, cwd=REPO_ROOT ) if result.returncode != 0: print(f"ERROR: git diff failed: {result.stderr}") sys.exit(1) return [p for p in result.stdout.splitlines() if p.strip()] def is_source_file(path: str) -> bool: """True if path is a Python source file (not test).""" return path.endswith(".py") and not path.startswith("tests/") and "/test" not in Path(path).name def is_test_file(path: str) -> bool: """True if path is a test file.""" if not path.endswith(".py"): return False name = Path(path).name # Test files: test_*.py or *_test.py or in tests/ directory return (name.startswith("test_") or name.endswith("_test.py") or path.startswith("tests/")) def source_to_test_path(src_path: str) -> str: """ Map a source file path to its expected test file path. Convention: scripts/.py -> tests/test_.py .py -> tests/test_.py """ name = Path(src_path).name stem = Path(name).stem # without .py # Common mapping: script name -> test_ prefix in tests/ test_name = f"test_{stem}.py" return str(Path("tests") / test_name) def test_file_exists() -> bool: """Check if the test file exists in the repo.""" return (REPO_ROOT / test_rel).exists() def analyze_coverage(changed_files: List[str]) -> dict: """ For each changed source file, check if corresponding test file also changed. Returns structured coverage gap report. """ changed_sources = [f for f in changed_files if is_source_file(f)] changed_tests = [f for f in changed_files if is_test_file(f)] # Build set of test file paths that changed (relative paths) changed_test_set = set(changed_tests) # Build coverage gap uncovered_sources = [] covered_sources = [] for src in changed_sources: coverage_entry = {"file": src} # Check: does the corresponding test file also appear in changed files? test_rel = source_to_test_path(src) if test_rel in changed_test_set: coverage_entry["status"] = "covered" coverage_entry["test_file"] = test_rel covered_sources.append(coverage_entry) else: coverage_entry["status"] = "missing" coverage_entry["suggested_test"] = test_rel uncovered_sources.append(coverage_entry) return { "repo": REPO_ROOT.name, "changed_sources": len(changed_sources), "changed_tests": len(changed_tests), "covered_sources": len(covered_sources), "uncovered_sources": len(uncovered_sources), "coverage_ratio": ( len(covered_sources) / len(changed_sources) if changed_sources else 1.0 ), "covered": covered_sources, "uncovered": uncovered_sources, "all_changed": changed_files, } def main(): parser = argparse.ArgumentParser(description="Test Coverage Checker") parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format") parser.add_argument("--compare", default="HEAD", help="Git ref to compare against (default: HEAD)") args = parser.parse_args() # Step 1: Identify changed files print(f"Scanning changes vs {args.compare}...") changed_files = run_git_diff(args.compare) if not changed_files: print("No changed files detected.") sys.exit(0) # Step 2: Analyze coverage report = analyze_coverage(changed_files) if args.format == "json": print(json.dumps(report, indent=2)) sys.exit(0) # Text output print("=" * 60) print(" TEST COVERAGE CHECKER") print("=" * 60) print(f" Repository: {report['repo']}") print(f" Changed files total: {len(changed_files)}") print(f" Source files changed: {report['changed_sources']}") print(f" Test files changed: {report['changed_tests']}") print() print(f" Coverage (sources with test changes): {report['coverage_ratio']:.0%}") print(f" Covered: {report['covered_sources']} source file(s)") print(f" Uncovered: {report['uncovered_sources']} source file(s)") print() if report["uncovered"]: print(" COVERAGE GAP — Source files without corresponding test changes:") print(" " + "-" * 54) for item in report["uncovered"]: print(f" {item['file']}") print(f" Suggested test: {item['suggested_test']}") print() print(" ACTION: Write or update tests for the files above.") sys.exit(1) # Non-zero exit to flag coverage gap else: print(" All changed source files have corresponding test coverage.") print("=" * 60) if __name__ == "__main__": main()