| |
| """ |
| Backend Coverage Quality Gate |
| |
| Implements progressive coverage thresholds (70% → 75% → 80%) for backend |
| with actual line coverage measurement from coverage.py JSON output. |
| |
| Integrates with emergency bypass mechanism for critical PRs while maintaining |
| audit trail and frequency monitoring. |
| |
| Usage: |
| # Normal mode: run pytest and enforce threshold |
| python backend_coverage_gate.py |
| |
| # With baseline comparison (requires 163-01 baseline) |
| python backend_coverage_gate.py --baseline backend/tests/coverage_reports/backend_163_baseline.json |
| |
| # Override threshold (emergency) |
| COVERAGE_THRESHOLD_OVERRIDE=70 python backend_coverage_gate.py |
| |
| # With emergency bypass justification |
| BYPASS_REASON=\"Security fix: Critical auth vulnerability\" python backend_coverage_gate.py |
| |
| Exit codes: |
| 0 = pass (coverage >= threshold) |
| 1 = fail (coverage < threshold) |
| 2 = error (invalid configuration, missing files, etc.) |
| |
| CI/CD Integration: |
| This script returns CI/CD-compatible exit codes for use in GitHub Actions, |
| GitLab CI, Jenkins, etc. |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import subprocess |
| import sys |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Dict, Optional, Tuple |
|
|
|
|
| |
| SCRIPT_DIR = Path(__file__).parent |
| PROJECT_ROOT = SCRIPT_DIR.parent.parent |
| COVERAGE_JSON_PATH = SCRIPT_DIR.parent / "coverage_reports" / "metrics" / "coverage.json" |
| BASELINE_PATH = SCRIPT_DIR.parent / "coverage_reports" / "backend_163_baseline.json" |
|
|
| |
| PROGRESSIVE_THRESHOLDS = { |
| "phase_1": 70.0, |
| "phase_2": 75.0, |
| "phase_3": 80.0, |
| } |
|
|
| |
| DEFAULT_PHASE = "phase_1" |
|
|
|
|
| def get_current_phase() -> str: |
| """Get current coverage phase from environment.""" |
| return os.getenv("COVERAGE_PHASE", DEFAULT_PHASE) |
|
|
|
|
| def get_threshold(phase: Optional[str] = None) -> float: |
| """Get coverage threshold for current phase.""" |
| if phase is None: |
| phase = get_current_phase() |
| return PROGRESSIVE_THRESHOLDS.get(phase, PROGRESSIVE_THRESHOLDS[DEFAULT_PHASE]) |
|
|
|
|
| def check_threshold_override() -> Optional[float]: |
| """Check for threshold override via environment variable.""" |
| override = os.getenv("COVERAGE_THRESHOLD_OVERRIDE") |
| if override: |
| try: |
| return float(override) |
| except ValueError: |
| print(f"⚠️ Invalid COVERAGE_THRESHOLD_OVERRIDE: {override} (must be numeric)") |
| return None |
| return None |
|
|
|
|
| def run_coverage_measurement() -> bool: |
| """ |
| Run pytest with coverage to generate coverage.json. |
| |
| Returns: |
| True if coverage measurement succeeded, False otherwise |
| """ |
| print("🔬 Running coverage measurement...") |
| print() |
|
|
| cmd = [ |
| sys.executable, "-m", "pytest", |
| "tests/", |
| "--cov=core", |
| "--cov=api", |
| "--cov=tools", |
| "--cov-branch", |
| "--cov-report=json:" + str(COVERAGE_JSON_PATH), |
| "--cov-report=term-missing:skip-covered", |
| "-q", |
| ] |
|
|
| print(f"Executing: {' '.join(cmd)}") |
| print() |
|
|
| try: |
| result = subprocess.run( |
| cmd, |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| timeout=600 |
| ) |
|
|
| |
| if result.stdout: |
| print(result.stdout) |
| if result.stderr: |
| print(result.stderr, file=sys.stderr) |
|
|
| if result.returncode != 0: |
| print(f"⚠️ Pytest exited with code {result.returncode} (continuing with coverage check)") |
| print() |
|
|
| return True |
|
|
| except subprocess.TimeoutExpired: |
| print("❌ ERROR: Coverage measurement timed out after 10 minutes") |
| return False |
| except FileNotFoundError: |
| print("❌ ERROR: pytest not found. Install with: pip install pytest pytest-cov") |
| return False |
| except Exception as e: |
| print(f"❌ ERROR: Coverage measurement failed: {e}") |
| return False |
|
|
|
|
| def parse_coverage_json(json_path: Path) -> Optional[Dict]: |
| """ |
| Parse coverage.json and extract overall metrics. |
| |
| Args: |
| json_path: Path to coverage.json |
| |
| Returns: |
| Dict with coverage metrics or None if parsing failed |
| """ |
| if not json_path.exists(): |
| print(f"❌ ERROR: Coverage file not found: {json_path}") |
| return None |
|
|
| try: |
| with open(json_path, 'r') as f: |
| coverage_data = json.load(f) |
| except json.JSONDecodeError as e: |
| print(f"❌ ERROR: Invalid JSON in {json_path}: {e}") |
| return None |
| except IOError as e: |
| print(f"❌ ERROR: Failed to read {json_path}: {e}") |
| return None |
|
|
| |
| if "totals" not in coverage_data: |
| print("❌ ERROR: coverage.json missing 'totals' section") |
| return None |
|
|
| totals = coverage_data["totals"] |
|
|
| |
| |
| if "num_statements" not in totals: |
| print("❌ ERROR: coverage.json missing line coverage metrics") |
| print(" Expected: totals.covered_lines and totals.num_statements") |
| print(" This indicates service-level aggregation, not actual line coverage") |
| return None |
|
|
| |
| if "covered_lines" not in totals and "line_covered" not in totals: |
| print("❌ ERROR: coverage.json missing line coverage metrics") |
| print(" Expected: totals.covered_lines or totals.line_covered") |
| print(" This indicates service-level aggregation, not actual line coverage") |
| return None |
|
|
| return coverage_data |
|
|
|
|
| def calculate_coverage_percentage(totals: Dict) -> float: |
| """ |
| Calculate actual line coverage percentage from coverage.py totals. |
| |
| Args: |
| totals: coverage.json['totals'] dict |
| |
| Returns: |
| Coverage percentage (covered_lines / num_statements * 100) |
| """ |
| |
| line_covered = totals.get("covered_lines") or totals.get("line_covered", 0) |
| num_statements = totals.get("num_statements", 1) |
|
|
| if num_statements == 0: |
| return 0.0 |
|
|
| return (line_covered / num_statements) * 100 |
|
|
|
|
| def compare_with_baseline(current_coverage: float, baseline_path: Path) -> Optional[Dict]: |
| """ |
| Compare current coverage with baseline. |
| |
| Args: |
| current_coverage: Current coverage percentage |
| baseline_path: Path to baseline JSON file |
| |
| Returns: |
| Dict with baseline comparison data or None if baseline not found |
| """ |
| if not baseline_path.exists(): |
| print(f"⚠️ Baseline not found: {baseline_path}") |
| print(" Run plan 163-01 to generate baseline: generate_baseline_coverage_report.py") |
| print() |
| return None |
|
|
| try: |
| with open(baseline_path, 'r') as f: |
| baseline_data = json.load(f) |
| except (json.JSONDecodeError, IOError) as e: |
| print(f"⚠️ Warning: Failed to read baseline: {e}") |
| return None |
|
|
| if "totals" not in baseline_data: |
| print("⚠️ Warning: Baseline missing 'totals' section") |
| return None |
|
|
| baseline_totals = baseline_data["totals"] |
| baseline_coverage = calculate_coverage_percentage(baseline_totals) |
|
|
| coverage_delta = current_coverage - baseline_coverage |
|
|
| return { |
| "baseline_coverage": baseline_coverage, |
| "current_coverage": current_coverage, |
| "delta": coverage_delta, |
| "delta_str": f"+{coverage_delta:.2f}%" if coverage_delta >= 0 else f"{coverage_delta:.2f}%" |
| } |
|
|
|
|
| def check_emergency_bypass(justification: str) -> bool: |
| """ |
| Check if emergency bypass is eligible with given justification. |
| |
| Args: |
| justification: Bypass justification (required, >= 20 chars) |
| |
| Returns: |
| True if bypass granted, False otherwise |
| """ |
| |
| try: |
| sys.path.insert(0, str(SCRIPT_DIR)) |
| import emergency_coverage_bypass |
|
|
| return emergency_coverage_bypass.check_bypass_eligibility(justification) |
| except ImportError as e: |
| print(f"❌ ERROR: Failed to import emergency_coverage_bypass: {e}") |
| return False |
| except Exception as e: |
| print(f"❌ ERROR: Emergency bypass check failed: {e}") |
| return False |
|
|
|
|
| def print_gate_summary( |
| coverage_percent: float, |
| threshold: float, |
| phase: str, |
| baseline_comparison: Optional[Dict] = None |
| ): |
| """Print coverage gate summary.""" |
| print("=" * 60) |
| print("BACKEND COVERAGE GATE") |
| print("=" * 60) |
| print() |
| print(f"Phase: {phase.upper()}") |
| print(f"Threshold: {threshold:.1f}%") |
| print(f"Coverage: {coverage_percent:.2f}%") |
| print() |
|
|
| if baseline_comparison: |
| print("Baseline Comparison:") |
| print(f" Baseline: {baseline_comparison['baseline_coverage']:.2f}%") |
| print(f" Current: {baseline_comparison['current_coverage']:.2f}%") |
| print(f" Delta: {baseline_comparison['delta_str']}") |
| print() |
|
|
| |
| gap = coverage_percent - threshold |
|
|
| if gap >= 0: |
| |
| if phase == "phase_1": |
| print(f"✅ PASS: Coverage meets {threshold:.1f}% minimum threshold") |
| elif phase == "phase_2": |
| print(f"✅ PASS: Coverage meets {threshold:.1f}% interim target") |
| else: |
| print(f"✅ PASS: Coverage meets {threshold:.1f}% final target") |
| else: |
| |
| print(f"❌ FAIL: Coverage {coverage_percent:.2f}% below {threshold:.1f}% threshold") |
| print(f" Gap: {abs(gap):.2f} percentage points") |
| print() |
|
|
| print() |
|
|
|
|
| def print_progressive_warning(coverage_percent: float, phase: str): |
| """Print progressive warnings for approaching thresholds.""" |
| if phase == "phase_1" and coverage_percent >= 75.0: |
| print("📈 INFO: Coverage approaching Phase 2 threshold (75%)") |
| print() |
| elif phase == "phase_2" and coverage_percent >= 80.0: |
| print("📈 INFO: Coverage approaching Phase 3 threshold (80%)") |
| print() |
|
|
|
|
| def main(): |
| """Main coverage gate logic.""" |
| parser = argparse.ArgumentParser( |
| description="Backend coverage quality gate with progressive thresholds" |
| ) |
|
|
| parser.add_argument( |
| "--phase", |
| type=str, |
| choices=["phase_1", "phase_2", "phase_3"], |
| default=None, |
| help="Override COVERAGE_PHASE environment variable" |
| ) |
|
|
| parser.add_argument( |
| "--baseline", |
| type=Path, |
| default=None, |
| help="Path to baseline JSON for comparison (default: backend/tests/coverage_reports/backend_163_baseline.json)" |
| ) |
|
|
| parser.add_argument( |
| "--threshold", |
| type=float, |
| default=None, |
| help="Override coverage threshold (overrides phase-based threshold)" |
| ) |
|
|
| parser.add_argument( |
| "--no-measure", |
| action="store_true", |
| help="Skip coverage measurement (use existing coverage.json)" |
| ) |
|
|
| parser.add_argument( |
| "--bypass-justification", |
| type=str, |
| default=None, |
| help="Emergency bypass justification (>= 20 characters required)" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| phase = args.phase if args.phase else get_current_phase() |
|
|
| |
| override_threshold = check_threshold_override() |
| if override_threshold is not None: |
| threshold = override_threshold |
| print(f"⚠️ THRESHOLD OVERRIDE: {threshold:.1f}% (via COVERAGE_THRESHOLD_OVERRIDE)") |
| print() |
| elif args.threshold: |
| threshold = args.threshold |
| print(f"⚠️ THRESHOLD OVERRIDE: {threshold:.1f}% (via CLI argument)") |
| print() |
| else: |
| threshold = get_threshold(phase) |
|
|
| |
| if not args.no_measure: |
| if not run_coverage_measurement(): |
| print("❌ ERROR: Coverage measurement failed") |
| return 2 |
|
|
| |
| coverage_data = parse_coverage_json(COVERAGE_JSON_PATH) |
| if coverage_data is None: |
| return 2 |
|
|
| |
| totals = coverage_data["totals"] |
| coverage_percent = calculate_coverage_percentage(totals) |
|
|
| |
| baseline_path = args.baseline if args.baseline else BASELINE_PATH |
| baseline_comparison = compare_with_baseline(coverage_percent, baseline_path) |
|
|
| |
| print_gate_summary(coverage_percent, threshold, phase, baseline_comparison) |
|
|
| |
| print_progressive_warning(coverage_percent, phase) |
|
|
| |
| if coverage_percent >= threshold: |
| |
| return 0 |
|
|
| |
| print("❌ COVERAGE BELOW THRESHOLD") |
| print() |
|
|
| |
| bypass_justification = args.bypass_justification or os.getenv("BYPASS_REASON") |
|
|
| if bypass_justification: |
| print("🚨 Emergency bypass requested...") |
| print() |
|
|
| if check_emergency_bypass(bypass_justification): |
| print() |
| print("⚠️ BYPASS GRANTED: Coverage gate bypassed via emergency justification") |
| print(" This bypass has been logged to the audit trail") |
| print() |
| return 0 |
| else: |
| print() |
| print("❌ BYPASS REJECTED: Justification insufficient or bypass not eligible") |
| print() |
| return 1 |
| else: |
| |
| print("Coverage gate enforcement active.") |
| print() |
| print("Options:") |
| print(" 1. Add tests to improve coverage above threshold") |
| print(f" 2. Use emergency bypass: BYPASS_REASON=\"<justification>\" {sys.argv[0]}") |
| print(" Justification must be >= 20 characters") |
| print(" Example: 'Security fix: Critical auth vulnerability in production'") |
| print() |
|
|
| return 1 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|