testsAndMisc/meta/scripts/lib/shell_coverage_lines.py
Krzysztof kuhy Rudnicki 0201d5460a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Clear both hosts-guard libs; teach the instrument done <redirect> (88 -> 86)
hosts_guard_migrate.sh 81/81 and hosts_guard_rollback.sh 65/65, both
100.00% through run_all.sh at --min 100, and check_shell_coverage.sh
passes with each allowlist line already deleted.

82 assertions over 36 cases in three files sharing one harness: all four
validate_requirements refusals, plugin installation, the mount-collapse
loop in every arm (clears, fails, hits its 20-iteration bound, dry-run),
per-instance migration for both the bind-mounted and the plugin flavour,
and do_migrate's refusal to retire the legacy pacman hooks when nothing
registered -- the arm that exists so a failed migration cannot leave the
files with no unlock hook at all.

INSTRUMENT FIX: rollback measured 97.01% with lines 69 and 108
uncovered, both `done <redirect>` closing a loop. Reproduced minimally:
in a three-iteration loop ending `done >"$1"`, the body reports hits=3
and the `done` line hits=0, because bash attributes the redirected
compound statement to the loop's OPENING line. That is the same defect
_CLOSING_BRACE_OP already handles for `} |`, so _LOOP_END_OP is its
twin. The redirect is required by the pattern: a bare `done` stays in
the denominator, so genuinely uncovered loops are still caught.

Audited as the instrument notes demand -- every newly excluded line
printed with its text -- and it is exactly those two lines across all of
fixes/lib. Acceptance pair unchanged and exact: dot_resolver_install.sh
39/39, rpi_nc_ca.sh 73/73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:50:13 +02:00

185 lines
7.3 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*(\||>>?|<|&>)")
# A loop terminator carrying a redirect: `done >file`, `done <file`,
# `done | cmd`. Same defect as _CLOSING_BRACE_OP and the same reasoning: bash
# attributes the redirected compound statement to the loop's OPENING line
# (the `for`/`while`), so the `done` line is instrumented but can never be
# hit. Measured on a three-line loop: the body reports hits=3 and the
# `done >"$1"` line reports hits=0 in the same run.
#
# The redirect is REQUIRED by the pattern. A bare `done` closing an ordinary
# loop is attributed normally and must stay in the denominator, so matching
# it here would hide real uncovered code.
_LOOP_END_OP = re.compile(r"^\s*done\s*(\||>>?|<|&>)")
# An assignment whose value opens a multi-line array literal, e.g.
# `local -a candidates=(` or `FILES=(`. bash reports the WHOLE assignment at
# this opening line and never emits the element lines or the closing paren, so
# those are data. Symmetric with the multi-line-quote case: the opening line
# is the statement, everything up to the closing paren is not.
_ARRAY_OPEN = re.compile(
r"^\s*(?:local\s+|declare\s+|readonly\s+|export\s+)*"
r"(?:-[a-zA-Z]+\s+)*"
r"[A-Za-z_][A-Za-z0-9_]*\+?=\(\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``.
* a loop terminator carrying a redirect (``done >file``, ``done <file``).
See ``_LOOP_END_OP``.
* the element lines and closing paren of a multi-line array literal.
See ``_ARRAY_OPEN``.
"""
inside: set[int] = set()
quote: str | None = None
in_array = False
for number, raw in enumerate(
source.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
):
if quote is not None or in_array:
inside.add(number)
starts_open = quote is None and not in_array and bool(_ARRAY_OPEN.match(raw))
quote = _quote_after(raw, quote)
if in_array and quote is None and _closes_array(raw):
in_array = False
elif starts_open:
in_array = True
if (
quote is None
and not in_array
and (_CLOSING_BRACE_OP.match(raw) or _LOOP_END_OP.match(raw))
):
inside.add(number)
return inside
def _closes_array(raw: str) -> bool:
"""Return True when ``raw`` closes a multi-line array literal.
Only a ``)`` that is not itself inside quotes counts, so an element like
``"a(b)"`` does not end the literal. Quote state is recomputed from the
start of the line for each candidate rather than pattern-matched.
"""
for index, char in enumerate(raw):
if char == ")" and _quote_after(raw[:index], None) is None:
return True
return False
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