Skip to content

coverage: replace the Ferrocene symbol-report/blanket flow with the reusable LLVM coverage pipeline - #394

Open
dcalavrezo-qorix wants to merge 3 commits into
mainfrom
dcalavrezo_llvm_coverage
Open

coverage: replace the Ferrocene symbol-report/blanket flow with the reusable LLVM coverage pipeline#394
dcalavrezo-qorix wants to merge 3 commits into
mainfrom
dcalavrezo_llvm_coverage

Conversation

@dcalavrezo-qorix

Copy link
Copy Markdown
Contributor

Summary

This PR makes @score_tooling//coverage the shared home of the LLVM source-based coverage pipeline already proven in communication (merged there via eclipse-score/communication#772, visible in its nightly reports) and ported to persistency (not pushed yet). It replaces the previous Ferrocene symbol-report/blanket workflow.

What the pipeline provides, in one report:

  • Unified C++ + Rust coverage (line and branch), generated by llvm-cov directly from covmap instrumentation — no gcov/genhtml, no per-language pipelines.
  • Untested in-scope files at exact 0%. Because --experimental_use_llvm_covmap instruments all targets at build time, archives of libraries that no test links against still carry the compiler's coverage map. The reporter runs llvm-cov --empty-profile over them, so untested files get exact line/branch denominators instead of silently disappearing from the report. (Under the old flow, a 311-line untested Rust binary in persistency was invisible while the 90% gate passed.)
  • Justifications: COV_JUSTIFIED in-code markers + a reviewed YAML database; an effective coverage metric ((covered + justified) / total) with stale-justification detection and a hard threshold gate (COVERAGE_THRESHOLD, default 100, exit 1 below).

Consumer API (full guide in coverage/README.md, mechanism deep-dive in coverage/COVERAGE_GUIDE.md, reference consumer in coverage/integration_tests/):

load("@score_tooling//coverage:defs.bzl", "score_coverage_reporter", "score_coverage_scope")

score_coverage_scope(name = "coverage_scope", testonly = True, deps = ["//src/mylib", "//src/rust/mycrate"])

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",
)

coverage:llvm_cov --coverage_output_generator=@score_tooling//coverage:merger
coverage:llvm_cov --coverage_report_generator=//tools/coverage:reporter_wrapper

LLVM/Ferrocene toolchains stay consumer-side (this repo's established dev-dependency convention); the Rust side requires score_toolchains_rust ≥ 0.10.0 .

⚠️ Breaking changes — propose releasing as 2.0.0

  • Removed: rust_coverage_report (from //:defs.bzl and //coverage:coverage.bzl), //coverage:ferrocene_report, ferrocene_report.sh, the symbol-report/blanket helper scripts and their tests. Migration notes are in coverage/README.md.
  • Kept unchanged: //coverage:llvm_profile_wrapper (still referenced by downstream ferrocene-coverage configs during migration) and //coverage:combined_report + @lcov_deb (this repo's own docs coverage, wired into deploy_docs.yml).

What we kept from @olivembo's score_cpp_policies#9

PR #9 explored centralizing the (pre-Rust) pipeline and its packaging layer is genuinely good — this PR adopts it:

  • The consumer-macro API shape: LLVM tools passed as labels (llvm_cov = "@llvm_toolchain//:llvm-cov"), so consumers bring their own toolchain version.
  • The self-derived-runfiles trick in the wrapper (details under gotcha 2 below).
  • The _rlocation_path() helper for building runfiles paths that span repositories.
  • The merger_lib/reporter_lib + imports = [".."] pattern making the pipeline scripts importable by plain py_tests (coverage/tests/).
  • The coverable/uncovered fixture pair for the integration workspace: a library whose test covers only some branches, and a library with no test at all — guaranteeing the report always contains covered, uncovered and 0%-file cases to assert against.
  • The README adoption-guide structure (component table → prerequisites → numbered steps → customization knobs → error-string→cause troubleshooting).
  • Reporter robustness fixes: LCOV SF:/HTML-title path relativization (portable reports), separate stderr capture so llvm-cov warnings can't corrupt LCOV output, and exit-0-with-empty-zip when a run produces no coverage data (matches Bazel's own behavior).

What we deliberately did not take, and why:

  • The heuristic untested-files mechanism (score_instrumented_sources_manifest + regex-based _count_instrumentable_lines + synthetic LCOV records). It estimates which lines are executable from source text, can't produce branch data, and keeps the estimates out of the gated totals (so a 100% gate passes with whole files untested). Under covmap instrumentation the estimate is unnecessary: the compiled archives of untested libraries exist and carry the compiler's own coverage map, so --empty-profile yields exact numbers that do count against the gate.
  • The merger's mandatory --llvm_profdata CLI argument. It replaced env-based discovery — but Rust coverage depends on it: rules_rust exports RUST_LLVM_PROFDATA (not LLVM_PROFDATA) to the coverage action, and the path needs resolution against the exec root. Our merger keeps the LLVM_PROFDATA → RUST_LLVM_PROFDATA fallback chain, which also means it needs no consumer wiring at all — --coverage_output_generator=@score_tooling//coverage:merger works as an external label directly.

Gotchas fixed (in detail)

These are the things that break exactly when the pipeline moves from an in-repo copy into a shared module — worth reading before reviewing the wrapper code:

  1. aspect_rules_lint was a dev_dependency — breaking use_format_targets for every consumer (separate commit, independent of coverage). third_party/format/macros.bzl does load("@aspect_rules_lint//format:defs.bzl", ...). A load() statement resolves repo names against the repo mapping of the module that owns the .bzl file — i.e. score_tooling's own mapping. Dev dependencies are dropped from that mapping whenever score_tooling is consumed as a dependency rather than built standalone, so any downstream repo calling use_format_targets() (public API per our README) failed with No repository visible as '@aspect_rules_lint' from repository '@@score_tooling+' — even if the consumer declares aspect_rules_lint itself (the consumer's mapping is irrelevant to score_tooling's load()). Our own CI never caught it because in this repo score_tooling is the root module, where dev deps are visible. Fix: make it a regular dependency.

  2. Runfiles paths break across module boundaries. The in-repo reporter_wrapper.bzl built runfiles paths as "_main/" + short_path — correct only while every file lives in the main repo. In the centralized setup one wrapper mixes files from three worlds: the consumer repo (MODULE.bazel, allowlist, baseline manifest → _main/...), score_tooling (the reporter binary → short_path starts with ../score_tooling+/...), and toolchain repos (llvm-cov etc.). The rewritten rule uses _rlocation_path() (strip ../ for external files, prepend workspace name for main-repo files) for every substituted path. Related: the launcher derives its own RUNFILES_DIR from $0 before falling back to the environment — Bazel invokes the coverage report generator from inside its coverage machinery where RUNFILES_DIR is already set pointing at the test's runfiles tree, so trusting the inherited value resolves every path against the wrong tree.

  3. runfiles.CurrentRepository() returns the wrong repo once the script moves. reporter.py resolved baseline-manifest entries via Rlocation(CurrentRepository() + "/" + line). CurrentRepository() answers "which repo does this Python file live in" — previously "" (main repo), now score_tooling+. But the manifest lists files built by the consumer, which is always the root module in a coverage run. The reporter now resolves those entries against _main explicitly (with a comment explaining why CurrentRepository() must not be used there).

  4. Hardcoded canonical repo names are a version trap. PR Fix offset mode #9's orchestration script resolves its helper binaries via hand-rolled runfiles lookups containing literal score_cpp_policies+/... paths. The canonical-name separator changed across Bazel releases (~ → +) and canonical names are explicitly not API — under local_path_override in a test workspace they differ again (plausibly why that script is broken in PR Fix offset mode #9's own tests/ workspace). Our generate_coverage_html.sh avoids runfiles entirely: it re-invokes bazel run @score_tooling//coverage:justify / :effective_coverage from the consumer workspace, where the apparent repo name is resolved by Bazel itself. Slightly slower, immune to the problem.

  5. LCOV/HTML relativization must not touch hrefs. When adopting PR Fix offset mode #9's HTML path relativization we found a blanket text replacement corrupts the report: llvm-cov's hrefs and on-disk layout embed the absolute source path without a leading slash (coverage/home/user/src/...), so replacing /home/user/src/ hits the middle of every link. The rewrite is therefore scoped to the source-name-title header text only; a unit test pins hrefs staying untouched.

For downstream adopters two more consumer-side gotchas are documented (hit while validating with persistency, fixes belong in the consumer repos): a git_override pinning trlc < 3.0.0 breaks loading current score_tooling (the registered rules_score Sphinx toolchain loads @trlc//:trlc.bzl symbols that only exist in 3.0.0, and a git_override forces its commit graph-wide); and copyright_checker/use_format_targets are no longer re-exported from //:defs.bzl (load from //cr_checker:cr_checker.bzl / //third_party/format:macros.bzl).

Validation

  • Unit tests (bazel test //coverage/tests:all): ar/rlib parsing incl. GNU long-name tables, rlib→.o expansion, the Rust ELF-manifest branch, external/ skipping, LCOV filtering/relativization, href-untouched HTML rewrite, ROOT-unset hard error.
  • Integration workspace (coverage/integration_tests/, own Bazel module, .bazelignored, new coverage entry in the tests.yml matrix): consumer-style setup exercising C++ + Rust end to end. Asserts: uncovered.cpp and rust/main.rs present at exact 0% (LH:0) in the LCOV; gate fails at threshold 100 and passes at 10; the justified line raises effective coverage above raw (58.82% → 61.76%). It also proves the two label-visibility assumptions: the external --coverage_output_generator label and the external cc_feature in llvm.toolchain(extra_known_features = ...).
  • Real-repo parity: persistency consuming this branch via local_path_override reproduces its in-repo pipeline results exactly — 94.15% lines / 90.74% branches raw, 86.95% effective (584 unjustified uncovered lines), 3/3 tests, kvs_tool.rs at 0%.

Follow-ups (separate PRs / repos)

  • Release as 2.0.0; switch persistency (and later communication) from their in-repo copies to this module.
  • Reusable llvm-coverage.yml workflow in cicd-workflows.
  • Coordinate with score_cpp_policies#9: the macros/tests/fixtures ideas are incorporated here; proposal is to continue the centralization effort in this module.

…LLVM pipeline

Centralize the LLVM source-based coverage pipeline (proven in
eclipse-score/communication and the persistency port) as the reusable
coverage module of score_tooling:

- Unified C++ + Rust coverage (line + branch) via llvm-cov directly:
  custom --coverage_output_generator (merger) and
  --coverage_report_generator (reporter) replacing gcov/genhtml.
- Untested in-scope files appear at exact 0% through llvm-cov
  --empty-profile baselines over covmap-instrumented archives
  (including rlib expansion for Rust).
- Justification system (COV_JUSTIFIED markers + YAML) with effective
  coverage metric, stale detection and threshold gating.
- Consumer API: score_coverage_scope + score_coverage_reporter macros
  (coverage/defs.bzl); the reporter_wrapper resolves runfiles across
  module boundaries and receives the consumer's LLVM tool labels.
- Unit tests (coverage/tests) and a consumer-style integration
  workspace (coverage/integration_tests) run in CI; the integration
  test asserts exact-0% entries for untested C++ and Rust files, gate
  behavior at high/low thresholds and the justification round-trip.
- Adoption guide (coverage/README.md) and mechanism deep-dive
  (coverage/COVERAGE_GUIDE.md).

BREAKING: rust_coverage_report, //coverage:ferrocene_report and the
symbol-report/blanket helper scripts are removed. llvm_profile_wrapper
and //coverage:combined_report are kept unchanged.
use_format_targets (third_party/format/macros.bzl) is public consumer API
and load()s @aspect_rules_lint//format:defs.bzl; as a dev_dependency the
repo is invisible to score_tooling's repo mapping when downstream modules
consume the macro, breaking every consumer of use_format_targets.
…t coverage

The integration workspace (and the adoption guide) declared a second
Ferrocene instance whose only purpose was attaching the coverage-tools
tarball — a leftover from before score_toolchains_rust 0.10.0. The
standard toolchains in score_toolchains_rust's own MODULE.bazel already
pin the ferrocene_toolchain_builder 1.3.1 coverage-tools (same LLVM tree
as rustc) including the required link flags, so the toolchain registered
for regular builds is the one that produces coverage.

Consumer MODULE.bazel footprint for Rust coverage drops to zero; only
the standard toolchain registration remains:
  common --extra_toolchains=@score_toolchains_rust//toolchains/ferrocene:ferrocene_x86_64_unknown_linux_gnu

Integration checks re-validated unchanged (0% baselines, gate, justification).
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Coverage Report

Coverage report was generated.

Full report can be downloaded from the CI artifacts (expand Artifacts at the bottom of the run).

Overall coverage rate:

lines......: 86.6%
functions......: 54.0%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant