/ scripts / check_coverage.py
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()