198 lines
8.2 KiB
Python
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())
|