coverage-filter.py
1 #!/usr/bin/env python3 2 """Convert LCOV coverage data to compact uncovered-lines YAML. 3 4 Usage: 5 coverage-filter.py coverage/lcov.info > coverage/uncovered.yaml 6 7 Output format: 8 uncovered: 9 src/vm/executor.rs: [12, "88-102"] 10 src/consensus/block.rs: [45, 47, "89-93"] 11 """ 12 import sys 13 import yaml 14 from pathlib import Path 15 16 17 def parse_lcov(lcov_path: str) -> dict: 18 """Parse LCOV file and extract uncovered lines per file.""" 19 uncovered = {} 20 current_file = None 21 22 with open(lcov_path) as f: 23 for line in f: 24 line = line.strip() 25 if line.startswith('SF:'): 26 # Source file path 27 current_file = line[3:] 28 # Make path relative if absolute 29 if current_file.startswith('/'): 30 try: 31 current_file = str(Path(current_file).relative_to(Path.cwd())) 32 except ValueError: 33 pass # Keep as-is if not under cwd 34 elif line.startswith('DA:'): 35 # Line data: DA:line_number,hit_count 36 parts = line[3:].split(',') 37 if len(parts) >= 2: 38 linenum = int(parts[0]) 39 hits = int(parts[1]) 40 if hits == 0 and current_file: 41 uncovered.setdefault(current_file, []).append(linenum) 42 elif line == 'end_of_record': 43 current_file = None 44 45 # Compress consecutive line numbers into ranges 46 for filepath in uncovered: 47 uncovered[filepath] = compress_ranges(sorted(uncovered[filepath])) 48 49 return uncovered 50 51 52 def compress_ranges(nums: list) -> list: 53 """Compress consecutive numbers into ranges. 54 55 [1, 2, 3, 5, 7, 8, 9] -> [\"1-3\", 5, \"7-9\"] 56 """ 57 if not nums: 58 return [] 59 60 result = [] 61 start = end = nums[0] 62 63 for n in nums[1:]: 64 if n == end + 1: 65 end = n 66 else: 67 if start == end: 68 result.append(start) 69 else: 70 result.append(f"{start}-{end}") 71 start = end = n 72 73 # Add the last range/number 74 if start == end: 75 result.append(start) 76 else: 77 result.append(f"{start}-{end}") 78 79 return result 80 81 82 def main(): 83 if len(sys.argv) < 2: 84 print("Usage: coverage-filter.py <lcov-file>", file=sys.stderr) 85 sys.exit(1) 86 87 lcov_path = sys.argv[1] 88 89 if not Path(lcov_path).exists(): 90 print(f"Error: {lcov_path} not found", file=sys.stderr) 91 sys.exit(1) 92 93 uncovered = parse_lcov(lcov_path) 94 95 # Sort by file path for consistent output 96 sorted_uncovered = dict(sorted(uncovered.items())) 97 98 # Output compact YAML 99 output = {'uncovered': sorted_uncovered} 100 yaml.dump(output, sys.stdout, default_flow_style=False, sort_keys=False) 101 102 103 if __name__ == '__main__': 104 main()