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