This document explains, from first principles, how the unified Rust + C++
coverage pipeline in @score_tooling//coverage works: the mechanism, the
module/consumer split, the non-obvious pitfalls, and where the pipeline comes
from. For the step-by-step consumer setup see README.md; for a
working consumer workspace see integration_tests/.
When we run the test suite, we want to know which lines of production code were actually executed. The compiler helps: it can instrument the code — insert tiny counters at every branch and statement. When an instrumented test binary runs, it writes the counter values to a file. Tooling then maps those counters back to source lines and produces a report: green = executed, red = never executed.
Bazel is the build system used by S-CORE. Code is organized into targets
(a library, a binary, a test), declared in files named BUILD. Targets
reference each other by labels like //src/rust/mycrate:tests
(//path/to/package:target_name).
External dependencies (compilers, libraries) are declared in MODULE.bazel.
A toolchain is Bazel's packaging of a compiler + flags; you can register
several and select one per build. Command-line defaults live in .bazelrc,
grouped into named configs: bazel test --config=foo applies all lines
starting with test:foo.
Bazel has a built-in coverage mode: bazel coverage <targets> builds the
targets with instrumentation, runs the tests, and post-processes the results.
Two hooks matter for us: --coverage_output_generator (a tool that processes
each test's raw coverage output) and --coverage_report_generator (a tool
that combines everything into the final report). This pipeline replaces
both with its own tools — that is its core.
Rust code is organized into crates (≈ libraries/binaries). The Rust
compiler is rustc; S-CORE uses Ferrocene, a safety-certified Rust
toolchain distribution. Bazel builds Rust via the rules_rust plugin.
Crucially, rustc is built on the same compiler backend as Clang (LLVM).
That means Rust and C++ can use the same coverage instrumentation format —
which is what makes a single unified report possible.
The pipeline is built on LLVM's "source-based coverage":
| Artifact | Produced by | Contains |
|---|---|---|
| instrumented binary | Clang (C++) / rustc (Rust) with coverage flags | counters + a "covmap" mapping counters → source lines |
.profraw |
running the instrumented test | raw counter values |
.profdata |
llvm-profdata merge |
merged, indexed counters |
| HTML / LCOV / text report | llvm-cov show/export/report |
human- and machine-readable coverage |
There are two phases.
bazel coverage --config=llvm_cov //... --build_tests_only
The llvm_cov config (the consumer copies it from
integration_tests/.bazelrc) does four things:
- Swaps the compilers. C++ is compiled with a hermetic Clang/LLVM
toolchain (
@llvm_toolchain) instead of GCC; Rust with a Ferrocene toolchain that has LLVM coverage tools attached — wired in automatically by score_toolchains_rust >= 0.9.2 from the coverage-tools tarball, see §5. Both emit the same covmap format. - Turns on instrumentation.
--experimental_use_llvm_covmapplus thecoveragefeature for C++;rules_rustadds-Cinstrument-coverageto rustc automatically once the toolchain declares coverage tools. An extra flag (-Cllvm-args=-runtime-counter-relocation) enables "continuous mode" so coverage survives even if a test terminates abnormally (same purpose as theenable_llvm_coverage_for_death_testscc_feature on the C++ side). Branch coverage needs one more flag per language: Clang emits branch regions by default, but rustc only does so with-Zcoverage-options=branch— an unstable option that works on rolling (nightly-based) Ferrocene builds. On a stable-channel Ferrocene the flag must be dropped (Rust branch columns revert to-) until rustc stabilizes it. - Installs the per-test tool
(
--coverage_output_generator=@score_tooling//coverage:merger). After each test runs,merger.pyfinds the test's.profrawfiles, merges them into one.profdatawithllvm-profdata, records which instrumented binary was involved, and zips both up as the test'scoverage.dat. - Installs the final tool (
--coverage_report_generator=the consumer'sscore_coverage_reportertarget). After all tests finish,reporter.pymerges every per-test.profdatainto one, then runsllvm-covthree times:show→ HTML report,export→ LCOV data (for dashboards),report→ text summary. All three are zipped intobazel-out/_coverage/_coverage_report.dat.
Scope — which files appear in the report. LLVM covmap instruments
everything, including test code and third-party libraries. Filtering
happens at report time using an allowlist generated by the consumer's
score_coverage_scope target (coverage_scope.bzl): an aspect walks the
dependency graph starting from the listed production targets and collects
every in-workspace source file they own. The reporter excludes everything
else (test sources, googletest, external deps, ...).
Baseline — files with no tests at all. A file that no test executes
produces no coverage data, so naive tooling silently omits it — it looks
like there is no problem when in fact coverage is 0%. The scope aspect
therefore also collects the compiled libraries/binaries (.a archives for
cc_library/rust_library, the coverage-built executable for rust_binary), and
the reporter runs llvm-cov --empty-profile over them so untested files
show up with exact 0% line and branch entries — the denominators come
from the compiler's own coverage map, not from any source-text heuristic.
Rust rlib archives need special handling here: their leading lib.rmeta
member makes llvm-cov reject the whole archive, so the reporter expands them
into their .o members first.
bazel run @score_tooling//coverage:generate_coverage_html -- \
--yaml tools/coverage/coverage_justifications.yaml
generate_coverage_html.sh unpacks the HTML from the zip, then applies the
justification system:
-
justify.pyreads the consumer'scoverage_justifications.yamlplus in-code markers and produces a manifest of "argued" lines. A justification says: this line cannot reasonably be covered by a test, and here is why (e.g. defensive code for conditions that cannot occur). Markers work in both languages:unreachable!(); // COV_JUSTIFIED my-justification-id
default: return Error; // COV_JUSTIFIED my-justification-id
Every marker id must exist in the YAML with a category and a written reason — a justification is a reviewed engineering argument, not an opt-out.
-
effective_coverage.pyrecolors justified lines orange in the HTML (with the reason as tooltip) and computes:raw coverage = covered / total effective coverage = (covered + justified) / totalIt also flags stale justifications — lines that are justified but meanwhile covered by a test — so the database stays clean.
-
Finally the script compares effective line coverage against the
COVERAGE_THRESHOLDenvironment variable (default 100) and fails (exit 1) when below. The model: every uncovered line must eventually be either tested or justified; the threshold is ratcheted up as gaps close. -
Optionally a markdown job summary is emitted (
--summary-md <path>, or appended toGITHUB_STEP_SUMMARYautomatically inside GitHub Actions when the flag is absent): overall/per-directory tables computed from the LCOV data (which includes the exact-0% baseline records), raw-vs-effective numbers when justifications ran, and collapsible least-covered/0% file lists. It is written before the gate decides the exit code, so a failing gate still leaves the summary on the run page.
# collect coverage (Rust + C++, one run)
bazel coverage --config=llvm_cov //... --build_tests_only
# report without gating
COVERAGE_THRESHOLD=0 bazel run @score_tooling//coverage:generate_coverage_html -- \
--yaml tools/coverage/coverage_justifications.yaml
# open it
xdg-open coverage_linux/index.html
# CI-style archive (HTML + LCOV + justification report + JUnit XMLs)
bazel run @score_tooling//coverage:generate_coverage_html -- \
--yaml tools/coverage/coverage_justifications.yaml --archive my-reportDo not combine
--config=llvm_covwith configs that register other C++ toolchains (e.g. a GCC host config): the last--extra_toolchainswins resolution, GCC cannot produce covmap data, and the report script fails loudly on the resulting non-zip report.
Almost everything in the pipeline is generic. What is repo-specific is exactly three things: (a) the list of production targets in the scope, (b) the justification YAML, (c) the toolchain pins in MODULE.bazel. The split follows directly:
Lives in @score_tooling//coverage (shared):
| File | Role |
|---|---|
merger.py |
per-test profraw → profdata (C++ objects_list.txt and Rust ELF-manifest discovery) |
reporter.py |
final merge + llvm-cov show/export/report + allowlist filtering + --empty-profile baselines + rlib expansion |
coverage_scope.bzl |
the scope aspect/rule (CcInfo + CrateInfo) |
reporter_wrapper.bzl + defs.bzl |
the consumer-facing score_coverage_scope / score_coverage_reporter API |
justify.py, effective_coverage.py, generate_coverage_html.sh |
justification + gating layer |
enable_llvm_coverage_for_death_tests |
cc_feature for continuous-mode profiling |
Lives in the consumer repository:
| Piece | Why it cannot move |
|---|---|
score_coverage_scope(deps = [...]) |
names the repo's production targets |
score_coverage_reporter(...) |
carries the repo's LLVM tool labels and workspace root |
coverage_justifications.yaml |
reviewed, repo-specific engineering arguments |
| MODULE.bazel toolchain blocks | LLVM + Ferrocene pins are per-repo decisions |
the coverage:llvm_cov bazelrc block |
bazelrc cannot be imported across modules; copied from the canonical snippet |
Two wiring details make the external hosting work, both easy to get wrong:
- Runfiles paths span repositories. The reporter wrapper mixes files
from
_main(the consumer),score_toolingand toolchain repos, so every path in the generated launcher uses rlocation form (../repo/...→repo/...), and the launcher derives its ownRUNFILES_DIRfrom$0— the inherited value points at the test's runfiles tree, not ours. - The baseline manifest lists consumer files. The reporter resolves
manifest entries against
_mainexplicitly; using the runfiles library's "current repository" would resolve againstscore_toolingand find nothing.
These are the "landmines" discovered while bringing the pipeline up in communication and persistency:
- Warnings-as-errors under Clang. Repos whose deps request the
treat_warnings_as_errorsfeature may need--features=-treat_warnings_as_errorsand--host_features=-treat_warnings_as_errorsin the coverage config: Clang emits warnings GCC doesn't, and Bazel builds the coverage reporter (and hence the scope's libraries) a second time "as a tool" in a separate configuration — that's what the--host_featuresvariant covers. - Never disable the
coveragefeature under the LLVM toolchain — it is the instrumentation (-fprofile-instr-generate -fcoverage-mapping). -Cllvm-args=-runtime-counter-relocationfor Rust — without it, continuous-mode profiling errors out and Rust tests write no.profraw.llvm-cov reportprints raw covmap paths (/proc/self/cwd/...) —--path-equivalencedoes not rewrite displayed paths; the reporter normalizes them, otherwise the allowlist silently excludes all C++ files.- The Rust toolchain lists its own
llvm-cov/llvm-profdatabinaries as coverage metadata — the merger must skipexternal/entries or the final merge emits "mismatched data" warnings. - Rust branch coverage is opt-in and channel-dependent — llvm-cov only
renders branch data that the compiler wrote into the covmap; stable rustc
writes none.
-Zcoverage-options=branchenables it on nightly-based toolchains (like the Ferrocene rolling build). Verify withllvm-cov export: thebranchesarrays must be non-empty for Rust files.
Solved at the source, no consumer configuration needed:
ferrocene_toolchain_builder>= 1.3.1 shipsllvm-cov,llvm-profdataandllvm-cxxfiltin the coverage-tools tarball, built from the same LLVM tree as rustc (so the tools can always read the profraw/covmap the compiler emits). 1.3.1 also rebuilds ALL artifacts from a single tree — toolchain tarballs, miri-sysroots (now includinglibprofiler_builtins) and coverage tools are ABI-consistent.score_toolchains_rust>= 0.9.2 (current: 0.10.0) auto-wires the tools into the generatedrust_toolchainwhenever the coverage-tools tarball contains them. rules_rust then instruments crates underbazel coverageand exportsRUST_LLVM_COV/RUST_LLVM_PROFDATAto the coverage runner.
Consequently consumers need no coverage-specific Rust toolchain at all: the
standard toolchains declared in score_toolchains_rust's own MODULE.bazel
(e.g. @score_toolchains_rust//toolchains/ferrocene:ferrocene_x86_64_unknown_linux_gnu)
already pin the coverage-tools tarball, so the toolchain registered for
regular builds is the one that produces coverage — same rustc, same LLVM.
The pipeline core was built in the communication repository
(eclipse-score/communication, Rust support merged with PR #772 and visible
in its nightly coverage reports), then ported to persistency and finally
centralized here. During the persistency port, the previous Rust mechanism
(Ferrocene symbol-report + blanket, per-target 90% gate — the flow this
module's removed rust_coverage_report rule drove) was run against the new
pipeline on the same commit. Percentages agreed within a few points on
most files (the two tools attribute lines differently: blanket is
symbol-oriented, llvm-cov counts every executable region), with one headline
difference:
A 311-line, 0%-covered Rust binary (
kvs_tool.rs, no tests) was invisible to the old pipeline while its 90% gate passed. The new baseline mechanism reports it at exact 0% and the effective-coverage gate accounts for it.
That gap — untested files silently missing from reports — is the main correctness argument for this pipeline, alongside unified C++ + Rust reporting and branch coverage for Rust.
Planned next step for the ecosystem: a reusable GitHub Actions workflow in
eclipse-score/cicd-workflows wrapping the collection + report + artifact
steps, so consumer repos add one uses: block instead of a hand-written
job.