testsAndMisc/meta/scripts/lib/shell_coverage_lines.py
Krzysztof kuhy Rudnicki 81191f40e6 Fix both kcov defects: dwm_config 47.95% -> 98.48%, rpi_nc_install 11.36% -> 96.59%
The instrument, not the tests, was the bottleneck. Coverage is now
(PS4-traced lines | kcov hits) & (kcov line set - continuation lines).

The trace deliberately does NOT supply the denominator. A trace reports only
lines that RAN, so using it for both halves would make every subject 100.00%
and arm a repo-wide gate that checks nothing.

Three measured findings, each of which yields a silently wrong trace:

1. kcov's ptrace and bash's xtrace cannot share a process. Under
   SHELLOPTS=xtrace every line kcov recorded as hits="1" comes back hits="0"
   -- reproduced A/B on one subject. Hence two passes, two namespaces.
2. SHELLOPTS is readonly inside bash, so `export SHELLOPTS=xtrace` fails and
   tracing never turns on; and `set -x` does not reach child processes.
3. Decisive: bash under `unshare --map-root-user` runs PRIVILEGED and
   DISCARDS an inherited PS4, falling back to "+ " while still honouring
   xtrace. The trace loses its file:line prefix and every subject reads 0%.
   Fixed by delivering PS4 and `set -x` through a BASH_ENV file, which also
   solves child-process propagation.

A fourth trap, in the report: kcov records filename="dwm_config.sh", a bare
basename. The source path comes from the trace's ${BASH_SOURCE} or a unique
repo search; an ambiguous basename yields no exclusion rather than a guess.

Measured through run_all.sh, nothing down:

  dot_resolver_install.sh  39/39 = 100.00%  (acceptance, unchanged)
  rpi_nc_ca.sh             73/73 = 100.00%  (was 72/73 = 98.63%)
  dwm_config.sh            65/66 =  98.48%  (was 35/73 = 47.95%)
  rpi_nc_install.sh        85/88 =  96.59%  (was 10/88 = 11.36%)

rpi_nc_ca reaching 100% is the load-bearing check: its missing line 141 was
cross-process contagion, and the trace pass has no kcov in it to contaminate.

dwm_config's remaining line 39 is a `done < <(...)`. Left in the denominator
on purpose -- removing lines inflates coverage, the fail-open direction, so a
second exclusion rule needs per-line justification, not a pattern from one lib.

Both new Python modules hold 100% branch coverage (29 tests).

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

110 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Line classification for shell coverage: what is a statement, and what ran.
Split out of ``shell_coverage_report.py`` to keep both files under the repo's
250-line cap.
Two independent instruments feed the report, because neither is correct alone
(see ``docs/kcov-under-report.md``):
* kcov supplies the *line set* -- which lines are instrumentable at all. Its
hit counts are unreliable: ordinary statements execute without being
recorded (defect (b)).
* a PS4 xtrace supplies the *executed* set. A trace can only ever report lines
that ran, so it cannot supply a denominator -- using it for both halves
would make every subject 100% and gate on nothing.
So: denominator = kcov's line set minus non-statements; numerator =
(traced lines | kcov's hits) & denominator.
"""
from __future__ import annotations
from pathlib import Path
import re
# A PS4 trace line looks like: "+PS4:/abs/path/to/lib.sh:42 some command"
# The leading '+' repeats with nesting depth, so it is matched loosely.
_TRACE_RE = re.compile(r"\+*PS4:(?P<path>[^:]*):(?P<line>\d+)")
def traced_lines(trace_dir: Path, subject: str) -> set[int]:
"""Return the line numbers of *subject* seen executing in a PS4 trace."""
seen: set[int] = set()
if not trace_dir.is_dir():
return seen
for trace_file in sorted(trace_dir.iterdir()):
if not trace_file.is_file():
continue
# errors="replace": a trace interleaves writes from concurrent
# processes and can split a UTF-8 sequence. A decode error must not
# discard the whole file, which would read as zero coverage.
text = trace_file.read_text(encoding="utf-8", errors="replace")
for match in _TRACE_RE.finditer(text):
if Path(match.group("path")).name == subject:
seen.add(int(match.group("line")))
return seen
def traced_paths(trace_dir: Path, subject: str) -> list[Path]:
"""Return the distinct source paths a PS4 trace recorded for *subject*.
kcov's XML carries only a basename, so the trace is the one place the
subject's real location is written down.
"""
found: list[Path] = []
if not trace_dir.is_dir():
return found
for trace_file in sorted(trace_dir.iterdir()):
if not trace_file.is_file():
continue
text = trace_file.read_text(encoding="utf-8", errors="replace")
for match in _TRACE_RE.finditer(text):
path = Path(match.group("path"))
if path.name == subject and path not in found:
found.append(path)
return found
def continuation_lines(source: Path) -> set[int]:
"""Return lines that are *inside* a multi-line quoted argument.
kcov counts these as instrumentable statements that never run, which
inflates the denominator and caps a subject's coverage below 100% no
matter what the tests do (defect (a)). A `bash -x` tracer never reports
them as executable at all, which is the correct reading: they are data
inside an argument, not statements.
Detected by tracking quote state across the file: any line that *begins*
while a quote opened on an earlier line is still open is a continuation.
Only the opening line of such a construct is a statement.
"""
inside: set[int] = set()
quote: str | None = None
for number, raw in enumerate(
source.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
):
if quote is not None:
inside.add(number)
index = 0
while index < len(raw):
char = raw[index]
if quote is None:
if char == "#":
# A comment outside quotes ends the line's significance.
break
if char == "\\":
index += 2
continue
if char in ("'", '"'):
quote = char
elif char == quote:
quote = None
elif quote == '"' and char == "\\":
# Only double quotes honour backslash escapes; inside single
# quotes a backslash is a literal character.
index += 2
continue
index += 1
return inside