# 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 ```sh 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 no `src/` 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 of `plot::core` executes. * 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.o` is never pulled out of `libplot_core.a` — no test calls `plot::to_string`), `src/render/shaders.cpp` (string literals and inline definitions), and `src/text/stb_truetype_impl.cpp` (its lines belong to the vendored `third_party/stb_truetype.h`, which is filtered out). Should a record ever arrive with `LF: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: ```sh $ 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.