scripts: make strict coverage source-aware
This commit is contained in:
1 parent
ed5dc9b2a7
commit
f435e62f8b
3 files changed
+415
No files matched your search
@@ -0,0 +1,197 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user