Files
kaptan/scripts/summarize-test-coverage.py
T

198 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Summarize strict-core LCOV lines and LLVM JSON branches by source location.
`llvm-cov` emits one coverage record for every generic monomorphization. Its
per-file totals add those records, which can report an uncovered duplicate even
when the corresponding source line or branch has been exercised by another
monomorphization. This helper instead applies the gate to source locations:
* a source line is identified by its LCOV `DA` file and line number;
* a branch outcome is identified by its full source span; and
* a location is covered when any matching monomorphization executed it.
JSON segments are deliberately not used as a line denominator: they mark LLVM
instrumentation-region boundaries, not every source line represented by an
LCOV `DA` record. Replacing `DA` with segments would silently lower the strict
line denominator.
Macro-expansion records are deliberately rejected. Their source span denotes
the invocation, not necessarily the expanded predicate, so folding them into
the source-level key would make the gate unsound. Extend this helper with an
explicit expansion identity before adding macro-expanded strict-core coverage.
"""
from __future__ import annotations
import argparse
import json
from collections.abc import Iterable
from pathlib import Path
import sys
class CoverageFormatError(ValueError):
"""Coverage records cannot be safely summarized by source location."""
def selected_files(report: dict[object, object], source_path: str) -> Iterable[dict[object, object]]:
"""Yield selected file records after rejecting ambiguous macro expansion."""
exports = report.get("data")
if not isinstance(exports, list):
raise CoverageFormatError("LLVM JSON has no data array")
for export in exports:
if not isinstance(export, dict):
raise CoverageFormatError("LLVM JSON data array contains a non-object")
files = export.get("files")
if not isinstance(files, list):
raise CoverageFormatError("LLVM JSON export has no files array")
for file_record in files:
if not isinstance(file_record, dict):
raise CoverageFormatError("LLVM JSON files array contains a non-object")
filename = file_record.get("filename")
if not isinstance(filename, str) or source_path not in filename:
continue
expansions = file_record.get("expansions", [])
if not isinstance(expansions, list):
raise CoverageFormatError(f"{filename}: expansions is not an array")
if expansions:
raise CoverageFormatError(
f"{filename}: macro-expansion coverage is present; "
"refusing to merge it by invocation source span"
)
yield file_record
def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]]:
"""Return the union of selected LCOV `DA` lines and their source files."""
lines: dict[tuple[str, int], bool] = {}
files: set[str] = set()
filename: str | None = None
for record in report.splitlines():
if record.startswith("SF:"):
filename = record[3:]
if source_path in filename:
files.add(filename)
continue
if record == "end_of_record":
filename = None
continue
if filename is None or source_path not in filename or not record.startswith("DA:"):
continue
fields = record[3:].split(",", 2)
if len(fields) < 2:
raise CoverageFormatError(f"{filename}: malformed LCOV DA record")
try:
line = int(fields[0])
count = int(fields[1])
except ValueError as error:
raise CoverageFormatError(f"{filename}: non-integer LCOV DA record") from error
if line <= 0 or count < 0:
raise CoverageFormatError(f"{filename}: invalid LCOV DA location or count")
key = (filename, line)
lines[key] = lines.get(key, False) or count > 0
if not files:
raise CoverageFormatError(f"no LCOV source file contains {source_path!r}")
if not lines:
raise CoverageFormatError(f"no LCOV DA records found under {source_path!r}")
return sum(lines.values()), len(lines), files
def branch_totals(report: dict[object, object], source_path: str) -> tuple[int, int, set[str]]:
"""Return the union of selected JSON branch outcomes and source files."""
branches: dict[tuple[str, int, int, int, int], list[bool]] = {}
files: set[str] = set()
for file_record in selected_files(report, source_path):
filename = file_record["filename"]
assert isinstance(filename, str)
files.add(filename)
raw_branches = file_record.get("branches")
if not isinstance(raw_branches, list):
raise CoverageFormatError(f"{filename}: branches is not an array")
for branch in raw_branches:
if (
not isinstance(branch, list)
or len(branch) != 9
or not all(type(value) is int for value in branch)
):
raise CoverageFormatError(f"{filename}: invalid branch record")
(
start_line,
start_column,
end_line,
end_column,
true_count,
false_count,
file_id,
expanded_file_id,
_kind,
) = branch
if (
start_line <= 0
or start_column <= 0
or end_line < start_line
or end_column <= 0
or (end_line == start_line and end_column < start_column)
or true_count < 0
or false_count < 0
or file_id < 0
or expanded_file_id < 0
):
raise CoverageFormatError(f"{filename}: invalid branch location or count")
if file_id != expanded_file_id:
raise CoverageFormatError(
f"{filename}:{start_line}:{start_column}: macro-expanded branch is present; "
"refusing to merge it by invocation source span"
)
key = (filename, start_line, start_column, end_line, end_column)
outcomes = branches.setdefault(key, [False, False])
outcomes[0] = outcomes[0] or true_count > 0
outcomes[1] = outcomes[1] or false_count > 0
if not files:
raise CoverageFormatError(f"no LLVM JSON source file contains {source_path!r}")
branch_hit = sum(sum(outcomes) for outcomes in branches.values())
return branch_hit, 2 * len(branches), files
def coverage_totals(
lcov_report: str, json_report: dict[object, object], source_path: str
) -> tuple[int, int, int, int]:
"""Return strict source-level line and branch coverage for one core crate."""
lines_hit, lines_found, lcov_files = lcov_line_totals(lcov_report, source_path)
branch_hit, branches_found, json_files = branch_totals(json_report, source_path)
if lcov_files != json_files:
raise CoverageFormatError(
"LCOV/JSON source-file mismatch: "
f"LCOV={sorted(lcov_files)!r}, JSON={sorted(json_files)!r}"
)
return lines_hit, lines_found, branch_hit, branches_found
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("lcov_report", type=Path, help="LCOV report from cargo llvm-cov")
parser.add_argument("json_report", type=Path, help="LLVM JSON report from cargo llvm-cov")
parser.add_argument("source_path", help="workspace-relative path fragment to include")
arguments = parser.parse_args()
try:
lcov_report = arguments.lcov_report.read_text(encoding="utf-8")
with arguments.json_report.open(encoding="utf-8") as report_file:
json_report = json.load(report_file)
if not isinstance(json_report, dict):
raise CoverageFormatError("LLVM JSON root is not an object")
print(*coverage_totals(lcov_report, json_report, arguments.source_path))
except (CoverageFormatError, OSError, json.JSONDecodeError) as error:
print(f"coverage summary error: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())