Adoption guide#
score_coverage user manual
|
status: draft
security: NO
safety: ASIL_B
|
||||
What the pipeline provides#
One report for C++ and Rust (line and branch coverage), produced by
llvm-covdirectly from covmap instrumentation, without gcov or genhtml.Untested in-scope files appear at exact 0 %: all targets are instrumented at build time, and the reporter runs
llvm-cov --empty-profileover the archives of libraries no test links against. The denominators come from the compiler’s own coverage map, not from a source-text heuristic.Justifications:
COV_JUSTIFIEDin-code markers plus a YAML database turn intentionally uncovered lines into justified lines, tracked in an effective coverage metric with stale-justification detection.Gating: the report generator exits non-zero when the gated coverage is below
COVERAGE_THRESHOLD(default 100).
A complete working consumer setup is the integration_tests/ workspace of the
repository; every snippet below is taken from it.
Components#
Target |
Purpose |
|---|---|
|
Per-test coverage output generator (profraw to profdata plus object metadata). Referenced directly from the consumer’s bazelrc. |
|
Declares which targets are in scope; emits the source allowlist and the baseline-archive manifest through an aspect. |
|
Consumer-side wrapper wiring scope, workspace root and LLVM tools into the final report generator. |
|
Orchestration: unpacks the report, applies justifications, writes the summary, enforces the threshold, optionally archives. |
|
Standalone entry points of the justification and summary layer (called
in-process by |
|
|
Prerequisites#
A Bzlmod workspace (
MODULE.bazel).A Linux x86_64 host. The pipeline runs on the host platform; do not combine it with QNX or other cross-platform configs.
For Rust: a Ferrocene toolchain built by
ferrocene_toolchain_builder1.3.1 or newer, wired throughscore_toolchains_rust0.10.0 or newer. Its coverage-tools tarball shipsllvm-covandllvm-profdatabuilt from the same LLVM asrustc.
Step 1: depend on score_coverage#
bazel_dep(name = "score_coverage", version = "<version>")
Add one line to the root BUILD file so the reporter can locate the
workspace root at runtime:
exports_files(["MODULE.bazel"])
Step 2: declare the coverage toolchains#
bazel_dep(name = "score_toolchains_rust", version = "0.10.0", dev_dependency = True)
bazel_dep(name = "toolchains_llvm", version = "1.8.0", dev_dependency = True)
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm", dev_dependency = True)
llvm.toolchain(
cxx_standard = {"": "c++17"},
extra_known_features = ["@score_coverage//:enable_llvm_coverage_for_death_tests"],
llvm_version = "22.1.7",
stdlib = {"": "stdc++"},
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_llvm")
For Rust no coverage-specific toolchain is needed. Register the standard Ferrocene toolchain as usual:
common --extra_toolchains=@score_toolchains_rust//toolchains/ferrocene:ferrocene_x86_64_unknown_linux_gnu
rules_rust only instruments crates when the rust_toolchain declares
llvm_cov. A Ferrocene toolchain from an older score_toolchains_rust, or a
custom instance without coverage_tools_url, silently produces no Rust
coverage (see Constraints of use).
Step 3: declare scope and reporter#
In tools/coverage/BUILD:
load("@score_coverage//:defs.bzl", "score_coverage_reporter", "score_coverage_scope")
score_coverage_scope(
name = "coverage_scope",
testonly = True,
deps = [
"//src/mylib", # cc_library
"//src/rust/mycrate", # rust_library
"//src/rust/tool:tool", # rust_binary
],
)
score_coverage_reporter(
name = "reporter_wrapper",
testonly = True,
coverage_scope = ":coverage_scope",
llvm_cov = "@llvm_toolchain//:llvm-cov",
llvm_profdata = "@llvm_toolchain//:llvm-profdata",
llvm_cxxfilt = "@llvm_toolchain_llvm//:bin/llvm-cxxfilt",
)
The scope aspect walks the listed targets and their transitive in-workspace dependencies, collecting source files (allowlist) and compiled archives (baselines). Everything in scope but untested shows up at 0 %; everything outside the scope (tests, mocks, external dependencies) is filtered out of the report. The scope list is the written record of what is covered: a production library missing from it silently vanishes from the report, so every new library must be added, and exclusions need a written decision.
Step 4: import the bazelrc config#
Copy the coverage:llvm_cov block from integration_tests/.bazelrc into the
repository’s bazelrc, directly or via import. Place the import before any
try-import %workspace%/user.bazelrc: bazelrc resolves last-wins and the local
override file must stay last. The two labels to adapt:
coverage:llvm_cov --coverage_output_generator=@score_coverage//:merger
coverage:llvm_cov --coverage_report_generator=//tools/coverage:reporter_wrapper
Do not combine --config=llvm_cov with configs that append other
--extra_toolchains (for example a GCC host config): the last toolchain wins
resolution and a GCC toolchain produces no covmap data.
Set the instrumentation filter explicitly, to the module’s root package:
coverage:llvm_cov --instrumentation_filter=^//score[/:]
Left unset, Bazel guesses the filter from the packages of the test
targets and strips only a trailing /tests (plural) to reach the code
under test; bazel coverage prints the guess as Using default value for
--instrumentation_filter. A library whose tests live in a test
subpackage (score/os tested from score/os/test) is then outside the
filter. rules_cc still compiles it with counters when one of its direct deps
is instrumented, which hides the problem for most libraries, but a library
without such a dep is compiled without instrumentation and appears as
no-data. On the gcov backend the consequence is worse (see step 4b). The
reporters warn when the pattern is visible in the data (files without test
data whose directory is tested from a test/ or tests/ subdirectory).
Step 4b (optional): QNX on-target coverage, the gcov backend#
QCC is GCC-based and cannot emit LLVM coverage mapping, so QNX coverage uses
gcov counters. The tests run inside QEMU through score_qnx_unit_tests,
which brings the counters back; Bazel’s own collector turns them into LCOV;
score_coverage’s gcov reporter produces the same report zip as on Linux. C++
only: Rust sources in scope are listed as not instrumentable on this
backend and are measured by the Linux run.
Declare a second reporter next to the LLVM one, with the gcov binary of the
QCC package (add score_qcc_x86_64_toolchain_pkg to the use_repo of
the toolchain extension):
score_coverage_reporter(
name = "gcov_reporter_wrapper",
testonly = True,
backend = "gcov",
coverage_scope = ":coverage_scope",
gcov = "@score_qcc_x86_64_toolchain_pkg//:gcov",
tags = ["manual"], # keeps `bazel build //...` on a Linux host from fetching the QNX SDP
)
The manual tag matters: the target depends on the QNX SDP package, and a
wildcard build on a host without QNX credentials would otherwise fail on the
download. --coverage_report_generator names the target explicitly and is
not affected.
and a coverage config that resets the LLVM settings, keeps Bazel’s per-test
collector and points the final step at that reporter (copy and adapt the
coverage:gcov block of integration_tests/.bazelrc):
coverage:qnx --config=<your QNX build config> # QCC, IFS toolchain, platforms
coverage:qnx --run_under=@score_qnx_unit_tests//src:run_under_qnx
coverage:qnx --test_lang_filters=cc
coverage:qnx --instrument_test_targets
coverage:qnx --instrumentation_filter=^//score[/:]
coverage:qnx --noexperimental_use_llvm_covmap
coverage:qnx --noexperimental_generate_llvm_lcov
coverage:qnx --test_env=GENERATE_LLVM_LCOV --test_env=COVERAGE_GCOV_PATH --test_env=LLVM_PROFILE_CONTINUOUS_MODE
coverage:qnx --coverage_output_generator=@bazel_tools//tools/test:lcov_merger
coverage:qnx --coverage_report_generator=//tools/coverage:gcov_reporter_wrapper
The instrumentation filter is required on this backend, not only
advisable: Bazel’s own collector converts counters only for the targets
inside the filter, so a library outside Bazel’s guessed filter shows 0 % even
though its counters came back from the target (baselibs’ score/os: 13 %
on QNX against 80 % on Linux before the line was added; see
the instrumentation filter).
The LLVM-only rustc flags (-Zcoverage-options=branch and friends) stay
out of the way as long as they live in their own coverage:llvm_cov config,
as in Step 4. If your workspace puts them on the bare coverage command
instead, declare them with the list-typed
--@rules_rust//rust/settings:extra_rustc_flags and add
coverage:qnx --@rules_rust//rust/settings:extra_rustc_flags= to clear
them: an empty value resets that list, whereas the repeatable singular
extra_rustc_flag accumulates and cannot be reset from a config.
Run and report as on Linux, with the platform filter for justifications:
bazel coverage --config=qnx //score/... --build_tests_only
bazel run @score_coverage//:generate_coverage_html -- --platform qnx \
--yaml tools/coverage/coverage_justifications.yaml --archive-dir coverage_qnx_artifacts
Known differences to the LLVM backend: gcov has no lines for unused inline functions and for closing braces; gcov counts every conditional jump the compiler emits as a branch, including exception-handling edges, so branch percentages are structurally lower than LLVM’s; the report lists the toolchain’s line semantics, not LLVM’s, so the two reports are compared per file, not merged. Headers a workspace target vendors from an external repository are measured as long as the vendoring target is inside the instrumentation filter (Bazel’s collector keeps the sources of instrumented targets, external headers included).
Step 5 (optional): justifications#
tools/coverage/coverage_justifications.yaml:
version: 1
justifications:
- id: hw-unreachable-on-x86
category: platform_specific
platforms: [linux]
reason: |
ARM-only error path; cannot be exercised by x86 CI.
Mark the code in place:
return false; // COV_JUSTIFIED hw-unreachable-on-x86
// or a region:
// COV_JUSTIFIED_START hw-unreachable-on-x86
if (running_on_arm()) { ... }
// COV_JUSTIFIED_STOP
Valid categories: defensive_programming, tool_false_positive,
platform_specific, other. Ids are kebab-case. Justified lines render
orange in the HTML and count as covered in the effective metric. A
justification on a line that is meanwhile covered is flagged as stale. A
marker whose id is unknown is reported as a warning and does not count.
Step 6: run it#
bazel coverage --config=llvm_cov //... --build_tests_only
bazel run @score_coverage//:generate_coverage_html -- \
--yaml tools/coverage/coverage_justifications.yaml
# CI variant: HTML + LCOV + JUnit XMLs for artifact upload, gate at 95 %
COVERAGE_THRESHOLD=95 bazel run @score_coverage//:generate_coverage_html -- \
--yaml tools/coverage/coverage_justifications.yaml \
--archive-dir coverage_artifacts
--yaml is optional: without it, justification processing is skipped and the
gate applies to the raw line coverage computed from the LCOV data.
--build_tests_only is mandatory: without it, coverage builds every target
matched by the pattern, including manual-tagged or platform-incompatible test
binaries.
Inside GitHub Actions a markdown summary is appended to GITHUB_STEP_SUMMARY
automatically when --summary-md is absent. The summary is written before the
gate decides the exit code, so a failing gate still leaves it on the run page.
Command reference#
Option |
Effect |
|---|---|
|
Minimum gated coverage in percent (default 100). Effective line coverage
with |
|
Justification YAML, relative to the workspace root. |
|
Assemble HTML report, |
|
Same content as a local |
|
Subtree of |
|
Platform filter for justifications and default output directory
|
|
Write the markdown summary to |
|
HTML output directory (default |
Exit codes: 0 gate passed, 1 gate failed, 2 no verdict possible
(missing or non-zip report, invalid threshold, justification or tool failure).