Files
plotter/tools/coverage_floor.py
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

378 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""Gate an lcov report for src/** against the project's coverage floors.
The floors are the contract made visible in docs/coverage.md: every file under
src/ must keep at least 80% of its instrumented lines executed by the unit
suite, and src/ as a whole at least 90%. `tools/coverage.sh` runs this after it
captures the report, so a regression fails the one command instead of quietly
lowering a number in a document.
The check is deliberately able to fail: `--per-file` and `--aggregate` exist so
the failure path can be demonstrated (raise the floor above a file's real rate
and watch it exit 1), and the same knobs are what a future task would tighten.
Exit status: 0 when every floor holds, 1 when one does not, 2 when the report
cannot be read at all (missing file, no src/ records, malformed record) -- a
report this tool cannot find must never read as a pass.
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
# The floors docs/coverage.md states. Kept here rather than in the shell script
# so the checker can be run on its own against any captured report.
PER_FILE_FLOOR = 80.0
AGGREGATE_FLOOR = 90.0
@dataclass(frozen=True)
class Record:
"""One lcov SF..end_of_record block: a source file and what it executed."""
path: str
lines_found: int
lines_hit: int
functions_found: int
functions_hit: int
@property
def rate(self) -> float:
return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 100.0
@property
def function_rate(self) -> float:
return (
100.0 * self.functions_hit / self.functions_found
if self.functions_found
else 100.0
)
@property
def display(self) -> str:
return relative_to_source_root(self.path)
def relative_to_source_root(path: str) -> str:
"""`/home/.../plotter/src/gl/loader.cpp` -> `src/gl/loader.cpp`."""
if path.startswith("src/"):
return path
marker = path.rfind("/src/")
return path[marker + 1 :] if marker >= 0 else path
def parse_lcov(text: str) -> list[Record]:
"""Every SF block in an lcov tracefile, in file order.
A record with no LF line is treated as having no instrumented lines, which
is what a header-only or string-literal-only translation unit reports -- it
is not a failure and not part of the aggregate either way.
Functions are counted from lcov 2.x's per-function records (`FNA:<index>,
<count>,<name>`) when they are there, because that is also what `lcov --list`
and `genhtml` count; `FNF`/`FNH` are the fallback for a tracefile written
without them. The two can disagree by a function or two -- a duplicated
function name collapses in FNF but not in the FNA list -- and the table here
exists to agree with the tools the reader will run next.
"""
records: list[Record] = []
fields: dict[str, str] = {}
functions_found = 0
functions_hit = 0
def flush() -> None:
nonlocal functions_found, functions_hit
source = fields.get("SF")
found = functions_found or int(fields.get("FNF", 0))
hit = functions_hit or int(fields.get("FNH", 0))
lines_found = int(fields.get("LF", 0))
lines_hit = int(fields.get("LH", 0))
fields.clear()
functions_found = 0
functions_hit = 0
if source is None:
return
records.append(
Record(
path=source,
lines_found=lines_found,
lines_hit=lines_hit,
functions_found=found,
functions_hit=hit,
)
)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
if line == "end_of_record":
flush()
continue
key, separator, value = line.partition(":")
if not separator:
continue
if key == "FNA":
# FNA:<index>,<count>,<name> -- the name itself may hold a comma,
# so only the first two fields are split off.
_, count, _ = value.split(",", 2)
functions_found += 1
if int(count) > 0:
functions_hit += 1
elif key in {"SF", "LF", "LH", "FNF", "FNH"}:
fields[key] = value
flush()
return records
def src_records(records: list[Record]) -> list[Record]:
"""The records the floors apply to: the compiled bodies under src/."""
selected = [
record
for record in records
if record.path.startswith("src/") or "/src/" in record.path
]
selected.sort(key=lambda record: record.display)
return selected
def render_table(records: list[Record], markdown: bool) -> str:
aggregate_hit = sum(record.lines_hit for record in records)
aggregate_found = sum(record.lines_found for record in records)
aggregate_rate = 100.0 * aggregate_hit / aggregate_found if aggregate_found else 0.0
function_hit = sum(record.functions_hit for record in records)
function_found = sum(record.functions_found for record in records)
lines: list[str] = []
if markdown:
lines.append("| File | Lines | Line rate | Functions |")
lines.append("| --- | ---: | ---: | ---: |")
else:
lines.append(f"{'File':<40} {'Lines':>10} {'Rate':>8} {'Functions':>12}")
for record in records:
if markdown:
lines.append(
f"| `{record.display}` | {record.lines_hit}/{record.lines_found} | "
f"{record.rate:.1f}% | {record.function_rate:.1f}% |"
)
else:
lines.append(
f"{record.display:<40} "
f"{f'{record.lines_hit}/{record.lines_found}':>10} "
f"{f'{record.rate:.1f}%':>8} "
f"{f'{record.functions_hit}/{record.functions_found}':>12}"
)
if markdown:
function_rate = (
f"**{100.0 * function_hit / function_found:.1f}%**" if function_found else ""
)
lines.append(
f"| **src/ aggregate** | **{aggregate_hit}/{aggregate_found}** | "
f"**{aggregate_rate:.1f}%** | {function_rate} |"
)
else:
lines.append(
f"{'src/ aggregate':<40} {f'{aggregate_hit}/{aggregate_found}':>10} "
f"{f'{aggregate_rate:.1f}%':>8}"
)
return "\n".join(lines)
# The generated region docs/coverage.md carries. tools/coverage.sh calls this
# tool with --write-doc on every run, so the table in that document is the one
# the command that measured it just captured, never a number copied by hand.
DOC_BEGIN = "<!-- BEGIN GENERATED: tools/coverage.sh -->"
DOC_END = "<!-- END GENERATED: tools/coverage.sh -->"
def render_doc_block(records: list[Record], per_file: float, aggregate: float) -> str:
"""The region spliced into docs/coverage.md: the floor verdict and the table."""
failures = [
record
for record in records
if record.lines_found > 0
and record.lines_hit * 100.0 < per_file * record.lines_found
]
aggregate_hit = sum(record.lines_hit for record in records)
aggregate_found = sum(record.lines_found for record in records)
aggregate_rate = (
100.0 * aggregate_hit / aggregate_found if aggregate_found else 0.0
)
aggregate_below = aggregate_hit * 100.0 < aggregate * aggregate_found
lines = [DOC_BEGIN, ""]
lines.append("```")
if failures or aggregate_below:
lines.append(
f"coverage_floor: FAILED -- {len(failures) + (1 if aggregate_below else 0)} "
"floor(s) violated in the report this run captured:"
)
for record in failures:
lines.append(
f" {record.display}: {record.rate:.1f}% lines "
f"({record.lines_hit}/{record.lines_found}) is below the "
f"{per_file:g}% per-file floor"
)
if aggregate_below:
lines.append(
f" src/ aggregate: {aggregate_rate:.1f}% lines "
f"({aggregate_hit}/{aggregate_found}) is below the "
f"{aggregate:g}% aggregate floor"
)
else:
lines.append(
f"coverage_floor: ok -- {len(records)} files under src/, aggregate "
f"{aggregate_rate:.1f}% lines ({aggregate_hit}/{aggregate_found}), every file "
f"at or above {per_file:g}%, aggregate target {aggregate:g}%"
)
lines.append("```")
lines.append("")
lines.append(render_table(records, markdown=True))
lines.append("")
lines.append(DOC_END)
return "\n".join(lines)
def update_doc(path: Path, block: str) -> None:
"""Replace the region between the markers in `path` with `block`."""
lines = path.read_text(encoding="utf-8").splitlines()
try:
begin = next(i for i, line in enumerate(lines) if line.startswith(DOC_BEGIN))
end = next(i for i, line in enumerate(lines) if line.startswith(DOC_END))
except StopIteration:
raise ValueError(f"no {DOC_BEGIN} / {DOC_END} markers") from None
if end < begin:
raise ValueError("the end marker precedes the begin marker")
merged = lines[:begin] + block.splitlines() + lines[end + 1 :]
path.write_text("\n".join(merged) + "\n", encoding="utf-8")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description="Fail when src/** coverage drops below the project's floors.",
)
parser.add_argument("report", help="the lcov tracefile, e.g. build/cov/coverage.info")
parser.add_argument(
"--per-file",
type=float,
default=PER_FILE_FLOOR,
metavar="PCT",
help=f"minimum line rate for every file under src/ (default {PER_FILE_FLOOR:g})",
)
parser.add_argument(
"--aggregate",
type=float,
default=AGGREGATE_FLOOR,
metavar="PCT",
help=f"minimum line rate for src/ as a whole (default {AGGREGATE_FLOOR:g})",
)
parser.add_argument("--table", action="store_true", help="print every file, not just failures")
parser.add_argument(
"--format",
choices=("table", "markdown"),
default="table",
help="table layout; markdown is what docs/coverage.md carries",
)
parser.add_argument(
"--write-doc",
metavar="PATH",
help=(
"rewrite the generated region of the markdown file at PATH from the "
"report, and exit 0; the floor verdict is written inside that region"
),
)
args = parser.parse_args(argv)
report = Path(args.report)
if not report.is_file():
print(
f"coverage_floor: no report at {report} -- build and capture with "
"tools/coverage.sh first",
file=sys.stderr,
)
return 2
try:
records = src_records(parse_lcov(report.read_text(encoding="utf-8")))
except (OSError, ValueError) as error:
print(f"coverage_floor: cannot read {report}: {error}", file=sys.stderr)
return 2
if not records:
print(
f"coverage_floor: {report} holds no records under src/ -- the report is "
"empty or was filtered away; refusing to report a pass",
file=sys.stderr,
)
return 2
if args.write_doc:
try:
update_doc(
Path(args.write_doc),
render_doc_block(records, args.per_file, args.aggregate),
)
except (OSError, ValueError) as error:
print(
f"coverage_floor: cannot update {args.write_doc}: {error}",
file=sys.stderr,
)
return 2
print(f"coverage_floor: wrote the measured table to {args.write_doc}")
return 0
if args.table:
print(render_table(records, args.format == "markdown"))
print()
failures: list[str] = []
for record in records:
# LF:0 is a translation unit with nothing to execute (string literals
# only); it has no rate to hold to a floor.
if record.lines_found == 0:
continue
if record.lines_hit * 100.0 < args.per_file * record.lines_found:
failures.append(
f" {record.display}: {record.rate:.1f}% lines "
f"({record.lines_hit}/{record.lines_found}) is below the "
f"{args.per_file:g}% per-file floor"
)
aggregate_hit = sum(record.lines_hit for record in records)
aggregate_found = sum(record.lines_found for record in records)
aggregate_rate = 100.0 * aggregate_hit / aggregate_found if aggregate_found else 0.0
if aggregate_hit * 100.0 < args.aggregate * aggregate_found:
failures.append(
f" src/ aggregate: {aggregate_rate:.1f}% lines "
f"({aggregate_hit}/{aggregate_found}) is below the "
f"{args.aggregate:g}% aggregate floor"
)
if failures:
print(
f"coverage_floor: FAILED -- {len(failures)} floor(s) violated in {report}:",
file=sys.stderr,
)
print("\n".join(failures), file=sys.stderr)
return 1
print(
f"coverage_floor: ok -- {len(records)} files under src/, aggregate "
f"{aggregate_rate:.1f}% lines ({aggregate_hit}/{aggregate_found}), "
f"every file at or above {args.per_file:g}%, aggregate target {args.aggregate:g}%"
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))