bitchat-android/tools/coverage/check_changed_coverage.py

155 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base."""
from __future__ import annotations
import argparse
import pathlib
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
SOURCE_ROOTS = (
"app/src/main/java/",
"app/src/main/kotlin/",
)
@dataclass(frozen=True)
class CoverageLine:
missed_instructions: int
covered_instructions: int
@property
def covered(self) -> bool:
return self.covered_instructions > 0
def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]:
root = ET.parse(xml_path).getroot()
result: dict[tuple[str, int], CoverageLine] = {}
for package in root.findall("package"):
package_name = package.attrib["name"]
for source_file in package.findall("sourcefile"):
relative_path = f"{package_name}/{source_file.attrib['name']}"
for line in source_file.findall("line"):
result[(relative_path, int(line.attrib["nr"]))] = CoverageLine(
missed_instructions=int(line.attrib.get("mi", "0")),
covered_instructions=int(line.attrib.get("ci", "0")),
)
return result
def parse_changed_lines(diff_text: str) -> dict[str, set[int]]:
changed: dict[str, set[int]] = {}
current_path: str | None = None
for raw_line in diff_text.splitlines():
if raw_line.startswith("+++ b/"):
current_path = raw_line[6:]
continue
if raw_line.startswith("+++ /dev/null"):
current_path = None
continue
if not raw_line.startswith("@@") or current_path is None:
continue
match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line)
if match is None:
continue
start = int(match.group(1))
count = int(match.group(2) or "1")
if count > 0:
changed.setdefault(current_path, set()).update(range(start, start + count))
return changed
def jacoco_relative_path(repository_path: str) -> str | None:
for root in SOURCE_ROOTS:
if repository_path.startswith(root):
return repository_path[len(root) :]
return None
def git_diff_command(base: str) -> list[str]:
return [
"git",
"diff",
# Reformatting an executable line without changing its tokens must not turn an otherwise
# covered change set into a coverage failure.
"--ignore-all-space",
"--unified=0",
"--diff-filter=AM",
base,
"--",
*SOURCE_ROOTS,
]
def git_diff(base: str) -> str:
command = git_diff_command(base)
result = subprocess.run(command, check=True, capture_output=True, text=True)
return result.stdout
def evaluate(
changed: dict[str, set[int]],
coverage: dict[tuple[str, int], CoverageLine],
) -> tuple[int, int, list[str]]:
executable = 0
covered = 0
missed: list[str] = []
for repository_path, line_numbers in sorted(changed.items()):
source_path = jacoco_relative_path(repository_path)
if source_path is None:
continue
for line_number in sorted(line_numbers):
line = coverage.get((source_path, line_number))
if line is None:
continue
executable += 1
if line.covered:
covered += 1
else:
missed.append(f"{repository_path}:{line_number}")
return covered, executable, missed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--xml", type=pathlib.Path, required=True)
parser.add_argument("--base", required=True)
parser.add_argument("--threshold", type=float, default=0.80)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not 0.0 <= args.threshold <= 1.0:
raise SystemExit("--threshold must be between 0 and 1")
if not args.xml.is_file():
raise SystemExit(f"JaCoCo XML report not found: {args.xml}")
coverage = parse_jacoco(args.xml)
changed = parse_changed_lines(git_diff(args.base))
covered, executable, missed = evaluate(changed, coverage)
ratio = 1.0 if executable == 0 else covered / executable
print(
f"Changed executable line coverage: {covered}/{executable} "
f"({ratio:.1%}), required {args.threshold:.1%}"
)
if ratio >= args.threshold:
return 0
for location in missed[:50]:
print(f"UNCOVERED {location}")
if len(missed) > 50:
print(f"... and {len(missed) - 50} more uncovered executable lines")
return 1
if __name__ == "__main__":
sys.exit(main())