TradingAgents/.claude/hooks/auto_enforce_coverage.py

416 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Auto-enforce 100% test coverage by generating missing tests.
This hook maintains comprehensive test coverage by:
1. Running coverage analysis before commit
2. Identifying uncovered lines of code
3. Invoking test-master agent to generate coverage tests
4. Blocking commit if coverage < 80% threshold
5. Auto-generating tests to fill coverage gaps
Hook: PreCommit (runs before git commit completes)
Purpose:
- Prevent coverage from dropping below 80%
- Auto-generate tests for uncovered code
- Maintain comprehensive test suite without manual effort
- Ensure all code paths are tested
Usage:
Triggered automatically before git commit
Can be run manually: python scripts/hooks/auto_enforce_coverage.py
"""
import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Tuple
# ============================================================================
# Configuration
# ============================================================================
PROJECT_ROOT = Path(__file__).parent.parent.parent
SRC_DIR = PROJECT_ROOT / "src" / "[project_name]"
TESTS_DIR = PROJECT_ROOT / "tests"
COVERAGE_DIR = PROJECT_ROOT / "htmlcov"
COVERAGE_JSON = PROJECT_ROOT / "coverage.json"
# Coverage threshold (block commit if below this)
COVERAGE_THRESHOLD = 80.0
# Maximum number of iterations to try improving coverage
MAX_COVERAGE_ITERATIONS = 3
# ============================================================================
# Helper Functions
# ============================================================================
def run_coverage_analysis() -> Tuple[bool, Dict]:
"""
Run pytest with coverage and return results.
Returns:
(success, coverage_data) tuple
coverage_data contains coverage metrics from coverage.json
"""
print(" Running coverage analysis...")
try:
# Run pytest with coverage
result = subprocess.run(
[
"python",
"-m",
"pytest",
"tests/",
f"--cov={SRC_DIR}",
"--cov-report=json",
"--cov-report=term-missing",
"--cov-report=html",
"-q", # Quiet mode
],
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
# Read coverage.json
if not COVERAGE_JSON.exists():
return (False, {"error": "coverage.json not created"})
with open(COVERAGE_JSON) as f:
coverage_data = json.load(f)
return (True, coverage_data)
except subprocess.TimeoutExpired:
return (False, {"error": "Coverage analysis timed out after 5 minutes"})
except Exception as e:
return (False, {"error": f"Coverage analysis failed: {e}"})
def get_coverage_summary(coverage_data: Dict) -> Dict:
"""Extract summary metrics from coverage data."""
totals = coverage_data.get("totals", {})
return {
"percent_covered": totals.get("percent_covered", 0.0),
"num_statements": totals.get("num_statements", 0),
"covered_lines": totals.get("covered_lines", 0),
"missing_lines": totals.get("missing_lines", 0),
"excluded_lines": totals.get("excluded_lines", 0),
}
def find_uncovered_code(coverage_data: Dict) -> List[Dict]:
"""
Find all uncovered lines in source code.
Returns list of dicts with:
- file: file path
- missing_lines: list of uncovered line numbers
- coverage_pct: coverage percentage for this file
- priority: priority score (more missing lines = higher priority)
"""
uncovered = []
files = coverage_data.get("files", {})
for file_path, file_data in files.items():
# Only process source files (not tests)
if not file_path.startswith("src/"):
continue
missing_lines = file_data.get("missing_lines", [])
if missing_lines:
summary = file_data.get("summary", {})
coverage_pct = summary.get("percent_covered", 0.0)
uncovered.append(
{
"file": file_path,
"missing_lines": missing_lines,
"coverage_pct": coverage_pct,
"num_missing": len(missing_lines),
"priority": len(missing_lines)
* (100 - coverage_pct), # More missing + lower % = higher priority
}
)
# Sort by priority (highest first)
uncovered.sort(key=lambda x: x["priority"], reverse=True)
return uncovered
def extract_uncovered_code(file_path: str, missing_lines: List[int]) -> str:
"""Extract the actual uncovered code from source file."""
try:
with open(file_path) as f:
lines = f.readlines()
# Extract context around uncovered lines (±2 lines)
code_blocks = []
for line_num in missing_lines:
if 1 <= line_num <= len(lines):
start = max(1, line_num - 2)
end = min(len(lines), line_num + 2)
block = "".join(
[
f"{'' if i+1 == line_num else ' '} {i+1:4d}: {lines[i]}"
for i in range(start - 1, end)
]
)
code_blocks.append(block)
return "\n\n".join(code_blocks)
except Exception as e:
return f"Error reading file: {e}"
def create_coverage_test_prompt(uncovered_item: Dict) -> str:
"""Create prompt for test-master to generate coverage tests."""
file_path = uncovered_item["file"]
missing_lines = uncovered_item["missing_lines"]
coverage_pct = uncovered_item["coverage_pct"]
# Extract uncovered code
uncovered_code = extract_uncovered_code(file_path, missing_lines)
# Get module name for test file
module_path = Path(file_path)
module_name = module_path.stem
# Determine test file path
test_file = TESTS_DIR / "unit" / f"test_{module_name}_coverage.py"
return f"""You are test-master agent. Generate tests to cover uncovered code.
**Coverage Gap Detected**:
File: {file_path}
Current coverage: {coverage_pct:.1f}%
Uncovered lines: {missing_lines}
Number of gaps: {len(missing_lines)}
**Uncovered Code**:
```python
{uncovered_code}
```
**Instructions**:
1. Generate tests that execute these specific code paths
2. Focus on the lines marked with → (uncovered)
3. Write tests to: {test_file}
4. Use proper pytest patterns:
- Mock external dependencies
- Test edge cases that trigger these code paths
- Use parametrize for multiple scenarios if needed
5. Each test should:
- Have clear docstring explaining WHAT it covers
- Execute at least one of the uncovered lines
- Use proper assertions
6. Common reasons for uncovered code:
- Exception handlers (test error conditions)
- Edge cases (test boundary conditions)
- Error paths (test invalid inputs)
- Conditional branches (test both True and False)
**Generate comprehensive coverage tests now**.
"""
def invoke_test_master_for_coverage(uncovered_items: List[Dict]) -> Dict:
"""
Invoke test-master agent to generate coverage tests.
In production, Claude Code would invoke via Task tool.
For now, creates marker for manual invocation.
"""
# Take top 5 highest priority gaps
top_gaps = uncovered_items[:5]
print(f"\n 🤖 Generating coverage tests for {len(top_gaps)} files...")
# Create prompts for each gap
prompts = []
for item in top_gaps:
prompt = create_coverage_test_prompt(item)
prompts.append(
{
"file": item["file"],
"missing_lines": item["missing_lines"],
"prompt": prompt,
}
)
# Save prompts for agent invocation
marker_file = PROJECT_ROOT / ".coverage_test_generation.json"
marker_file.write_text(json.dumps({"prompts": prompts}, indent=2))
print(f" 📝 Coverage test prompts saved to: {marker_file}")
print(f" Claude Code will invoke test-master automatically")
# In production, would invoke agent here:
# for item in prompts:
# result = Task(
# subagent_type="test-master",
# prompt=item["prompt"],
# description=f"Generate coverage tests for {item['file']}"
# )
return {"success": False, "prompts_saved": str(marker_file), "num_prompts": len(prompts)}
def display_coverage_report(summary: Dict, uncovered: List[Dict]):
"""Display coverage report to user."""
total_pct = summary["percent_covered"]
num_statements = summary["num_statements"]
covered = summary["covered_lines"]
missing = summary["missing_lines"]
print(f"\n📊 Coverage Report")
print(f" Total Coverage: {total_pct:.1f}%")
print(f" Statements: {num_statements}")
print(f" Covered: {covered}")
print(f" Missing: {missing}")
if total_pct >= COVERAGE_THRESHOLD:
print(f" ✅ Above threshold ({COVERAGE_THRESHOLD}%)")
else:
print(f" ❌ Below threshold ({COVERAGE_THRESHOLD}%)")
print(f" Gap: {COVERAGE_THRESHOLD - total_pct:.1f}%")
if uncovered:
print(f"\n📋 Files with Coverage Gaps ({len(uncovered)} files):")
for i, item in enumerate(uncovered[:10], 1): # Show top 10
print(
f" {i}. {Path(item['file']).name}: "
f"{item['coverage_pct']:.1f}% "
f"({item['num_missing']} lines missing)"
)
if len(uncovered) > 10:
print(f" ... and {len(uncovered) - 10} more files")
# ============================================================================
# Main Logic
# ============================================================================
def main():
"""Main coverage enforcement logic."""
print(f"\n🔍 Auto-Coverage Enforcement Hook")
print(f" Threshold: {COVERAGE_THRESHOLD}%")
# Run coverage analysis
success, coverage_data = run_coverage_analysis()
if not success:
print(f"\n ❌ Coverage analysis failed!")
print(f" Error: {coverage_data.get('error', 'Unknown error')}")
print(f"\n ⚠️ Cannot enforce coverage without analysis")
print(f" Allowing commit to proceed (fix coverage manually)")
sys.exit(0) # Don't block commit on analysis failure
# Get coverage summary
summary = get_coverage_summary(coverage_data)
uncovered = find_uncovered_code(coverage_data)
# Display report
display_coverage_report(summary, uncovered)
total_coverage = summary["percent_covered"]
# Check if coverage meets threshold
if total_coverage >= COVERAGE_THRESHOLD:
print(f"\n✅ Coverage check PASSED: {total_coverage:.1f}%")
print(f" All code adequately tested")
sys.exit(0)
# Coverage below threshold - try to auto-fix
print(f"\n⚠️ Coverage BELOW threshold!")
print(f" Current: {total_coverage:.1f}%")
print(f" Required: {COVERAGE_THRESHOLD}%")
print(f" Gap: {COVERAGE_THRESHOLD - total_coverage:.1f}%")
if not uncovered:
print(f"\n No uncovered code found (might be excluded lines)")
print(f" Allowing commit to proceed")
sys.exit(0)
# Auto-generate coverage tests
print(f"\n🤖 Auto-generating tests to improve coverage...")
print(f" Found {len(uncovered)} files with coverage gaps")
result = invoke_test_master_for_coverage(uncovered)
if result.get("success"):
# Agent successfully generated tests
print(f"\n ✅ test-master generated coverage tests")
# Re-run coverage to see improvement
print(f"\n🧪 Re-running coverage with new tests...")
success, new_coverage_data = run_coverage_analysis()
if success:
new_summary = get_coverage_summary(new_coverage_data)
new_coverage = new_summary["percent_covered"]
print(f"\n Coverage improved: {total_coverage:.1f}% → {new_coverage:.1f}%")
if new_coverage >= COVERAGE_THRESHOLD:
print(f" ✅ Now above threshold!")
sys.exit(0)
else:
print(f" ⚠️ Still below threshold")
print(f" Gap remaining: {COVERAGE_THRESHOLD - new_coverage:.1f}%")
else:
# Agent invocation is placeholder
print(f"\n Coverage test generation prompts created")
print(f" Saved to: {result.get('prompts_saved')}")
print(f" Prompts: {result.get('num_prompts')}")
# Coverage still insufficient - provide guidance
print(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(f"❌ COVERAGE BELOW THRESHOLD")
print(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(f"\nCurrent: {total_coverage:.1f}% | Required: {COVERAGE_THRESHOLD}%")
print(f"\n📝 Next Steps:")
print(f" 1. Review coverage report: open htmlcov/index.html")
print(f" 2. Focus on high-priority files (shown above)")
print(f" 3. test-master can generate coverage tests automatically")
print(f" 4. Or write tests manually for uncovered code")
print(f"\n💡 Tip: Run 'pytest --cov=src/[project_name] --cov-report=html'")
print(f" Then open htmlcov/index.html to see which lines need tests")
# Decision: Block commit or allow with warning?
# For now, warn but allow (can be changed to exit(1) to block)
print(f"\n⚠️ Allowing commit with coverage warning")
print(f" (Change to exit(1) in production to block commits)")
sys.exit(0) # Change to sys.exit(1) to block commits below threshold
if __name__ == "__main__":
main()