9.8 KiB
Coverage
plot measures line coverage of the compiled library bodies under src/ and enforces a floor on
it, so a regression fails a command instead of quietly lowering a number in a document.
The one command
tools/coverage.sh # from the repository root
cmake --build build/cov --target coverage # the same thing, through CMake
The script configures build/cov from the linux-coverage preset, builds it, runs the whole
ctest suite, captures src/** with lcov into build/cov/coverage.info, renders the HTML report
to build/cov/html/index.html, prints the per-file table below, and exits non-zero when a floor
is violated. It needs cmake, ninja, ctest, lcov, genhtml and python3
(Arch: pacman -S cmake ninja lcov python); it exits 2 with a message when one is missing.
A clean run of it — no build tree, all 26 translation units under src/ compiled, 274 cases — takes
about two minutes here, of which the suite is ~107 s.
The floors
| Scope | Floor | Enforced by |
|---|---|---|
Every file under src/ |
80% of its instrumented lines | tools/coverage_floor.py (--per-file, default 80) |
src/ as a whole |
90% of instrumented lines | tools/coverage_floor.py (--aggregate, default 90) |
tools/coverage_floor.py build/cov/coverage.info reads the lcov tracefile on its own and exits:
0— every floor holds;1— a floor is violated; every offending file is printed with its rate and counts;2— the report cannot be read at all (missing file, or nosrc/records in it), because a report this tool cannot find must never read as a pass.
The measured table
Measured on 2026-09-28 with GCC 16.2.1 from a clean build/cov, over the full test suite.
coverage.sh prints exactly this table on every run.
| File | Lines | Line rate | Functions |
|---|---|---|---|
src/data/array_series.cpp |
43/43 | 100.0% | 100.0% |
src/data/minmax_pyramid.cpp |
163/163 | 100.0% | 100.0% |
src/data/series.cpp |
105/107 | 98.1% | 100.0% |
src/data/stream_series.cpp |
39/39 | 100.0% | 100.0% |
src/gl/handle.cpp |
214/256 | 83.6% | 100.0% |
src/gl/loader.cpp |
53/81 | 65.4% | 77.8% |
src/input/controller.cpp |
327/341 | 95.9% | 100.0% |
src/math/transform.cpp |
28/28 | 100.0% | 100.0% |
src/plot/axis.cpp |
12/12 | 100.0% | 100.0% |
src/plot/figure.cpp |
213/218 | 97.7% | 95.5% |
src/plot/grid.cpp |
15/16 | 93.8% | 100.0% |
src/plot/measure.cpp |
21/21 | 100.0% | 100.0% |
src/plot/plot.cpp |
733/753 | 97.3% | 98.9% |
src/plot/ticks.cpp |
93/99 | 93.9% | 100.0% |
src/render/buffer.cpp |
87/98 | 88.8% | 100.0% |
src/render/embedded_mono_font.cpp |
45/45 | 100.0% | 100.0% |
src/render/primitives.cpp |
71/75 | 94.7% | 100.0% |
src/render/program.cpp |
26/27 | 96.3% | 100.0% |
src/render/renderer.cpp |
529/604 | 87.6% | 93.7% |
src/render/text.cpp |
27/30 | 90.0% | 100.0% |
src/render/text_atlas.cpp |
40/41 | 97.6% | 100.0% |
src/render/vertex_array.cpp |
49/59 | 83.1% | 100.0% |
src/text/true_type_font.cpp |
270/291 | 92.8% | 96.8% |
| src/ aggregate | 3203/3447 | 92.9% | 97.8% |
The floor currently fails, and that is the point. src/gl/loader.cpp sits at 65.4% lines
(53/81) — below the 80% per-file floor — so tools/coverage.sh exits 1 on today's tree. What is
uncovered there is the loader's error paths and its extension scanner; src/gl/handle.cpp (83.6%)
and src/render/vertex_array.cpp (83.1%) are the same kind of gap, just above the line. Those tests
are Task 2 of docs/superpowers/plans/2026-09-28-plot-docs-and-coverage.md; the floor went in first
so the fix has something to satisfy rather than something to negotiate with, and so the next
regression in that area is a failing command rather than a number nobody reads.
What the report measures
src/**— the compiled library bodies. These are what a caller ofplot::coreexecutes.- Lines and functions. Branch coverage is off (
--rc branch_coverage=0): the data-structure and GL code is dense with short-circuit conditions whose branch counts mostly measure how the tests were written, so a branch floor would be noise rather than a contract. - Three of the 26 translation units produce no record at all, because gcov only writes counters for
code that runs:
src/core/status.cpp(status.ois never pulled out oflibplot_core.a— no test callsplot::to_string),src/render/shaders.cpp(string literals and inline definitions), andsrc/text/stb_truetype_impl.cpp(its lines belong to the vendoredthird_party/stb_truetype.h, which is filtered out). Should a record ever arrive withLF:0, the checker exempts that file from the per-file floor rather than dividing by zero. - Not measured: the public headers under
include/plot/(inline and template code, exercised through their callers but not held to a per-file rate);tests/and the generated header-check translation units;adapters/— only the two header-only seams the unit suite drives appear in a raw capture, and the adapter bodies need a window, which the demos cover and this report does not; and the C++ standard library headers gcov also records.
Reproducibility
gcov's counters accumulate in .gcda files, so a run that inherits the previous run's counters
reports a superset of what it actually exercised — the classic reason a coverage number drifts on a
second run with no source change, and here it is measurable: build/cov before this script existed
had been reused across revisions, and its accumulated counters reported 93.0% (3206/3447) for
the same tree that a clean single run reports as 92.9% (3203/3447). Three lines of hit in
plot.cpp, renderer.cpp and buffer.cpp were left over from an earlier build.
tools/coverage.sh therefore deletes every .gcda under the build tree
(find "$BUILD" -name '*.gcda' -delete) before it runs the suite, so the captured counters are
exactly the ones that run produced. lcov --zerocounters --directory "$BUILD" is the equivalent
call — on lcov 2.5 it unlinks the .gcda files and reports success even on a tree that has none
(checked) — and the script keeps the find because it is one explicit step whose meaning does not
depend on the lcov version, and because clearing counters must never be the thing that breaks a
fresh tree. The capture also runs with --rc geninfo_unexecuted_blocks=1 (the "unexecuted
block" notes come from libstdc++ headers on this toolchain; this records those blocks as
unexecuted, which is what they are) and --ignore-errors inconsistent,inconsistent for the same
source. lcov --extract then narrows the tracefile to src/**, and the HTML directory is removed
before genhtml re-creates it.
The numbers are stable; the raw counts are not. Two consecutive runs on the same tree produce
per-file LF/LH/FNF/FNH tallies that are identical for all 23 files — verified by diffing the
two tracefiles — but the per-line hit counts differ by a few parts per million, because a handful
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
floor reads (the LF/LH tallies, and therefore every rate in the table above) is unaffected.
Adjusting the floors, and seeing the gate fail
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
the contract. Against build/cov/coverage.info from the table above:
$ python3 tools/coverage_floor.py build/cov/coverage.info # the real floors
coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
src/gl/loader.cpp: 65.4% lines (53/81) is below the 80% per-file floor
$ echo $?
1
$ python3 tools/coverage_floor.py --per-file 95 build/cov/coverage.info # floor raised above rates
coverage_floor: FAILED -- 10 floor(s) violated in build/cov/coverage.info:
src/gl/handle.cpp: 83.6% lines (214/256) is below the 95% per-file floor
src/gl/loader.cpp: 65.4% lines (53/81) is below the 95% per-file floor
src/plot/grid.cpp: 93.8% lines (15/16) is below the 95% per-file floor
src/plot/ticks.cpp: 93.9% lines (93/99) is below the 95% per-file floor
src/render/buffer.cpp: 88.8% lines (87/98) is below the 95% per-file floor
src/render/primitives.cpp: 94.7% lines (71/75) is below the 95% per-file floor
src/render/renderer.cpp: 87.6% lines (529/604) is below the 95% per-file floor
src/render/text.cpp: 90.0% lines (27/30) is below the 95% per-file floor
src/render/vertex_array.cpp: 83.1% lines (49/59) is below the 95% per-file floor
src/text/true_type_font.cpp: 92.8% lines (270/291) is below the 95% per-file floor
$ echo $?
1
$ python3 tools/coverage_floor.py --per-file 60 --aggregate 90 build/cov/coverage.info
coverage_floor: ok -- 23 files under src/, aggregate 92.9% lines (3203/3447), every file at or
above 60%, aggregate target 90%
$ echo $?
0
Why the check is a CMake target and not a ctest case
A ctest entry registered only in the coverage preset would either fail in a freshly configured tree
— no report exists until tools/coverage.sh has run once, so the preset's own clean-tree command
(cmake --preset linux-coverage && cmake --build build/cov && ctest --test-dir build/cov) would
then fail for a reason that has nothing to do with the code — or it would have to build the library
and re-run the whole suite inside the test, duplicating the script. A custom target instead: it is
not part of all, so it cannot slow or destabilise the linux-debug, linux-asan or
linux-release builds, and it is not even created in those presets, because they leave
PLOT_COVERAGE off and the option's guard rejects a tree that carries no --coverage flags.