Coverage — the collection pipeline¶
The problem: embedded and cross-compiled products execute where no coverage
tooling runs. gcov counters (.gcda) accumulate on the target — in memory or
on an on-device filesystem — while the compile-time graph (.gcno) and
sources live in the build tree on the runner. Neither side alone can make a
report; this pipeline marries them.
The stages (packages otto.coverage.fetcher → merge → capture →
renderer → reporter):
Fetch — pull
.gcdadata from each covered host after the run. Fetchers are per-family: file transfer for Unix hosts, console extraction for embedded targets. Which hosts are covered is repo-declared — the[coverage].hostsregex insettings.toml— never inferred, so hop hosts and uninstrumented beds can’t sneak into a report.Merge — match counters to the build tree’s
.gcnograph and remap embedded/sysroot paths back to source paths, merging counters across hosts and runs (lcov semantics).Capture — freeze the merged result into a per-board
capture.json: parsed hits in committed-code coordinates, anchored to the repo’sHEAD(base_commit) and per-file blob SHAs.Render / report —
otto cov reportassembles every tier — e2e captures, a fresh unit-tier harvest, the committed manual store — into an HTML report plus summary tiers.
The merge stage’s core invariant is build/counter identity: .gcda files
are only meaningful against the exact .gcno graph the binary was compiled
with. That pairing happens once, at collection — the capture holds
parsed hits, so the report step never touches the build tree again and a
later rebuild cannot invalidate it (a capture’s own guard is its
base_commit, which must match HEAD at report time). When the raw pairing disagrees — a
stale or partially rebuilt product tree at collection time, or a
pre-capture run directory re-merged via the legacy fallback — the pipeline
stops with a diagnostic error that names the mismatch and the rebuild that
fixes it, rather than a gcov stack trace or a silently wrong report. That
fail-with-instructions posture is a house rule (Design principles).
Tiers and what is committed¶
Coverage is organized into tiers — system (e2e), unit, manual, or
any other name — each with a kind (e2e / unit / manual) that selects
how otto collects that tier’s data; declaring tiers and driving the
three-tier workflow is covered in Coverage Collection.
Only the manual tier’s data is committed into the repo, even though
every tier’s data is anchored to base_commit: e2e and unit data are
reproducible — a fresh otto test --cov or a rebuilt unit harvest
regenerates them — but a manual session (a human at a GDB prompt, poking at
running hardware) produces evidence nothing else can regenerate. Selecting a
manual-kind tier on otto cov get copies the capture into the repo’s
committed store at .otto/coverage/manual/ — proof of that session that
travels with the code and is PR-reviewable. E2e data instead lives in each
test run’s output directory, and unit data is harvested fresh from the build
tree’s harvest_dirs at report time, so neither needs a permanent home.
otto cov report assembles a store from all three sources per tier kind:
e2e captures from the given output directories (behind the base_commit guard
above), the unit harvest, and every committed manual capture — loaded
automatically, no path needed. Because manual evidence outlives the commit
it was captured against, a report-time validity pass
(otto.coverage.validity) re-anchors each manual capture’s lines against the
current tree by git blob SHA rather than trusting the stored line numbers
forever: unchanged lines stay valid, changed/deleted lines go stale
(coverage revoked — the evidence no longer describes this code), and
valid-but-old lines past the tier’s max_age are flagged aging without
losing coverage credit. See Coverage Collection for the full
valid/stale/aging/unverifiable state table and how each renders in a report.
What is unique about cov¶
otto cov report runs after the fact, over directories otto test --cov
or otto cov get already wrote: it still loads the lab — per-host toolchain
resolution (gcov, lcov) comes from host configuration, with the .gcno
header’s gcov version stamp as the fallback (a clang stamp routes counters
through llvm-cov gcov) — but it creates no output directory of its
own and runs no gate: reporting on yesterday’s run must never be
blocked by today’s reservations (The command lifecycle). Its siblings do touch the
lab: otto cov get fetches counters — into the standard per-invocation
output directory, or --output — and otto cov clean zeroes them on the
remotes.
Where the code lives¶
otto.coverage.fetcher— pulls.gcdaoff covered hosts (file transfer on Unix, console extraction on embedded)otto.coverage.merge— pairs counters to the.gcnobuild graph and merges hosts and runsotto.coverage.capture— freezes a merge into a per-boardcapture.json, anchored tobase_commitotto.coverage.renderer— turns an assembled store into the HTML reportotto.coverage.reporter—otto cov report’s store assembly: tiers, the base_commit guard, and the--tier NAME=PATHescape hatchotto.coverage.validity— the report-time valid/stale/aging pass over manual captures