testsAndMisc/meta/scripts/shell_coverage_report.py
Krzysztof kuhy Rudnicki a174b2a3c4 meta: measurable shell coverage, and dns_iptables.sh at 100%
kcov works here once invoked correctly, so shell coverage is measurable
after all. shell_coverage.sh wraps it as a gate; the XML parsing lives in
shell_coverage_report.py per the rule against inlining another language's
logic in a shell script.

Two kcov traps are documented in the wrapper because both fail silently.
It must be handed the test script DIRECTLY: given `bash script.sh` it
instruments the bash binary, finds no shell source, and reports 0/0 at
0.00% -- indistinguishable from "nothing is covered". And per-line detail
exists only in cov.xml; coverage.json carries the percentage alone, so it
cannot say which lines are missing.

First measurement put dns_iptables.sh at 68/74 = 91.89%, with all six
uncovered lines in error and edge branches: chain creation failing, jump
insertion failing, the stale-jump de-dupe log, and the v6 trusted-resolver
ACCEPT. The stub grew failure injection and jump seeding to reach them,
and four new cases take it to 74/74 = 100.00%.

The gate fails closed: deleting the three ensure_chain failure cases drops
it to 93.24%, names lines 23, 24, 37, 40, 41, and exits 1.

Not wired into pre-commit -- kcov is absent on CI, and most shell files
here have no test at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 15:29:08 +02:00

68 lines
2.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Report line coverage for one shell file from a kcov output directory.
Reads kcov's cobertura-style ``cov.xml`` rather than its ``coverage.json``:
the JSON summary carries only a percentage, so it cannot say which lines are
missing, which is the only part that helps close a gap.
Exits 1 when coverage is below the requested minimum, so the caller can use
it as a gate.
"""
from __future__ import annotations
from pathlib import Path
import sys
from defusedxml import ElementTree
def _measure(out_dir: Path, subject: str) -> tuple[int, int, list[str]]:
"""Return (covered, total, uncovered line numbers) for *subject*.
kcov writes one report per instrumented process. A harness that stages
the subject into a temp dir produces several, so the entries are merged:
a line counts as covered if any process hit it.
"""
hits: dict[str, int] = {}
for xml_path in out_dir.rglob("cov.xml"):
for cls in ElementTree.parse(xml_path).iter("class"):
filename = cls.get("filename", "")
if Path(filename).name != subject:
continue
for line in cls.iter("line"):
number = line.get("number")
if number is None:
continue
hits[number] = hits.get(number, 0) + int(line.get("hits", "0"))
uncovered = sorted((n for n, h in hits.items() if h == 0), key=int)
return len(hits) - len(uncovered), len(hits), uncovered
def main() -> None:
"""Print the coverage summary and gate on the minimum percentage."""
out_dir, subject, minimum = Path(sys.argv[1]), sys.argv[2], float(sys.argv[3])
covered, total, uncovered = _measure(out_dir, subject)
if total == 0:
sys.exit(
f"{subject}: kcov instrumented no lines. It was probably invoked as "
f"`kcov ... bash <script>`; hand it the script directly instead."
)
percent = 100.0 * covered / total
# sys.stdout.write rather than print, matching extract_shell_functions.py:
# this is the tool's result, not debug output, and ruff's T201 autofix
# silently deletes bare print() calls.
sys.stdout.write(f"{subject}: {covered}/{total} lines = {percent:.2f}%\n")
if uncovered:
sys.stdout.write(f" uncovered: {', '.join(uncovered)}\n")
if percent < minimum:
sys.exit(f"coverage {percent:.2f}% is below the required {minimum:.2f}%")
if __name__ == "__main__":
main()