testsAndMisc/meta/scripts/shell_coverage_report.py
Krzysztof kuhy Rudnicki 29b8c5a788 Name the lines outside kcov's line set; bound defect (c) as fail-closed
The report printed a COUNT of traced lines that kcov never listed as
instrumentable. A count cannot be acted on: deciding whether such a line is a
real statement or a function declaration needs the line number. It now lists
them, the way it already lists uncovered lines.

Reading them across every lib with a harness splits them cleanly:

  benign  dwm_config.sh:27 (`heal_config() {`), transcribe_deps.sh:9
          (one-line function declaration), clean_audio_filters.sh:197,200
          (`((running++))`) -- correctly not statements
  real    aw_autostart.sh 68,70,73,79-81,83-84,87-88,94 -- plain echo, local,
          if, `sudo -u ... mkdir`, `cat >` heredoc. kcov lists nothing after
          the `{ ... } >>"$file"` command group at lines 61-65.

So kcov's LINE SET is incomplete too, not just its hit counts. This does NOT
inflate anything: a line reaches that list only because the TRACE SAW IT RUN,
so folding all 11 into both halves moves aw_autostart from 80/82 = 97.56% to
91/93 = 97.85% -- up. An off-set line is by construction an executed line, so
it can only ever be a missing COVERED line, never a missing uncovered one.

What it does mean is that such a subject is measured over a subset of its
statements. The percentage is honest about the subset, not the whole file,
which is why the lines are now printed rather than tallied.

Separately: nc_php.sh reports "kcov instrumented no lines" on the PRE-FIX
instrument too -- checked at 078d3463 in a throwaway worktree -- so it is not
a regression from the two-pass change. The 84/85 = 98.82% in earlier notes
does not reproduce and is recorded as unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:30:39 +02:00

152 lines
6.1 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.
kcov alone is not trustworthy (``docs/kcov-under-report.md``): it counts
continuation lines of multi-line quoted arguments as statements that never run
(wrong denominator), and it fails to record ordinary statements that provably
execute (wrong numerator). Both are corrected here -- the denominator by
excluding non-statements, the numerator by unioning in a PS4 xtrace captured
in a second pass.
Exits 1 when coverage is below the requested minimum, so the caller can use
it as a gate.
"""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import sys
from defusedxml import ElementTree
# Loaded by path rather than imported: meta/scripts/lib is not a package on
# sys.path, and a sys.path.insert would need the import to sit below it,
# which is an E402 the repo will not suppress.
_LINES_PATH = Path(__file__).resolve().parent / "lib" / "shell_coverage_lines.py"
_SPEC = spec_from_file_location("shell_coverage_lines", _LINES_PATH)
if _SPEC is None or _SPEC.loader is None: # pragma: no cover - unreachable
raise ImportError(_LINES_PATH)
_LINES = module_from_spec(_SPEC)
_SPEC.loader.exec_module(_LINES)
continuation_lines = _LINES.continuation_lines
traced_lines = _LINES.traced_lines
def _kcov_lines(out_dir: Path, subject: str) -> dict[int, int]:
"""Return kcov's {line: hits} 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[int, int] = {}
for xml_path in out_dir.rglob("cov.xml"):
for cls in ElementTree.parse(xml_path).iter("class"):
if Path(cls.get("filename", "")).name != subject:
continue
for line in cls.iter("line"):
number = line.get("number")
if number is None:
continue
key = int(number)
hits[key] = hits.get(key, 0) + int(line.get("hits", "0"))
return hits
def _find_source(subject: str, trace_dir: Path | None) -> Path | None:
"""Locate *subject*'s source file so its non-statements can be excluded.
kcov records `filename="dwm_config.sh"` -- a bare BASENAME, not a path --
so the XML cannot supply the source location. The PS4 trace can: it
carries ${BASH_SOURCE}, an absolute path, for every line it reports.
Falling back to a repo search keeps the exclusion working for a subject
whose trace is empty.
"""
if trace_dir is not None:
for candidate in _LINES.traced_paths(trace_dir, subject):
if candidate.is_file():
return candidate
matches = sorted(Path(__file__).resolve().parents[2].rglob(subject))
real = [m for m in matches if m.is_file() and ".git" not in m.parts]
# Exactly one match, or the exclusion would be applied from the wrong
# file. Ambiguity is reported by the caller as "no exclusions" rather
# than silently guessing, which could only inflate coverage.
return real[0] if len(real) == 1 else None
def _measure(
out_dir: Path, subject: str, trace_dir: Path | None
) -> tuple[int, int, list[str], list[str]]:
"""Return (covered, total, uncovered lines, lines outside kcov's line set)."""
hits = _kcov_lines(out_dir, subject)
if not hits:
return 0, 0, [], []
source = _find_source(subject, trace_dir)
excluded = continuation_lines(source) if source is not None else set()
denominator = set(hits) - excluded
executed = {line for line, count in hits.items() if count > 0}
traced: set[int] = set()
if trace_dir is not None:
traced = traced_lines(trace_dir, subject)
# Lines the trace saw executing that kcov never listed as instrumentable.
# Dropped from BOTH numerator and denominator, so if any of them is a real
# statement the percentage is an OVER-estimate -- the fail-open direction.
# They are listed individually, not counted, because deciding whether they
# are statements or non-statements needs the actual line numbers.
off_set = sorted(traced - set(hits) - excluded)
covered_set = (executed | traced) & denominator
uncovered = sorted(denominator - covered_set)
return (
len(covered_set),
len(denominator),
[str(n) for n in uncovered],
[str(n) for n in off_set],
)
# argv: <cov-dir> <subject> <minimum> [<trace-dir>]. The trace dir is optional
# so the report still runs against a kcov-only output directory.
_ARGV_WITH_TRACE = 5
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])
trace_dir = Path(sys.argv[4]) if len(sys.argv) >= _ARGV_WITH_TRACE else None
covered, total, uncovered, off_set = _measure(out_dir, subject, trace_dir)
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 off_set:
sys.stdout.write(
f" outside kcov's line set (ran, but counted in neither half): "
f"{', '.join(off_set)}\n"
)
if percent < minimum:
sys.exit(f"coverage {percent:.2f}% is below the required {minimum:.2f}%")
if __name__ == "__main__":
main()