check_coverage.py
1 #!/usr/bin/env python3 2 """Enforce code coverage thresholds from LCOV reports. 3 4 Usage: 5 check_coverage.py coverage/lcov.info --threshold 95.0 6 check_coverage.py coverage/lcov.info --per-crate-config scripts/coverage-thresholds.toml 7 8 Exit codes: 9 0 - Coverage meets or exceeds threshold 10 1 - Coverage below threshold 11 2 - Error parsing coverage data 12 """ 13 import sys 14 import argparse 15 from pathlib import Path 16 from typing import Dict, Tuple, Optional 17 18 19 def parse_lcov(lcov_path: str) -> Tuple[int, int, Dict[str, Tuple[int, int]]]: 20 """Parse LCOV file and extract coverage statistics. 21 22 Returns: 23 (lines_hit, lines_found, per_file_coverage) 24 25 per_file_coverage maps file paths to (lines_hit, lines_found) tuples. 26 """ 27 total_lines_hit = 0 28 total_lines_found = 0 29 per_file = {} 30 31 current_file = None 32 file_lines_hit = 0 33 file_lines_found = 0 34 35 try: 36 with open(lcov_path) as f: 37 for line in f: 38 line = line.strip() 39 40 if line.startswith('SF:'): 41 # Start of a new file record 42 current_file = line[3:] 43 # Make path relative if absolute 44 if current_file.startswith('/'): 45 try: 46 current_file = str(Path(current_file).relative_to(Path.cwd())) 47 except ValueError: 48 pass # Keep as-is if not under cwd 49 file_lines_hit = 0 50 file_lines_found = 0 51 52 elif line.startswith('DA:'): 53 # Line data: DA:line_number,hit_count 54 parts = line[3:].split(',') 55 if len(parts) >= 2: 56 hits = int(parts[1]) 57 file_lines_found += 1 58 if hits > 0: 59 file_lines_hit += 1 60 61 elif line == 'end_of_record': 62 # End of current file record 63 if current_file and file_lines_found > 0: 64 per_file[current_file] = (file_lines_hit, file_lines_found) 65 total_lines_hit += file_lines_hit 66 total_lines_found += file_lines_found 67 current_file = None 68 file_lines_hit = 0 69 file_lines_found = 0 70 71 except Exception as e: 72 print(f"Error parsing LCOV file: {e}", file=sys.stderr) 73 sys.exit(2) 74 75 return total_lines_hit, total_lines_found, per_file 76 77 78 def calculate_coverage(lines_hit: int, lines_found: int) -> float: 79 """Calculate coverage percentage.""" 80 if lines_found == 0: 81 return 0.0 82 return (lines_hit / lines_found) * 100.0 83 84 85 def format_coverage(coverage: float) -> str: 86 """Format coverage percentage with 2 decimal places.""" 87 return f"{coverage:.2f}%" 88 89 90 def extract_crate_name(file_path: str) -> str: 91 """Extract crate name from file path. 92 93 Example: crates/acdc-core/src/lib.rs -> acdc-core 94 """ 95 parts = Path(file_path).parts 96 97 # Look for 'crates' directory 98 try: 99 crates_idx = parts.index('crates') 100 if crates_idx + 1 < len(parts): 101 return parts[crates_idx + 1] 102 except (ValueError, IndexError): 103 pass 104 105 return "unknown" 106 107 108 def group_by_crate(per_file: Dict[str, Tuple[int, int]]) -> Dict[str, Tuple[int, int]]: 109 """Group per-file coverage data by crate. 110 111 Returns: Dict[crate_name, (total_lines_hit, total_lines_found)] 112 """ 113 crates = {} 114 115 for file_path, (hit, found) in per_file.items(): 116 # Skip generated files in target/ 117 if file_path.startswith('target/'): 118 continue 119 120 crate_name = extract_crate_name(file_path) 121 if crate_name not in crates: 122 crates[crate_name] = (0, 0) 123 124 crate_hit, crate_found = crates[crate_name] 125 crates[crate_name] = (crate_hit + hit, crate_found + found) 126 127 return crates 128 129 130 def load_per_crate_config(config_path: Path) -> Dict[str, float]: 131 """Load per-crate threshold configuration from TOML file. 132 133 Returns: Dict[crate_name, threshold] 134 """ 135 try: 136 import tomllib 137 except ImportError: 138 try: 139 import tomli as tomllib 140 except ImportError: 141 print("Warning: No TOML library available (Python 3.11+ required)", file=sys.stderr) 142 print("Per-crate config requires Python 3.11+ or 'tomli' package", file=sys.stderr) 143 return {} 144 145 if not config_path.exists(): 146 print(f"Warning: Config file not found: {config_path}", file=sys.stderr) 147 return {} 148 149 try: 150 with open(config_path, 'rb') as f: 151 config = tomllib.load(f) 152 153 thresholds = {} 154 for crate_name, crate_config in config.get('crate', {}).items(): 155 if 'threshold' in crate_config: 156 thresholds[crate_name] = float(crate_config['threshold']) 157 158 return thresholds 159 except Exception as e: 160 print(f"Warning: Error loading per-crate config: {e}", file=sys.stderr) 161 return {} 162 163 164 def main(): 165 parser = argparse.ArgumentParser(description='Enforce code coverage thresholds') 166 parser.add_argument('lcov_file', help='Path to LCOV coverage file') 167 parser.add_argument('--threshold', type=float, default=95.0, 168 help='Minimum coverage threshold (default: 95.0)') 169 parser.add_argument('--show-files', action='store_true', 170 help='Show per-file coverage breakdown') 171 parser.add_argument('--show-crates', action='store_true', 172 help='Show per-crate coverage breakdown') 173 parser.add_argument('--per-crate-config', type=Path, 174 help='Path to per-crate threshold configuration (TOML format)') 175 176 args = parser.parse_args() 177 178 # Validate inputs 179 if not Path(args.lcov_file).exists(): 180 print(f"Error: Coverage file not found: {args.lcov_file}", file=sys.stderr) 181 sys.exit(2) 182 183 if args.threshold < 0 or args.threshold > 100: 184 print(f"Error: Threshold must be between 0 and 100 (got {args.threshold})", 185 file=sys.stderr) 186 sys.exit(2) 187 188 # Parse coverage data 189 lines_hit, lines_found, per_file = parse_lcov(args.lcov_file) 190 191 if lines_found == 0: 192 print("Warning: No coverage data found in LCOV file", file=sys.stderr) 193 print("Total coverage: 0.00%") 194 print(f"Threshold: {format_coverage(args.threshold)}") 195 print("\nStatus: FAIL - No coverage data") 196 sys.exit(1) 197 198 # Calculate total coverage 199 coverage = calculate_coverage(lines_hit, lines_found) 200 201 # Load per-crate config if specified 202 per_crate_thresholds = {} 203 if args.per_crate_config: 204 per_crate_thresholds = load_per_crate_config(args.per_crate_config) 205 if per_crate_thresholds: 206 print(f"Loaded per-crate thresholds for {len(per_crate_thresholds)} crates") 207 208 # Display results 209 print(f"Total coverage: {format_coverage(coverage)}") 210 print(f"Lines hit: {lines_hit}/{lines_found}") 211 print(f"Threshold: {format_coverage(args.threshold)}") 212 213 # Show per-crate breakdown if requested or if per-crate config is loaded 214 if args.show_crates or per_crate_thresholds: 215 print("\nPer-crate coverage:") 216 crates = group_by_crate(per_file) 217 218 crate_failures = [] 219 for crate_name in sorted(crates.keys()): 220 crate_hit, crate_found = crates[crate_name] 221 crate_coverage = calculate_coverage(crate_hit, crate_found) 222 223 crate_threshold = per_crate_thresholds.get(crate_name, args.threshold) 224 status = "✓" if crate_coverage >= crate_threshold else "✗" 225 226 if crate_coverage < crate_threshold: 227 crate_failures.append((crate_name, crate_coverage, crate_threshold)) 228 229 print(f" {status} {crate_name:30} {format_coverage(crate_coverage):>8} (threshold: {format_coverage(crate_threshold)})") 230 231 if crate_failures: 232 print(f"\n{len(crate_failures)} crate(s) below threshold:") 233 for crate_name, crate_cov, crate_thresh in crate_failures: 234 deficit = crate_thresh - crate_cov 235 print(f" - {crate_name}: {format_coverage(crate_cov)} < {format_coverage(crate_thresh)} (need +{format_coverage(deficit)})") 236 237 # Show per-file breakdown if requested 238 if args.show_files and per_file: 239 print("\nPer-file coverage:") 240 # Sort by coverage percentage (lowest first) to highlight problem areas 241 sorted_files = sorted( 242 per_file.items(), 243 key=lambda x: calculate_coverage(x[1][0], x[1][1]) 244 ) 245 for filepath, (hit, found) in sorted_files: 246 file_cov = calculate_coverage(hit, found) 247 print(f" {format_coverage(file_cov):>8} - {filepath} ({hit}/{found})") 248 249 # Enforce threshold 250 if coverage >= args.threshold: 251 print(f"\nStatus: PASS - Coverage meets threshold") 252 sys.exit(0) 253 else: 254 deficit = args.threshold - coverage 255 print(f"\nStatus: FAIL - Coverage below threshold by {format_coverage(deficit)}") 256 sys.exit(1) 257 258 259 if __name__ == '__main__': 260 main()