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
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Per-workspace-crate line and branch coverage report.
|
||||||
|
#
|
||||||
|
# This is deliberately a report over every host-testable workspace package,
|
||||||
|
# not a misleading aggregate. STM32F7 platform/board images are target-only:
|
||||||
|
# their `panic-halt` handler cannot link into the host test harness, so they
|
||||||
|
# are reported as N/A and must be covered by target checks plus bench
|
||||||
|
# evidence instead.
|
||||||
|
#
|
||||||
|
# Prerequisites (kept out of the firmware dependency graph):
|
||||||
|
# cargo install cargo-llvm-cov --locked
|
||||||
|
# rustup toolchain install nightly --component llvm-tools-preview
|
||||||
|
# python3 (standard library only, for strict source-level accounting)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if ! cargo llvm-cov --version >/dev/null 2>&1; then
|
||||||
|
echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! rustup toolchain list | grep -q '^nightly'; then
|
||||||
|
echo "nightly with llvm-tools-preview is required for branch coverage" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 --version >/dev/null 2>&1; then
|
||||||
|
echo "python3 is required for strict source-level coverage accounting" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 scripts/test-summarize-test-coverage.py
|
||||||
|
|
||||||
|
# Keep instrumentation products outside a possibly shared target directory.
|
||||||
|
: "${CARGO_TARGET_DIR:=/tmp/kaptan-test-coverage}"
|
||||||
|
export CARGO_TARGET_DIR
|
||||||
|
mkdir -p "$CARGO_TARGET_DIR"
|
||||||
|
|
||||||
|
# `cargo llvm-cov` merges every instrumented object below `CARGO_TARGET_DIR`.
|
||||||
|
# A shared target directory therefore makes a later strict-core report include
|
||||||
|
# an unexecuted copy of a shared dependency built while testing an earlier
|
||||||
|
# package. Keep reports together but isolate each strict-core invocation. The
|
||||||
|
# non-core packages retain the shared directory: several intentionally
|
||||||
|
# target-only crates have no host-instrumented object to export by themselves.
|
||||||
|
coverage_root=$CARGO_TARGET_DIR
|
||||||
|
|
||||||
|
core_incomplete=0
|
||||||
|
|
||||||
|
report_package() {
|
||||||
|
package=$1
|
||||||
|
source_path=$2
|
||||||
|
shift 2
|
||||||
|
case "$package" in
|
||||||
|
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
|
||||||
|
package_target_dir="$coverage_root/$package"
|
||||||
|
lcov_report="$coverage_root/$package.lcov"
|
||||||
|
json_report="$coverage_root/$package.json"
|
||||||
|
report_format=strict-core
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
package_target_dir=$coverage_root
|
||||||
|
lcov_report="$coverage_root/$package.lcov"
|
||||||
|
report_format=lcov
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$report_format" in
|
||||||
|
strict-core)
|
||||||
|
# Keep the strict core isolated, then export both representations
|
||||||
|
# from the same test run. LCOV `DA` defines source lines; JSON
|
||||||
|
# coordinates define branch identities across generic instances.
|
||||||
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
||||||
|
--branch \
|
||||||
|
--no-report \
|
||||||
|
-p "$package" \
|
||||||
|
"$@"
|
||||||
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
||||||
|
--lcov \
|
||||||
|
--output-path "$lcov_report"
|
||||||
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
||||||
|
--json \
|
||||||
|
--output-path "$json_report"
|
||||||
|
totals=$(python3 scripts/summarize-test-coverage.py \
|
||||||
|
"$lcov_report" "$json_report" "$source_path")
|
||||||
|
;;
|
||||||
|
lcov)
|
||||||
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
||||||
|
--branch \
|
||||||
|
--lcov \
|
||||||
|
--output-path "$lcov_report" \
|
||||||
|
-p "$package" \
|
||||||
|
"$@"
|
||||||
|
totals=$(awk -v source_path="$source_path" '
|
||||||
|
/^SF:/ { selected = index($0, source_path) != 0 }
|
||||||
|
selected && /^LF:/ { lines_found += substr($0, 4) }
|
||||||
|
selected && /^LH:/ { lines_hit += substr($0, 4) }
|
||||||
|
selected && /^BRF:/ { branches_found += substr($0, 5) }
|
||||||
|
selected && /^BRH:/ { branches_hit += substr($0, 5) }
|
||||||
|
END {
|
||||||
|
printf "%d %d %d %d", lines_hit, lines_found, branches_hit, branches_found
|
||||||
|
}
|
||||||
|
' "$lcov_report")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
set -- $totals
|
||||||
|
printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4"
|
||||||
|
|
||||||
|
case "$package" in
|
||||||
|
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
|
||||||
|
if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then
|
||||||
|
core_incomplete=1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Host test coverage (reports: $CARGO_TARGET_DIR; strict core unions LCOV DA lines and JSON branches)"
|
||||||
|
report_package kaptan-actuator /libs/kaptan-actuator/
|
||||||
|
report_package kaptan-board /libs/kaptan-board/
|
||||||
|
report_package kaptan-control /libs/kaptan-control/
|
||||||
|
report_package kaptan-data-channel /libs/kaptan-data-channel/
|
||||||
|
report_package kaptan-dma /libs/kaptan-dma/
|
||||||
|
report_package kaptan-drivers /libs/kaptan-drivers/
|
||||||
|
report_package kaptan-i2c /libs/kaptan-i2c/
|
||||||
|
report_package kaptan-math /libs/kaptan-math/
|
||||||
|
report_package kaptan-mavlink /libs/kaptan-mavlink/ --features dialect-kaptan
|
||||||
|
report_package kaptan-navigation /libs/kaptan-navigation/ --features sitl
|
||||||
|
report_package kaptan-safety /libs/kaptan-safety/
|
||||||
|
report_package kaptan-sensor /libs/kaptan-sensor/
|
||||||
|
report_package kaptan-spi /libs/kaptan-spi/
|
||||||
|
report_package kaptan-time /libs/kaptan-time/
|
||||||
|
report_package kaptan-tx /libs/kaptan-tx/
|
||||||
|
report_package kaptan-sitl /tools/sitl/
|
||||||
|
|
||||||
|
echo "platform-stm32f7 target-only/N/A (run cargo check -p platform-stm32f7 --lib)"
|
||||||
|
echo "kaptan-geprc-f722-v2 target-only/N/A (run its thumbv7em-none-eabihf check)"
|
||||||
|
echo "kaptan-kakute-f7-mini-v3 target-only/N/A (run its thumbv7em-none-eabihf check)"
|
||||||
|
|
||||||
|
if [ "$core_incomplete" -ne 0 ]; then
|
||||||
|
echo "Strict core coverage is incomplete; reports above identify each crate." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Synthetic regression tests for strict source-level coverage accounting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
def load_summary_module():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"summarize_test_coverage", "scripts/summarize-test-coverage.py"
|
||||||
|
)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
SUMMARY = load_summary_module()
|
||||||
|
SOURCE = "/libs/kaptan-navigation/"
|
||||||
|
FILENAME = "/repo/libs/kaptan-navigation/src/eskf.rs"
|
||||||
|
|
||||||
|
|
||||||
|
def json_report(*, expansions=None, expanded_file_id=0):
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": FILENAME,
|
||||||
|
"expansions": [] if expansions is None else expansions,
|
||||||
|
# Segments intentionally differ from DA lines: strict
|
||||||
|
# line coverage must not lower its denominator to them.
|
||||||
|
"segments": [[10, 1, 3, True, True, False]],
|
||||||
|
"branches": [
|
||||||
|
[10, 5, 10, 10, 0, 3, 0, expanded_file_id, 4],
|
||||||
|
[10, 5, 10, 10, 2, 0, 0, expanded_file_id, 4],
|
||||||
|
[11, 5, 11, 10, 0, 0, 0, expanded_file_id, 4],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LCOV = """SF:/repo/libs/kaptan-navigation/src/eskf.rs
|
||||||
|
DA:10,0
|
||||||
|
DA:11,0
|
||||||
|
end_of_record
|
||||||
|
SF:/repo/libs/kaptan-navigation/src/eskf.rs
|
||||||
|
DA:10,3
|
||||||
|
DA:12,1
|
||||||
|
end_of_record
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageSummaryTests(unittest.TestCase):
|
||||||
|
def test_unions_da_lines_and_full_span_branch_outcomes(self):
|
||||||
|
self.assertEqual(
|
||||||
|
SUMMARY.coverage_totals(LCOV, json_report(), SOURCE),
|
||||||
|
# DA lines: 10 is covered by the second generic instance; 11 is
|
||||||
|
# uncovered; 12 is covered. Branch outcomes: the two duplicate
|
||||||
|
# source spans cover both outcomes, while line 11 covers neither.
|
||||||
|
(2, 3, 2, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_macro_expansion_records(self):
|
||||||
|
with self.assertRaises(SUMMARY.CoverageFormatError):
|
||||||
|
SUMMARY.coverage_totals(LCOV, json_report(expansions=[{}]), SOURCE)
|
||||||
|
with self.assertRaises(SUMMARY.CoverageFormatError):
|
||||||
|
SUMMARY.coverage_totals(LCOV, json_report(expanded_file_id=1), SOURCE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user