testsAndMisc/meta/scripts/lib/shell_coverage_lines.py
Krzysztof kuhy Rudnicki d989cd7ebf Coverage instrument: a closing brace plus an operator is not a statement
continuation_lines() excluded the continuation lines of multi-line QUOTED
arguments but nothing else, so a line holding only a group's closing brace
and an operator -- `} |`, `} >>"$REPORT_FILE"` -- stayed in the denominator
while bash attributed no statement to it. That capped four fixes/lib libs
below 100% with no test able to move them.

Why the anchor makes this safe: bash attributes every command INSIDE a group
to its own line, and a pipe's right-hand side to its own line, leaving the
brace-plus-operator line with nothing. Anchoring the pattern on a LEADING
`}` therefore excludes only lines that can hold no statement. A one-liner
`{ echo a; } >file` begins with `{` and is untouched; a bare `}` closing a
function has no operator and is untouched.

Measured effect, all through fixes/lib/tests/run_all.sh:

  bt_audio.sh          96.97% -> 100.00%  (127/127)
  arch_perf_report.sh  98.33% -> 100.00%  (59/59)
  bt_report.sh        100.00% -> 100.00%  (37/37)
  bt_pairing.sh       100.00% -> 100.00%  (65/65)
  bt_adapter.sh        98.55% ->  98.55%  (68/69, capped by a different flavour)

Two gained, three held, none went DOWN -- the handoff's regression signal.
That is not luck: _measure computes covered_set = (executed|traced) &
denominator, so for a lib already at 100% the covered set IS the denominator
and removing a subset cannot lower it. The five gated pacman_hook_stall libs
are safe by the same arithmetic.

Acceptance test from the handoff passes unchanged: dot_resolver_install.sh
39/39 and rpi_nc_ca.sh 73/73, both 100.00%.

The fail-open check that actually matters, since over-detection INFLATES
coverage and could promote an untested lib: the new predicate was run over
every lib in the allowlist plus the cleared ones, printing each newly
excluded line WITH its text. All 17 across 10 libs are genuine
brace-plus-operator lines; no ordinary statement was caught.

Tests cover both directions, including the two negative cases (a bare `}`
and a single-line `{ ...; } >file`), keeping the module at 100% branch
coverage. The quote scanner moved into _quote_after() to stay under the
C901 complexity limit rather than suppressing it.

Deliberately NOT fixed here: the backslash-continued ASSIGNMENT flavour that
caps bt_adapter.sh line 97. bash attributes those to the LAST line of the run
but a backslash-continued plain COMMAND to its FIRST, so that predicate needs
a command-vs-assignment distinction. One line in one lib; not worth the
landmine today.

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

135 lines
5.1 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
# A line holding only a group's closing brace plus an operator: `} |`,
# `} >>file`, `} <in`, `} &>log`. kcov instruments these, but bash attributes
# no statement to them -- each command INSIDE the group is attributed to its
# own line, and a pipe's right-hand side to its own line, leaving the brace
# line with nothing. Anchoring on a leading `}` is what keeps this safe: a
# one-liner `{ echo a; } >file` begins with `{` and is a real statement, and a
# bare `}` closing a function carries no operator.
_CLOSING_BRACE_OP = re.compile(r"^\s*\}\s*(\||>>?|<|&>)")
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.
Two shapes qualify:
* a line that *begins* while a quote opened on an earlier line is still
open -- the continuation of a multi-line quoted argument. Only the
opening line of such a construct is a statement.
* a line holding just a group's closing brace and an operator (``} |``,
``} >>file``). See ``_CLOSING_BRACE_OP``.
"""
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)
quote = _quote_after(raw, quote)
if quote is None and _CLOSING_BRACE_OP.match(raw):
inside.add(number)
return inside
def _quote_after(raw: str, quote: str | None) -> str | None:
"""Return the open-quote state at the end of ``raw``, given its state at the start.
``None`` means no quote is open. Escapes are honoured outside quotes and
inside double quotes, but not inside single quotes, which take a
backslash literally.
"""
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 == "\\":
index += 2
continue
index += 1
return quote