testsAndMisc/meta/scripts/lib/shell_coverage_lines.py
Krzysztof kuhy Rudnicki 28f2925c30 Instrument: multi-line array elements are not statements; clear arch_perf_fixes
Fourth flavour of defect (a) found this session, after multi-line quotes,
`} |` and the backslash-continued assignment. bash reports a multi-line array
assignment at its OPENING line and never emits the element lines or the
closing paren, so kcov instrumenting them inflates the denominator:

    +L3 items=('one' 'two')      # opened line 3, closed line 6
                                 # lines 4, 5, 6: never emitted

The rule is therefore symmetric with the existing multi-line-quote case: the
opening line is the statement, everything up to the closing paren is data.
_closes_array() finds that paren by recomputing quote state per candidate, so
an element like "a(b)" does not end the literal early.

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

  arch_perf_fixes.sh  93.48% -> 100.00%  (43/43)
  bt_audio.sh        100.00% -> 100.00%  (127/127)
  arch_perf_report.sh 100.00% -> 100.00% (59/59)
  arch_perf_probes.sh 100.00% -> 100.00% (93/93)
  ubuntu_perf_more.sh 100.00% -> 100.00% (32/32)
  bt_report.sh       100.00% -> 100.00%  (37/37)
  bt_pairing.sh      100.00% -> 100.00%  (65/65)

One gained, six held, none went DOWN. Acceptance test unchanged:
dot_resolver_install.sh 39/39 and rpi_nc_ca.sh 73/73. arch_perf_fixes.sh
therefore comes off the allowlist in the same commit, confirmed by the real
gate with no exemption.

The fail-open check, since over-detection INFLATES coverage: the old and new
predicates were diffed over every allowlisted lib plus the six cleared ones,
printing each newly excluded line WITH its text. All 91 across 10 libs are
array elements or closing parens -- common.sh's tool tables, packages.sh's
associative arrays, arch_sysctl.sh's sysctl maps, thorium_repairs.sh's four
path lists, ubuntu_perf_fixes.sh's sssd units, and so on. No statement caught.

Tests cover both directions, including three negatives: a single-line
`x=(a b)`, an element containing a parenthesis, and `x=$(echo hi)` which is a
command substitution rather than an array. Module stays at 100% branch
coverage.

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

167 lines
6.5 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*(\||>>?|<|&>)")
# 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``.
* 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):
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