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.

coverage test otto test --cov / otto cov get instrumented run or retrieval fetch fetch .gcda from covered hosts (transfer on Unix, console extraction embedded) test->fetch merge merge match .gcda ↔ .gcno graph, remap sysroot paths, merge hosts + runs (lcov) fetch->merge capture capture.json per board: parsed hits, git-anchored coordinates merge->capture err CoverageDataMismatchError stale build → instructions, not a wrong report merge->err stamp mismatch render otto cov report captures + unit harvest + manual store → HTML capture->render

The stages (packages otto.coverage.fetchermergecapturerendererreporter):

  1. Fetch — pull .gcda data 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].hosts regex in settings.toml — never inferred, so hop hosts and uninstrumented beds can’t sneak into a report.

  2. Merge — match counters to the build tree’s .gcno graph and remap embedded/sysroot paths back to source paths, merging counters across hosts and runs (lcov semantics).

  3. Capture — freeze the merged result into a per-board capture.json: parsed hits in committed-code coordinates, anchored to the repo’s HEAD (base_commit) and per-file blob SHAs.

  4. Render / reportotto cov report assembles 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 tierssystem (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 .gcda off covered hosts (file transfer on Unix, console extraction on embedded)

  • otto.coverage.merge — pairs counters to the .gcno build graph and merges hosts and runs

  • otto.coverage.capture — freezes a merge into a per-board capture.json, anchored to base_commit

  • otto.coverage.renderer — turns an assembled store into the HTML report

  • otto.coverage.reporterotto cov report’s store assembly: tiers, the base_commit guard, and the --tier NAME=PATH escape hatch

  • otto.coverage.validity — the report-time valid/stale/aging pass over manual captures