diff --git a/.github/cache-strategy.md b/.github/cache-strategy.md index 4352d1225b..7cb80aa8cb 100644 --- a/.github/cache-strategy.md +++ b/.github/cache-strategy.md @@ -287,7 +287,7 @@ All workflows that save or delete caches already declare this permission. | `_thread_sanitizer.yml` | `build_and_test_tsan` | | `_address_sanitizer.yml` | `build_and_test_asan_ubsan_lsan` | | `_linter.yml` | (one per linter bazel-config) | -| `_coverage_report.yml` | `coverage_report` | +| `_coverage_report.yml` | `coverage_report` (Linux); QNX variant runs without disk cache (`disk-cache: ""`), gated on QNX secret availability like `_codeql.yml` | | `_codeql.yml` | _(none — empty)_ | | `build_and_test_qnx.yml` | `build_and_test_qnx` | | `deploy_docs.yml` | `build_docs` | diff --git a/.github/workflows/_coverage_report.yml b/.github/workflows/_coverage_report.yml index 7fe910a672..f959b356d8 100644 --- a/.github/workflows/_coverage_report.yml +++ b/.github/workflows/_coverage_report.yml @@ -27,10 +27,16 @@ on: secrets: UBUNTU_SNAPSHOT_MIRROR_URL: required: false + SCORE_QNX_LICENSE: + required: false + SCORE_QNX_USER: + required: false + SCORE_QNX_PASSWORD: + required: false jobs: - coverage: - name: Build & Test + coverage-linux: + name: Build & Test (Linux) runs-on: ubuntu-24.04 permissions: contents: read @@ -57,9 +63,14 @@ jobs: if: inputs.fetch-only != 'true' && steps.run-coverage.outcome == 'success' id: generate-report run: | - bazel run --config=ci //quality/coverage:generate_coverage_html -- \ + bazel run --config=ci @score_coverage//:generate_coverage_html -- \ + --yaml quality/coverage/coverage_justifications.yaml \ + --testlogs-subdir score \ --archive "${{ github.event.repository.name }}_coverage_report_${{ github.sha }}" \ cpp_coverage_linux + env: + # Report-only: the effective-coverage gate is not enforced yet. + COVERAGE_THRESHOLD: "0" - name: Upload coverage artifacts if: inputs.fetch-only != 'true' && steps.generate-report.outcome == 'success' @@ -67,3 +78,76 @@ jobs: with: name: ${{ github.event.repository.name }}_coverage_report_${{ github.sha }} path: ${{ github.event.repository.name }}_coverage_report_${{ github.sha }}.zip + + # Whether to additionally collect the --config=qnx coverage is derived + # automatically from secret availability rather than a caller-supplied + # flag: it only runs when the QNX license secret has actually been + # provided (e.g. not forwarded by cache-warming callers). + determine-qnx: + name: Determine QNX availability + runs-on: ubuntu-24.04 + outputs: + available: ${{ steps.qnx.outputs.available }} + steps: + - id: qnx + run: | + if [[ -n "${{ secrets.SCORE_QNX_LICENSE }}" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + coverage-qnx: + name: Build & Test (QNX, --config=qnx) + needs: [determine-qnx] + if: inputs.fetch-only != 'true' && needs.determine-qnx.outputs.available == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@v6.0.2 + + - name: Setup QNX and QEMU environment + uses: ./.github/actions/00_infrastructure/setup_qnx_environment + with: + qnx-license: ${{ secrets.SCORE_QNX_LICENSE }} + + - uses: ./.github/actions/00_infrastructure/prepare_bazel_environment + with: + disk-cache: "" + cache-mode: ${{ inputs.cache-mode }} + ubuntu-snapshot-mirror-url: ${{ secrets.UBUNTU_SNAPSHOT_MIRROR_URL }} + + - name: Run coverage (QNX, --config=qnx) + id: run-coverage-qnx + env: + SCORE_QNX_USER: ${{ secrets.SCORE_QNX_USER }} + SCORE_QNX_PASSWORD: ${{ secrets.SCORE_QNX_PASSWORD }} + run: bazel coverage --config=ci --config=qnx //score/... --build_tests_only + + - name: Generate coverage report (QNX) + if: steps.run-coverage-qnx.outcome == 'success' + id: generate-report-qnx + env: + # Report-only: the effective-coverage gate is not enforced yet. + COVERAGE_THRESHOLD: "0" + run: | + # Note: no --config=qnx here. This step only reads the coverage + # data produced by the previous "bazel coverage" step and runs a + # host-side Python tool; --config=qnx sets the *target* platform to + # QNX for the whole invocation, and no Python toolchain is + # registered for that platform, so the build fails to resolve one. + bazel run --config=ci @score_coverage//:generate_coverage_html -- \ + --platform qnx \ + --yaml quality/coverage/coverage_justifications.yaml \ + --testlogs-subdir score \ + --archive "${{ github.event.repository.name }}_coverage_report_qnx_${{ github.sha }}" \ + cpp_coverage_qnx + + - name: Upload coverage artifacts (QNX) + if: steps.generate-report-qnx.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}_coverage_report_qnx_${{ github.sha }} + path: ${{ github.event.repository.name }}_coverage_report_qnx_${{ github.sha }}.zip diff --git a/.github/workflows/nightly_quality.yml b/.github/workflows/nightly_quality.yml index a8e40a9503..81cbd3e78a 100644 --- a/.github/workflows/nightly_quality.yml +++ b/.github/workflows/nightly_quality.yml @@ -14,19 +14,21 @@ # Workflow: Run nightly quality jobs and publish results to GitHub Pages. # # Runs every night at midnight UTC: -# - Coverage : full C++ test suite with llvm-cov → HTML report +# - Coverage : full C++ test suite with llvm-cov (Linux) and gcov (QNX, when +# QNX secrets are available) → HTML reports # - Clang-Tidy: static analysis across all C++ targets → SARIF findings # - Clippy : static analysis across all Rust targets → SARIF findings # - CodeQL : MISRA C++ compliance analysis → CSV findings # # After all jobs complete, deploy-quality-reports: -# 1. Downloads the coverage HTML artifact, clang-tidy/clippy SARIF findings, and CodeQL CSV +# 1. Downloads the coverage HTML artifacts (Linux + QNX), clang-tidy/clippy SARIF findings, and CodeQL CSV # 2. Runs `bazel run //quality/dashboard:generate_dashboard` to produce the KPI dashboard # 3. Uploads everything as nightly-quality-reports artifact for docs.yml to deploy # # Deployed URL structure: # https://eclipse-score.github.io/communication/latest/quality/index.html ← dashboard -# https://eclipse-score.github.io/communication/latest/quality/coverage/index.html ← lcov HTML +# https://eclipse-score.github.io/communication/latest/quality/coverage/index.html ← lcov HTML (Linux) +# https://eclipse-score.github.io/communication/latest/quality/coverage_qnx/index.html ← gcov HTML (QNX) # https://eclipse-score.github.io/communication/latest/quality/clang-tidy.sarif ← clang-tidy SARIF # https://eclipse-score.github.io/communication/latest/quality/clippy.sarif ← clippy SARIF # https://eclipse-score.github.io/communication/latest/quality/codeql/index.html ← CodeQL report @@ -65,6 +67,9 @@ jobs: actions: write secrets: UBUNTU_SNAPSHOT_MIRROR_URL: ${{ secrets.UBUNTU_SNAPSHOT_MIRROR_URL }} + SCORE_QNX_LICENSE: ${{ secrets.SCORE_QNX_LICENSE }} + SCORE_QNX_USER: ${{ secrets.SCORE_QNX_USER }} + SCORE_QNX_PASSWORD: ${{ secrets.SCORE_QNX_PASSWORD }} # -------------------------------------------------------------------- # Quality job 2: Linters @@ -140,6 +145,33 @@ jobs: /tmp/coverage_zip \ "${GITHUB_WORKSPACE}/_quality/coverage" + # ------------------------------------------------------------------ + # Download QNX Coverage artifact. This is a best-effort step: the + # inner coverage-qnx job only runs when QNX secrets were forwarded + # to _coverage_report.yml, so the artifact may legitimately not + # exist (continue-on-error, matching the clang-tidy/clippy pattern). + # ------------------------------------------------------------------ + - name: Download QNX coverage artifact + id: download-coverage-qnx + if: needs.run-coverage.result == 'success' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: ${{ github.event.repository.name }}_coverage_report_qnx_${{ github.sha }} + path: /tmp/coverage_zip_qnx + + # ------------------------------------------------------------------ + # Extract QNX coverage artifact to a known location alongside the + # Linux report for docs.yml to deploy. + # ------------------------------------------------------------------ + - name: Extract QNX coverage HTML report + if: steps.download-coverage-qnx.outcome == 'success' + run: | + bash quality/scripts/extract_coverage_artifact.sh \ + /tmp/coverage_zip_qnx \ + "${GITHUB_WORKSPACE}/_quality/coverage_qnx" \ + cpp_coverage_qnx + # ------------------------------------------------------------------ # Download clang-tidy findings artifact # ------------------------------------------------------------------ @@ -231,6 +263,7 @@ jobs: # absent files gracefully (shows N/A for that metric). bazel run --config=ci //quality/dashboard:generate_dashboard -- \ --lcov /tmp/coverage_zip/extracted/artifacts/coverage_report.dat \ + --lcov-qnx /tmp/coverage_zip_qnx/extracted/artifacts/coverage_report.dat \ --clang-tidy /tmp/clang_tidy/clang-tidy.sarif \ --clippy /tmp/clippy/clippy.sarif \ --codeql "${GITHUB_WORKSPACE}/_quality/codeql_findings.sarif" \ diff --git a/MODULE.bazel b/MODULE.bazel index 5f5535a1a9..67b9923c90 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -157,6 +157,7 @@ use_repo( "score_qcc_x86_64_toolchain_pkg", ) +bazel_dep(name = "score_coverage", version = "0.3.1", dev_dependency = True) bazel_dep(name = "score_itf", version = "0.5.0", dev_dependency = True) # Use pinned score_itf fork commit for integration testing changes. @@ -256,7 +257,7 @@ llvm.toolchain( "@score_cpp_policies//sanitizers/features:lsan", "@score_cpp_policies//sanitizers/features:tsan", "//quality/compiler_warnings:treat_warnings_as_errors", - "//quality/coverage:enable_llvm_coverage_for_death_tests", + "@score_coverage//:enable_llvm_coverage_for_death_tests", ], link_libs = {"": [ "-lrt", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 39bdc0afa6..e9f8c34eec 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1050,6 +1050,8 @@ "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_bazel_platforms/0.1.2/MODULE.bazel": "d1889bf36241521c5d5c401aaf1e98242a60f9cab9d223f8190e5d7087956e83", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_bazel_platforms/1.1.0/MODULE.bazel": "e5c656abcad121a89cbabad3550bb20167a6865de219ef6e1c68ca30567e1c32", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_bazel_platforms/1.1.0/source.json": "9797d56e9dd68e9d3e8b674402df2d6d5e2174f84184ca4cbee78fc07dc117d9", + "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_coverage/0.3.1/MODULE.bazel": "e11bb5c649f1c42b2e6933d0c297cb44c45d806d61d9fa71b1185830d284edac", + "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_coverage/0.3.1/source.json": "328c3bb04ec50bb2cbd81070af41ac7247022cb006724340e251c73159e369b4", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_cpp_policies/0.1.1/MODULE.bazel": "62968745a2fe77c449d4e11447b50918c58f870eeac72f0f3198c63617a2f8a2", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_cpp_policies/0.1.1/source.json": "29d45fbd6f3fae0a21487f5d652dfa0796bd2095d2a19732a5456e5b232754a9", "https://raw.githubusercontent.com/eclipse-score/bazel_registry/refs/heads/main/modules/score_cr_checker/0.2.2/MODULE.bazel": "dc36d9c35543db918c3fb5b93a8e684431f56c7c784cf2a1b90f35802a373c98", diff --git a/bazel/rules/generate_quality_links.bzl b/bazel/rules/generate_quality_links.bzl index a12442d5e8..9d2402687a 100644 --- a/bazel/rules/generate_quality_links.bzl +++ b/bazel/rules/generate_quality_links.bzl @@ -61,6 +61,7 @@ def _generate_quality_links_impl(ctx): ) release_coverage_asset_url = _release_asset_url(docs_base_url, docs_version, "coverage_report") + release_coverage_qnx_asset_url = _release_asset_url(docs_base_url, docs_version, "coverage_report_qnx") release_clang_tidy_asset_url = _release_asset_url(docs_base_url, docs_version, "clang_tidy_report") release_codeql_asset_url = _release_asset_url(docs_base_url, docs_version, "codeql_report") release_dashboard_asset_url = _release_asset_url(docs_base_url, docs_version, "quality_dashboard") @@ -68,6 +69,7 @@ def _generate_quality_links_impl(ctx): if docs_version == "latest": # quality reports are published alongside the latest/ docs coverage_ref = "`Coverage report `__" + coverage_qnx_ref = "`Coverage report (QNX) `__" dashboard_ref = "`Quality Dashboard `__" clang_tidy_ref = "`Clang-Tidy report `__" codeql_ref = "`CodeQL findings `__" @@ -87,6 +89,14 @@ def _generate_quality_links_impl(ctx): else: coverage_ref = ("`Coverage report (latest) <" + latest + "/quality/coverage/index.html>`__") + if release_coverage_qnx_asset_url: + coverage_qnx_ref = ( + "`Coverage report (QNX, release artifact) <" + + release_coverage_qnx_asset_url + ">`__" + ) + else: + coverage_qnx_ref = ("`Coverage report (QNX, latest) <" + latest + + "/quality/coverage_qnx/index.html>`__") if release_dashboard_asset_url: dashboard_ref = ( "`Quality Dashboard (release artifact) <" + @@ -129,7 +139,11 @@ def _generate_quality_links_impl(ctx): # local build — no published reports; show the equivalent bazel command coverage_ref = ( "*local build* — run " + - "``bazel run //quality/coverage:generate_coverage_html``" + "``bazel run @score_coverage//:generate_coverage_html``" + ) + coverage_qnx_ref = ( + "*local build* — run " + + "``bazel run @score_coverage//:generate_coverage_html -- --platform qnx``" ) dashboard_ref = ( "*local build* — dashboard only available on GitHub Pages" @@ -153,6 +167,7 @@ def _generate_quality_links_impl(ctx): content = ( ":orphan:\n\n" + ".. |coverage_report_link| replace:: " + coverage_ref + "\n" + + ".. |coverage_qnx_report_link| replace:: " + coverage_qnx_ref + "\n" + ".. |quality_dashboard_link| replace:: " + dashboard_ref + "\n" + ".. |clang_tidy_report_link| replace:: " + clang_tidy_ref + "\n" + ".. |codeql_report_link| replace:: " + codeql_ref + "\n" + diff --git a/docs/sphinx/quality_reports.rst b/docs/sphinx/quality_reports.rst index b6ecf71cad..a220a1c66d 100644 --- a/docs/sphinx/quality_reports.rst +++ b/docs/sphinx/quality_reports.rst @@ -28,8 +28,13 @@ nightly run of the `Nightly Quality Jobs`_ workflow. - Description - Report * - Coverage - - Line, function, and branch coverage from C++ unit tests (gcov/lcov) + - Line, function, and branch coverage from C++ unit tests (Linux, llvm-cov/lcov) - |coverage_report_link| + * - Coverage (QNX) + - Line, function, and branch coverage from C++ unit tests built for QNX + (``--config=qnx``, gcov/lcov). Only generated when QNX secrets are + available. + - |coverage_qnx_report_link| * - Clang-Tidy - Static analysis findings (errors and warnings) across all C++ targets - |clang_tidy_report_link| @@ -51,7 +56,7 @@ nightly run of the `Nightly Quality Jobs`_ workflow. exclusively alongside the ``latest`` documentation on GitHub Pages. In local Sphinx builds the link cells above show equivalent ``bazel run`` commands. In versioned release docs, every quality link (coverage, - dashboard, clang-tidy, and CodeQL) points to the corresponding report - uploaded as a release artifact for that release. + coverage (QNX), dashboard, clang-tidy, and CodeQL) points to the + corresponding report uploaded as a release artifact for that release. .. _Nightly Quality Jobs: https://github.com/eclipse-score/communication/actions/workflows/nightly_quality.yml diff --git a/quality/coverage/BUILD b/quality/coverage/BUILD index 50535c9c66..cc7f27910b 100644 --- a/quality/coverage/BUILD +++ b/quality/coverage/BUILD @@ -10,73 +10,44 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* - -load("@rules_cc//cc/toolchains:args.bzl", "cc_args") -load("@rules_cc//cc/toolchains:feature.bzl", "cc_feature") -load("@rules_python//python:defs.bzl", "py_binary") -load("@rules_shell//shell:sh_binary.bzl", "sh_binary") -load("//quality/coverage:coverage_scope.bzl", "coverage_scope") - -sh_binary( - name = "generate_coverage_html", - srcs = ["generate_coverage_html.sh"], - visibility = ["//visibility:private"], -) - -py_binary( - name = "justify", - srcs = ["justify.py"], - deps = [ - "@score_communication_pip//pyyaml", - ], -) - -py_binary( - name = "effective_coverage", - srcs = ["effective_coverage.py"], -) - -py_binary( - name = "lcov_to_html", - srcs = ["lcov_to_html.py"], - deps = [ - "@score_communication_pip//gcovr", - ], -) - -# These compile time options are required to cover abnormal termination cases. In GCC one can use `__gcc_dump()`, but this does not work with LLVM. -# LLVM provides these compile-time options in combination with a specific profile setting which is enabled in bazel via `LLVM_PROFILE_CONTINUOUS_MODE`. -# See coverage.bazelrc -cc_args( - name = "runtime_relocation_args", - actions = [ - "@rules_cc//cc/toolchains/actions:compile_actions", - "@rules_cc//cc/toolchains/actions:link_actions", - ], - args = [ - "-mllvm", - "-runtime-counter-relocation", - ], -) - -cc_feature( - name = "enable_llvm_coverage_for_death_tests", - args = [":runtime_relocation_args"], - feature_name = "enable_llvm_coverage_for_death_tests", - visibility = ["//visibility:public"], -) - -coverage_scope( +load("@score_coverage//:defs.bzl", "score_coverage_reporter", "score_coverage_scope") + +# Coverage scope: the dependable elements of the module. The aspect walks the +# component / unit hierarchy into the implementation libraries and their +# transitive in-workspace deps; everything else (tests, mocks, external +# dependencies) is filtered out of the report. A production library missing +# here silently vanishes from the report, so keep this list complete. +score_coverage_scope( name = "coverage_scope", testonly = True, visibility = ["//quality/coverage:__subpackages__"], deps = [ "//score/message_passing", "//score/mw/com", - # Public Rust API crate. Not reachable through the dependable_element - # indices above (those traverse C++ components only). The scope aspect - # walks its transitive deps, which cover all Rust production crates - # (score_com_concept, runtime-lola, ffi-lola, plumbing). "//score/mw/com/rust:score_com", ], ) + +# Linux: LLVM source-based coverage (C++ and Rust). Selected by the default +# coverage config in coverage.bazelrc. +score_coverage_reporter( + name = "reporter_wrapper", + testonly = True, + coverage_scope = ":coverage_scope", + llvm_cov = "@llvm_toolchain//:llvm-cov", + llvm_cxxfilt = "@llvm_toolchain_llvm//:bin/llvm-cxxfilt", + llvm_profdata = "@llvm_toolchain//:llvm-profdata", +) + +# QNX on target: QCC is GCC-based, so the counters are gcov's. Selected by +# --config=qnx under bazel coverage. C++ only. Manual: the target depends on +# the QNX SDP package, which a wildcard build on a Linux host without QNX +# credentials must not try to fetch. +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"], +) diff --git a/quality/coverage/README.md b/quality/coverage/README.md index 0ca48ba511..b120e4b949 100644 --- a/quality/coverage/README.md +++ b/quality/coverage/README.md @@ -1,288 +1,42 @@ -# Coverage Infrastructure - -This directory contains the tooling to generate, post-process, and report C++ code coverage for the Score Communication project. It supports two platforms: -- **Linux**: LLVM source-based coverage (`llvm-cov`) -- **QNX**: gcov-based coverage via gcovr - -## Overview - -``` -quality/coverage/ -├── README.md ← You are here -├── BUILD ← Bazel targets for coverage tools -├── coverage.bazelrc ← Bazel coverage configuration flags -├── coverage_justifications.yaml ← Central justification database -├── generate_coverage_html.sh ← Orchestrator script (entry point) -├── effective_coverage.py ← HTML post-processor & effective coverage calculator -├── justify.py ← Justification resolver -├── lcov_to_html.py ← LCOV→HTML converter (uses gcovr, for QNX) -└── llvm_cov/ ← LLVM-specific coverage tools (Linux) - ├── README.md ← Detailed LLVM tool documentation - ├── BUILD ← Bazel targets for LLVM tools - ├── merger.py ← Per-test coverage output generator - ├── reporter.py ← Final combined report generator - └── filter_regexes.txt ← Source filtering patterns for llvm-cov -``` - -## Requirements - -The coverage pipeline was built to satisfy the following requirements: - -### REQ-COV-001: Native llvm-cov HTML Reports - -Coverage reports **must** be generated directly by `llvm-cov show` using LLVM's source-based coverage (`--experimental_use_llvm_covmap`). No intermediate LCOV-to-HTML conversion (genhtml) is used. This provides accurate source-level coverage including branch and expansion views. - -### REQ-COV-002: Instrumentation Filtering - -Only project source code under `//score/message_passing` and `//score/mw/com` shall be instrumented and reported. Tests, benchmarks, and external/third-party code must be excluded from the report. - -> **Note:** `--experimental_use_llvm_covmap` causes Bazel to instrument ALL targets regardless of `--instrumentation_filter`. Actual source filtering is enforced by `--ignore-filename-regex` in the merger and reporter. See `coverage.bazelrc` for details. - -### REQ-COV-003: Coverage Justification Infrastructure - -A YAML-based justification system must allow developers to "argue" non-covered lines and branches to achieve 100% effective coverage. Justified lines must: -- Be tracked in a central YAML file with unique IDs, categories, and rationale -- Optionally be referenced from code via `COV_JUSTIFIED` markers -- Appear visually distinct (yellow/orange) in the HTML report -- Be reflected in both per-file and total coverage percentages - -### REQ-COV-004: Effective Coverage Calculation - -The system must calculate and display: -- **Raw coverage**: actual lines/branches hit ÷ total instrumented lines/branches -- **Effective coverage**: (hit + justified) ÷ total - -Both line and branch effective coverage must be shown in the summary, per-file index table, and totals row. - -### REQ-COV-005: Stale Justification Detection - -Justifications for lines/branches that are actually covered by tests must be detected and reported as stale warnings, enabling cleanup. - -### REQ-COV-006: Template Instantiation Handling - -For C++ templates with multiple instantiations, a line or branch is considered "covered" if ANY instantiation covers it (consistent with llvm-cov semantics). This prevents inflated totals from repeated template expansions. - -### REQ-COV-007: Threshold Enforcement - -The pipeline must support a configurable effective coverage threshold (default: 100%) and emit a warning when coverage falls below it. - -## Quick Start - -### 1. Run Coverage Collection - -```bash -# Full project (Linux, LLVM source-based coverage) -bazel coverage //... - -# Full project (QNX x86_64, gcov-based coverage) -bazel coverage //score/... --config=qnx - -# Specific target (Linux) -bazel coverage //score/message_passing:client_connection_test - -# Specific target (QNX) -bazel coverage --config=qnx //score/message_passing:client_connection_test -``` - -### 2. Generate the HTML Report - -```bash -# Linux coverage report (default) -bazel run //quality/coverage:generate_coverage_html - -# QNX coverage report -bazel run //quality/coverage:generate_coverage_html -- --platform qnx -``` - -For Linux (LLVM pipeline), the custom reporter produces a zip with an HTML report directly. -For QNX (gcov pipeline), the script first resolves the collected coverage allowlist, then converts the LCOV data to HTML using `lcov_to_html.py` (which internally uses gcovr). - -The report is written to `cpp_coverage/` (Linux) or `cpp_coverage_qnx/` (QNX). Open it: - -```bash -# Linux -xdg-open cpp_coverage_linux/index.html - -# QNX -xdg-open cpp_coverage_qnx/index.html -``` - -### 3. Create an Archive (CI) - -```bash -# Linux -bazel run //quality/coverage:generate_coverage_html -- --archive coverage-report - -# QNX -bazel run //quality/coverage:generate_coverage_html -- --platform qnx --archive coverage-report-qnx -``` - -Creates a zip archive containing the HTML report, LCOV data, and JUnit XML test results. - -## Coverage Pipelines - -The project supports two coverage pipelines depending on the target platform: - -| Pipeline | Config | Compiler | Tool | HTML Generation | -|----------|--------|----------|------|-----------------| -| **LLVM** (Linux) | *(default)* | Clang/LLVM | llvm-cov, llvm-profdata | Custom reporter (llvm-cov show) | -| **gcov** (QNX) | `--config=qnx` | QCC (GCC-based) | gcov | lcov_to_html.py (gcovr) | - -## Pipeline Architecture - -The coverage pipeline has two phases. Phase 1 differs by platform; Phase 2 is shared. - -### Phase 1: Bazel Coverage Collection - -**LLVM pipeline** (Linux, default): - -``` -bazel coverage //... - │ - ├── Per-test: merger.py (--coverage_output_generator) - │ • Receives .profraw files from test execution - │ • Merges into .profdata via llvm-profdata - │ • Packages profdata + metadata into a zip - │ - └── Final: reporter.py (--coverage_report_generator) - • Merges per-test profdata artifacts into a single merged profdata and uses `llvm-cov --empty-profile` for baseline-only archives. - • Runs llvm-cov show → HTML report - • Runs llvm-cov export → LCOV data - • Runs llvm-cov report → text summary - • Packages everything into _coverage_report.dat (zip) -``` - -**gcov pipeline** (QNX, `--config=qnx`): - -``` -bazel coverage //score/... --config=qnx - │ - ├── Per-test: Bazel default merger - │ • Runs gcov on .gcda/.gcno files - │ • Produces LCOV data per test - │ - └── Final: Bazel default reporter - • Merges all per-test LCOV data - • Writes combined LCOV to _coverage_report.dat (text) -``` - -### Phase 2: Report Extraction & Justification - -``` -bazel run //quality/coverage:generate_coverage_html [-- --platform ] - │ - └── generate_coverage_html.sh - ├── Detect format: zip (LLVM) or LCOV text (gcov) - ├── LLVM: Extract HTML from zip → cpp_coverage_linux/ - │ gcov: Build collected allowlist + merge baseline_coverage.dat inputs → convert LCOV → HTML via lcov_to_html.py (gcovr) → cpp_coverage_qnx/ - ├── justify.py: YAML + code markers → manifest.json - ├── effective_coverage.py: Post-process HTML + calculate effective % - └── Print summary + threshold check -``` - -For QNX, the allowlist from `quality/coverage:coverage_scope` is the authoritative file scope for the HTML report. Regex-based file filters are no longer used in that path. - -## Configuration - -### coverage.bazelrc - -The configuration is split into platform-agnostic settings (applied globally) and -platform-specific sections: - -**Platform-agnostic flags** (apply to all coverage runs): - -| Flag | Purpose | -|------|---------| -| `--instrumentation_filter` | Documents intended scope | -| `--cxxopt=-O0` | Disable optimizations for accurate coverage | -| `--dynamic_mode=off` | Static linking to avoid coverage conflicts | - -**LLVM-specific flags** (Linux, default): - -| Flag | Purpose | -|------|---------| -| `--experimental_use_llvm_covmap` | Use LLVM source-based coverage (not gcov) | -| `--coverage_output_generator` | Points to `merger.py` for per-test processing | -| `--coverage_report_generator` | Points to `reporter.py` for final aggregation | -| `--test_env=LLVM_PROFILE_CONTINUOUS_MODE=1` | Enables profiling of abnormal terminations | -| `-mllvm -runtime-counter-relocation` | Required for continuous-mode profiling with LLVM | - -**QNX-specific flags** (`--config=qnx`): - -| Flag | Purpose | -|------|---------| -| `--noexperimental_fetch_all_coverage_outputs` | Disables tree artifact validation (avoids dangling gcov symlink) | - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `COVERAGE_THRESHOLD` | `100` | Minimum effective line coverage % (warning if below) | - -## Coverage Justifications - -See [`coverage_justifications.yaml`](coverage_justifications.yaml) for the justification database and [`llvm_cov/README.md`](llvm_cov/README.md) for detailed documentation of the justification tools. - -### Adding a Justification - -1. **Via YAML** — add an entry to `coverage_justifications.yaml`: - -```yaml -justifications: - - id: my-unique-id # kebab-case, must be unique - category: defensive_programming # or: tool_false_positive, platform_specific, other - platforms: [linux, qnx] # required: choose one or more target platforms - reason: > - Explanation of why these lines cannot be covered by tests. - locations: - - file: score/mw/com/impl/some_file.cpp - line_start: 42 - line_end: 45 -``` - -2. **Via code markers** — reference the ID from source (no `locations` needed in YAML): - -```cpp -unreachable_code(); // COV_JUSTIFIED my-unique-id - -// COV_JUSTIFIED_START my-unique-id -defensive_block(); -more_defensive_code(); -// COV_JUSTIFIED_STOP -``` - -Both methods can be combined. A justification covers both the line and any branches on that line. - -We strongly suggest though to use the in-code marker where possible, as this better supports refactorings and avoids -better that justifications get outdated. - -### Justification Categories - -| Category | Use Case | -|----------|----------| -| `defensive_programming` | Unreachable code kept as safety guard (e.g., default case in exhaustive switch) | -| `tool_false_positive` | Coverage tool incorrectly marks line as uncovered | -| `platform_specific` | Code path only reachable on platforms not under test | -| `other` | Any other valid reason | - -### Platform Scoping - -Each justification must specify which platforms it applies to via the `platforms` field: - -| Value | Meaning | -|-------|---------| -| `[linux]` | Only applies when generating Linux coverage | -| `[qnx]` | Only applies when generating QNX coverage | -| `[linux, qnx]` | Applies to both platforms | - -The `--platform` argument to `generate_coverage_html.sh` controls which justifications are included. - -### Visual Indicators in HTML Report - -| Color | Meaning | -|-------|---------| -| **Green** | Covered by tests | -| **Red** | Not covered (needs tests or justification) | -| **Yellow/Orange** | Justified — not covered but argued with rationale | - -The index page shows a banner with overall effective coverage and updates per-file percentages in the table to reflect justifications. +# Code coverage + +Coverage is produced by the qualified S-CORE coverage tool, `score_coverage` +(https://eclipse-score.github.io/coverage_tool/main/). This directory only +holds what is specific to this repository: + +| File | Role | +|---|---| +| `BUILD` | the coverage scope (which dependable elements are measured) and the two report generators: LLVM for Linux, gcov for QNX | +| `coverage.bazelrc` | the `bazel coverage` configuration, imported by the root `.bazelrc` | +| `coverage_justifications.yaml` | reviewed justifications for lines that cannot be covered by tests | + +## Running + +```sh +# Linux, C++ and Rust (LLVM source-based coverage) +bazel coverage //... --build_tests_only +bazel run @score_coverage//:generate_coverage_html -- \ + --yaml quality/coverage/coverage_justifications.yaml --testlogs-subdir score + +# QNX x86_64 on target, C++ (gcov counters brought back from QEMU) +bazel coverage --config=qnx //score/... --build_tests_only +bazel run @score_coverage//:generate_coverage_html -- --platform qnx \ + --yaml quality/coverage/coverage_justifications.yaml --testlogs-subdir score +``` + +The report is written to `coverage_linux/` or `coverage_qnx/` (`index.html`); +`--archive ` or `--archive-dir ` also collects the LCOV file, +`unmapped_files.txt`, the justification report and the JUnit XMLs. The gate +compares effective line coverage against `COVERAGE_THRESHOLD` (default 100) +and exits 1 when it is not met, 2 when no verdict is possible. + +What the QNX report does not contain, by design of that backend: Rust +sources (listed as `not-instrumented`, measured on Linux) and headers vendored +from external repositories. gcov counts lines differently from LLVM (no unused +inline functions, no closing braces), so the two reports are compared per +file, not merged. + +Justifications: a `COV_JUSTIFIED ` marker in the source or a `locations` +entry in the YAML, both referencing an entry with `id`, `category`, +`platforms` and `reason`. Details and the exact rules are in the tool's user +manual. diff --git a/quality/coverage/coverage.bazelrc b/quality/coverage/coverage.bazelrc index be2787de5f..06ad93a15d 100644 --- a/quality/coverage/coverage.bazelrc +++ b/quality/coverage/coverage.bazelrc @@ -10,108 +10,90 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +# Coverage configuration for the score_coverage pipeline +# (https://eclipse-score.github.io/coverage_tool/main/). +# +# Linux, LLVM backend (C++ + Rust): bazel coverage //... --build_tests_only +# QNX x86_64, gcov backend (C++): bazel coverage --config=qnx //score/... --build_tests_only +# Report + gate, both: bazel run @score_coverage//:generate_coverage_html -- [--platform qnx] ... +# +# The default `coverage` command is the LLVM backend; --config=qnx resets the +# LLVM settings and switches to Bazel's own per-test gcov collector plus the +# gcov reporter of score_coverage. # ============================================================================ -# Platform-agnostic coverage settings +# Platform-agnostic # ============================================================================ coverage --nocache_test_results coverage --cxxopt=-O0 coverage --combined_report=lcov coverage --experimental_fetch_all_coverage_outputs - -# By default Bazel creates for each library a *.so for its tests. If there is a header that is used by multiple *.so files -# and these *.so files have different instrumentation (production code has, test has not) a conflict arises which one to use when calculating coverage. -# Then the first object is used, and it is pure luck if it contains the correct data or not. Even worse, small changes can change the order and then lead -# to big coverage gaps. All these problems do not arise if no dynamic libs are used. Thus, we rather take bigger build times and binaries in advance for correct data. +# Shared objects with mixed instrumentation make llvm-cov pick the wrong +# object for a header; static linking avoids that (score_coverage CSTR-05). coverage --dynamic_mode=off +# Coverage builds must not fail on instrumentation-related warnings. +coverage --features=-score_communication_treat_warnings_as_errors +# FORTIFY_SOURCE conflicts with the LLVM coverage runtime at link time. +coverage --features=-score_communication_fortify_source +coverage --host_features=-score_communication_fortify_source # ============================================================================ -# LLVM source-based coverage (Linux) +# Linux: LLVM source-based coverage (default) # ============================================================================ -# Use with: bazel coverage //... -# -# Uses LLVM's source-based coverage instrumentation (--experimental_use_llvm_covmap) -# with custom merger/reporter that produce HTML reports via llvm-cov directly. -# -# NOTE: --experimental_use_llvm_covmap causes Bazel to instrument ALL targets. -# Source filtering is handled at report time by the reporter using the coverage -# allowlist generated from dependable_element declarations (coverage_scope rule). -# See quality/coverage/coverage_scope.bzl for details. - coverage --experimental_generate_llvm_lcov coverage --experimental_use_llvm_covmap coverage --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux coverage --extra_toolchains=@ferrocene_x86_64_unknown_linux_gnu_llvm//:rust_ferrocene_toolchain - -# Use llvm-cov directly for HTML report generation instead of genhtml/lcov. -# The custom merger (per-test) receives profraw files and produces a zip with profdata + HTML. -# The custom reporter (final) merges all profdata and generates the combined HTML report. -coverage --coverage_output_generator=//quality/coverage/llvm_cov:merger -coverage --coverage_report_generator=//quality/coverage/llvm_cov:reporter_wrapper - -# Override GENERATE_LLVM_LCOV to keep raw profraw files instead of converting to LCOV. -# The custom merger handles profraw→profdata→HTML directly. +coverage --coverage_output_generator=@score_coverage//:merger +coverage --coverage_report_generator=//quality/coverage:reporter_wrapper coverage --test_env=GENERATE_LLVM_LCOV=0 -# Suppress the default gcov path since we use llvm-cov directly. coverage --test_env=COVERAGE_GCOV_PATH=/usr/bin/true - -# These compile time options are required to cover abnormal termination cases. In GCC one can use `__gcc_dump()`, but this does not work with LLVM. -# LLVM provides these compile-time options in combination with a specific profile setting which is enabled in bazel via `LLVM_PROFILE_CONTINUOUS_MODE`. +# Continuous-mode profiling so coverage survives abnormal termination +# (death tests); needs runtime counter relocation, see the cc_feature. coverage --test_env=LLVM_PROFILE_CONTINUOUS_MODE=1 coverage --features=enable_llvm_coverage_for_death_tests - -# Rust coverage instrumentation (LLVM pipeline only; reset for QNX below). -# Requires score_toolchains_rust >= 0.9.2 + ferrocene_toolchain_builder >= -# 1.3.0 coverage-tools: the extension then declares llvm_cov/llvm_profdata on -# the rust_toolchain, which makes rules_rust add -Cinstrument-coverage under -# `bazel coverage`. -# -# The list-typed extra_rustc_flags setting is used (not the repeatable -# extra_rustc_flag) so the QNX section can reset it to empty. -# -Cllvm-args=-runtime-counter-relocation: Rust equivalent of the -# enable_llvm_coverage_for_death_tests cc_feature — continuous-mode -# profiling (%c in LLVM_PROFILE_FILE) requires runtime counter relocation, -# otherwise the profile runtime errors out and writes no .profraw. -# -Clink-dead-code: keep unlinked functions in the coverage map so -# unreferenced code shows as uncovered instead of vanishing. -# -Zcoverage-options=branch: branch coverage instrumentation. NOTE: -Z flags -# are unstable rustc options; this works because the Ferrocene toolchain -# in use is a rolling (nightly-based) build. If a stable-channel Ferrocene -# is ever pinned, drop this flag (Rust branch columns revert to "-"). +# Rust: rules_rust adds -Cinstrument-coverage once the toolchain declares +# llvm_cov (score_toolchains_rust >= 0.9.2 with coverage tools). +# -runtime-counter-relocation: continuous mode, as above +# -Clink-dead-code: unreferenced functions stay in the coverage map +# -Zcoverage-options=branch: branch regions; unstable flag, works on the +# rolling Ferrocene build in use (drop if a stable channel is pinned) coverage --@rules_rust//rust/settings:extra_rustc_flags=-Cllvm-args=-runtime-counter-relocation,-Clink-dead-code,-Zcoverage-options=branch # ============================================================================ -# QNX x86_64 coverage configuration (gcov-based) +# QNX x86_64: gcov backend (tests run in QEMU through score_qnx_unit_tests, +# which brings the .gcda counters back) # ============================================================================ -# Use with: bazel coverage //score/... --config=qnx -# -# QNX SDP 8 uses QCC which is GCC-based. Coverage uses the standard gcov -# pipeline: QCC compiles with --coverage flags, tests produce .gcda/.gcno -# files, and Bazel's default LCOV pipeline aggregates the results. - coverage:qnx_x86_64 --platforms=@score_bazel_platforms//:x86_64-qnx-sdp_8.0.0-posix coverage:qnx_x86_64 --extra_toolchains=@score_qcc_x86_64_toolchain//:x86_64-qnx-sdp_8.0.0 coverage:qnx_x86_64 --extra_toolchains=@score_toolchains_rust//toolchains/ferrocene:ferrocene_x86_64_pc_nto_qnx800 - -# Reset all values that were added for the default Linux coverage with llvm-cov +# Reset the LLVM settings above coverage:qnx_x86_64 --noexperimental_generate_llvm_lcov coverage:qnx_x86_64 --noexperimental_use_llvm_covmap -coverage:qnx_x86_64 --noexperimental_fetch_all_coverage_outputs -coverage:qnx_x86_64 --coverage_output_generator=@bazel_tools//tools/test:lcov_merger -coverage:qnx_x86_64 --coverage_report_generator=@bazel_tools//tools/test:coverage_report_generator coverage:qnx_x86_64 --test_env=GENERATE_LLVM_LCOV coverage:qnx_x86_64 --test_env=COVERAGE_GCOV_PATH coverage:qnx_x86_64 --test_env=LLVM_PROFILE_CONTINUOUS_MODE -# Reset the Rust LLVM-coverage rustc flags: the QNX pipeline is gcov-based -# and must not carry llvm covmap instrumentation options. -coverage:qnx_x86_64 --@rules_rust//rust/settings:extra_rustc_flags= - -# Shorthand: --config=qnx uses qnx_x86_64 (consistent with .bazelrc). +# rules_rust adds --codegen=instrument-coverage whenever the Rust toolchain +# declares llvm_cov, which makes rustc pass `-u __llvm_profile_runtime` to the +# linker; q++ rejects it ("cc: unknown option: '-u'"). Extra rustc flags come +# after that flag and the last -C instrument-coverage wins, so switch it off. +coverage:qnx_x86_64 --@rules_rust//rust/settings:extra_rustc_flags=-Cinstrument-coverage=no +# Bazel's own per-test gcov collector; score_coverage's gcov reporter for the +# final report. Tests are instrumented too so header-only code that only a +# test translation unit instantiates is measured (the scope drops the tests). +coverage:qnx_x86_64 --coverage_output_generator=@bazel_tools//tools/test:lcov_merger +coverage:qnx_x86_64 --coverage_report_generator=//quality/coverage:gcov_reporter_wrapper +coverage:qnx_x86_64 --instrument_test_targets +# Bazel guesses --instrumentation_filter from the packages of the test targets +# and only strips a trailing "/tests" (plural) to reach the code under test. +# Libraries whose tests live in a "test" subpackage would fall outside the +# guess and the stock collector of the gcov backend would drop their counters +# (seen as 0 % for score/os in baselibs). The LLVM backend on Linux is +# unaffected (its merger does not consult the filter). +coverage:qnx_x86_64 --instrumentation_filter=^//score[/:] +# NOTE: no --test_lang_filters=cc here. QNX unit tests are wrapped by +# cc_unit_test/score_qnx_unit_tests as a Starlark _forwarding_test rule (which +# dispatches to a sh_test running the binary under QEMU), not a native +# cc_test. --test_lang_filters matches on the rule class' language prefix, so +# any non-empty value (including "cc") silently excludes every QNX test. coverage:qnx --config=qnx_x86_64 - -# Disable treat_warnings_as_errors for coverage builds to avoid build failures from instrumentation-related warnings -coverage --features=-score_communication_treat_warnings_as_errors - -# Disable fortify_source for coverage builds to avoid linking failures with LLVM coverage runtime. -coverage --features=-score_communication_fortify_source -coverage --host_features=-score_communication_fortify_source diff --git a/quality/coverage/coverage_scope.bzl b/quality/coverage/coverage_scope.bzl deleted file mode 100644 index 6c17c2261e..0000000000 --- a/quality/coverage/coverage_scope.bzl +++ /dev/null @@ -1,202 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -""" -Coverage scope rule for deriving file-level allowlists from dependable_element structure. - -This rule uses an aspect to traverse the build graph starting from -dependable_element _index targets, through component and unit targets, into -their implementation cc_library targets (and their transitive deps). At each -cc_library, it collects the actual source files (srcs + hdrs). - -The resulting allowlist contains one source file path per line (relative to the -workspace root). The coverage reporter uses this to restrict reports to exactly -the files that are part of a dependable_element's implementation. - -Rust support: rust_library targets provide CcInfo (their rlib is exposed as a -.a symlink) and their .rs files come from the srcs attribute, so the CcInfo -branch covers them. rust_binary targets provide no CcInfo; a dedicated -CrateInfo branch collects their sources and the coverage-built executable. -""" - -load("@rules_rust//rust:rust_common.bzl", "CrateInfo") - -visibility(["//..."]) - -# ============================================================================= -# Provider to carry collected source file paths through the aspect -# ============================================================================= - -_CoverageScopeInfo = provider( - doc = "Carries source file paths, cc_library labels, and object files collected by the coverage scope aspect.", - fields = { - "source_files": "Depset of source file path strings (workspace-relative).", - "object_files": "Depset of compiled .o File objects for baseline coverage.", - }, -) - -# ============================================================================= -# Aspect: traverses component hierarchy and cc_library deps to collect files -# ============================================================================= - -def _coverage_scope_aspect_impl(target, ctx): - """Collects source file paths, cc_library labels, and archive files from the build graph.""" - direct_files = [] - direct_archives = [] - transitive = [] - transitive_archives = [] - - # At cc_library targets: collect srcs, hdrs, label, and static archive - if CcInfo in target: - for attr_name in ["srcs", "hdrs"]: - if hasattr(ctx.rule.attr, attr_name): - for src in getattr(ctx.rule.attr, attr_name): - for f in src.files.to_list(): - if not f.path.startswith("external/") and f.is_source: - direct_files.append(f.short_path) - - # Only collect workspace-internal labels and archives - if not str(target.label).startswith("@@") or str(target.label).startswith("@@//"): - # Collect .a archive files for baseline coverage. - for linker_input in target[CcInfo].linking_context.linker_inputs.to_list(): - for lib in linker_input.libraries: - for archive in [lib.static_library, lib.pic_static_library]: - if archive and "/external/" not in archive.path and not archive.path.startswith("external/"): - direct_archives.append(archive) - break - elif CrateInfo in target: - # rust_binary: no CcInfo, collect .rs sources from CrateInfo and use the - # coverage-built executable as the baseline object. - for f in target[CrateInfo].srcs.to_list(): - if not f.path.startswith("external/") and f.is_source: - direct_files.append(f.short_path) - out = target[CrateInfo].output - if out and "/external/" not in out.path and not out.path.startswith("external/"): - direct_archives.append(out) - - # Propagate from children traversed by the aspect - for attr_name in ["components", "implementation", "deps", "implementation_deps", "exported_deps"]: - if hasattr(ctx.rule.attr, attr_name): - for dep in getattr(ctx.rule.attr, attr_name): - if _CoverageScopeInfo in dep: - transitive.append(dep[_CoverageScopeInfo].source_files) - transitive_archives.append(dep[_CoverageScopeInfo].object_files) - - return [_CoverageScopeInfo( - source_files = depset(direct_files, transitive = transitive), - object_files = depset(direct_archives, transitive = transitive_archives), - )] - -_coverage_scope_aspect = aspect( - implementation = _coverage_scope_aspect_impl, - attr_aspects = ["components", "implementation", "deps", "implementation_deps", "exported_deps"], - doc = "Traverses component/unit/cc_library hierarchy to collect implementation source files.", -) - -# ============================================================================= -# Rule: aggregates aspect results into an allowlist file -# ============================================================================= - -def _coverage_scope_impl(ctx): - """Aggregates source file paths from all deps and writes allowlist + baseline objects.""" - all_files = {} - all_objects = [] - - for dep in ctx.attr.deps: - if _CoverageScopeInfo in dep: - for path in dep[_CoverageScopeInfo].source_files.to_list(): - if path: - all_files[path] = True - all_objects.append(dep[_CoverageScopeInfo].object_files) - - sorted_files = sorted(all_files.keys()) - object_depset = depset(transitive = all_objects) - - # Write the allowlist file - output = ctx.actions.declare_file(ctx.attr.name + "_allowlist.txt") - ctx.actions.write( - output = output, - content = "\n".join(sorted_files) + "\n" if sorted_files else "", - ) - - # Write archive file paths for baseline coverage (reporter uses these as --object args) - archive_paths = sorted(set([f.short_path for f in object_depset.to_list()])) - objects_output = ctx.actions.declare_file(ctx.attr.name + "_objects.txt") - ctx.actions.write( - output = objects_output, - content = "\n".join(archive_paths) + "\n" if archive_paths else "", - ) - - return [ - DefaultInfo(files = depset([output, objects_output], transitive = [object_depset])), - OutputGroupInfo( - allowlist = depset([output]), - objects = depset([objects_output]), - object_files = object_depset, - ), - ] - -def _coverage_transition_impl(settings, attr): - # This dictionary modifies the build configuration - return { - "//command_line_option:collect_code_coverage": True, - } - -# Define the transition -coverage_transition = transition( - implementation = _coverage_transition_impl, - inputs = [], - outputs = ["//command_line_option:collect_code_coverage"], -) - -def _coverage_wrapper_impl(ctx): - # Forward the executable or providers from the underlying target - actual_target = ctx.attr.actual[0] - return [actual_target[DefaultInfo]] - -# Define a rule that applies the transition to its 'actual' dependency -coverage_wrapper = rule( - implementation = _coverage_wrapper_impl, - attrs = { - "actual": attr.label( - mandatory = True, - cfg = coverage_transition, # Applying the transition here - ), - # Mandatory attribute needed when a rule uses a transition - "_allowlist_function_transition": attr.label( - default = "@bazel_tools//tools/allowlists/function_transition_allowlist", - ), - }, -) - -coverage_scope = rule( - implementation = _coverage_scope_impl, - doc = """Generates a file-level coverage allowlist from dependable_element targets. - - Uses an aspect to traverse the component hierarchy of dependable_element - targets into their implementation cc_library targets (and transitive deps), - collecting all source files (srcs + hdrs). Outputs a text file with one - workspace-relative file path per line. - - This allowlist is consumed by the coverage reporter to restrict coverage - reporting to exactly the source files that are part of a dependable_element. - """, - attrs = { - "deps": attr.label_list( - mandatory = True, - aspects = [_coverage_scope_aspect], - cfg = coverage_transition, - doc = "dependable_element _index targets whose implementation deps define the coverage scope.", - ), - }, -) diff --git a/quality/coverage/effective_coverage.py b/quality/coverage/effective_coverage.py deleted file mode 100644 index 8e02590122..0000000000 --- a/quality/coverage/effective_coverage.py +++ /dev/null @@ -1,1182 +0,0 @@ -#!/usr/bin/env python3 -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Effective coverage calculator and HTML post-processor. - -Takes an HTML coverage report (llvm-cov or gcovr) and the resolved justification -manifest. Modifies the HTML to show justified lines in a distinct color (yellow/orange) -and calculates effective coverage metrics. - -Supports two HTML formats: - - llvm-cov: produced by our custom reporter (Linux) - - gcovr: produced by lcov_to_html.py via gcovr (QNX) - -Usage: - python effective_coverage.py --html-dir --manifest --output [--lcov ] -""" - -import argparse -import json -import math -import re -import sys -from pathlib import Path -from typing import Any, Dict, List, Tuple - - -# Pattern to match a table row in llvm-cov HTML source pages -# Format: ......... -LINE_NUMBER_RE = re.compile(r"") -COVERED_LINE_TD_RE = re.compile(r"") - - -def floor_two_decimals(value: float) -> float: - """Floor a percentage value to two decimal places.""" - return math.floor(value * 100.0) / 100.0 - - -def main() -> None: - """Main entry point.""" - args = parse_args() - - # Load the justification manifest - manifest = load_manifest(args.manifest) - justified_files = manifest.get("justified_files", {}) - - # Find all source HTML files in the report - html_dir = args.html_dir - if not html_dir.exists(): - print(f"ERROR: HTML report directory not found: {html_dir}", file=sys.stderr) - sys.exit(1) - - # Detect HTML format: llvm-cov uses style.css, gcovr uses index...html naming. - html_format = detect_html_format(html_dir) - - if html_format == "gcovr": - _main_gcovr(args, html_dir, justified_files) - else: - _main_llvm_cov(args, html_dir, justified_files) - - -def _main_llvm_cov(args: argparse.Namespace, html_dir: Path, justified_files: Dict) -> None: - """Main logic for llvm-cov HTML format.""" - - # Parse raw coverage totals from the index page (matches llvm-cov exactly). - totals = parse_index_page_totals(html_dir) - raw_covered, raw_total = totals["lines"] - raw_branch_covered, raw_branch_total = totals["branches"] - - # Process each source HTML file (restyle justified lines + count them) - total_justified = 0 - total_stale = 0 - total_justified_branches = 0 - applied_justifications: List[Dict[str, Any]] = [] - stale_justifications: List[Dict[str, Any]] = [] - # Track per-file justification counts for index page updates - per_file_stats: Dict[str, Dict[str, int]] = {} - - source_html_files = find_source_html_files(html_dir) - for html_file in source_html_files: - rel_source_path = extract_source_path_from_html(html_file, html_dir) - if not rel_source_path: - continue - - file_justifications = find_matching_justifications(rel_source_path, justified_files) - - file_stats = process_html_file(html_file, file_justifications, applied_justifications, stale_justifications) - - total_justified += file_stats["justified"] - total_stale += file_stats["stale"] - total_justified_branches += file_stats["justified_branches"] - - if file_stats["justified"] > 0 or file_stats["justified_branches"] > 0: - per_file_stats[rel_source_path] = file_stats - - # Calculate stats using llvm-cov's exact numbers - raw_uncovered = raw_total - raw_covered - unjustified_uncovered = raw_uncovered - total_justified - - effective_branch_covered = raw_branch_covered + total_justified_branches - - stats = { - "total_instrumented_lines": raw_total, - "covered_lines": raw_covered, - "justified_lines": total_justified, - "unjustified_uncovered_lines": max(0, unjustified_uncovered), - "stale_justifications": total_stale, - "raw_line_coverage_pct": floor_two_decimals(100.0 * raw_covered / raw_total) if raw_total > 0 else 0.0, - "effective_line_coverage_pct": floor_two_decimals(100.0 * (raw_covered + total_justified) / raw_total) - if raw_total > 0 - else 0.0, - "total_branches": raw_branch_total, - "covered_branches": raw_branch_covered, - "justified_branches": total_justified_branches, - "raw_branch_coverage_pct": floor_two_decimals(100.0 * raw_branch_covered / raw_branch_total) - if raw_branch_total > 0 - else 0.0, - "effective_branch_coverage_pct": floor_two_decimals(100.0 * effective_branch_covered / raw_branch_total) - if raw_branch_total > 0 - else 0.0, - } - - # Inject CSS for justified lines into style.css - inject_justified_css(html_dir) - - # Update the index page with effective coverage info and per-file stats - update_index_page(html_dir, stats, per_file_stats) - - # Write output report - report = { - "version": 1, - "summary": stats, - "applied_justifications": applied_justifications, - "stale_justifications": stale_justifications, - } - - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - json.dump(report, f, indent=2) - - # Write human-readable summary - summary_path = output_path.parent / "summary.txt" - write_summary(summary_path, stats, stale_justifications) - - # Print summary - print( - f"INFO: Effective line coverage: {stats['effective_line_coverage_pct']}% " - f"(raw: {stats['raw_line_coverage_pct']}%, " - f"justified: {stats['justified_lines']} lines, " - f"unjustified uncovered: {stats['unjustified_uncovered_lines']} lines)", - file=sys.stderr, - ) - if stats["justified_branches"] > 0: - print( - f"INFO: Effective branch coverage: {stats['effective_branch_coverage_pct']}% " - f"(raw: {stats['raw_branch_coverage_pct']}%, " - f"justified: {stats['justified_branches']} branches)", - file=sys.stderr, - ) - if stale_justifications: - print( - f"WARNING: {len(stale_justifications)} stale justifications " - f"(lines are actually covered, justification can be removed)", - file=sys.stderr, - ) - - -def process_html_file( - html_file: Path, - justifications: Dict[int, Dict[str, str]], - applied_justifications: List[Dict[str, Any]], - stale_justifications: List[Dict[str, Any]], -) -> Dict[str, int]: - """Process a single source HTML file. Modifies it in-place. - - Restyles justified lines: changes the count cell to show "J" with justified-line - class, and changes red code regions to justified (orange) background. - Also restyles uncovered branches on justified lines. - Only counts justified/stale lines for the justification report — raw coverage - numbers are taken from the index page to match llvm-cov exactly. - """ - file_stats = { - "justified": 0, - "stale": 0, - "justified_branches": 0, - } - - with open(html_file, "r", encoding="utf-8") as f: - content = f.read() - - if not justifications: - return file_stats - - # Determine effective line status (covered if ANY instantiation covers it) - row_pattern = re.compile( - r"
\d+
" - r"" - ) - line_effective_status: Dict[int, str] = {} - for m in row_pattern.finditer(content): - line_num = int(m.group(1)) - line_class = m.group(2) - if line_class == "covered-line": - line_effective_status[line_num] = "covered" - elif line_class == "uncovered-line": - if line_num not in line_effective_status: - line_effective_status[line_num] = "uncovered" - - # Determine which lines have truly uncovered branches (never covered in any instantiation). - # A branch direction is "truly uncovered" if no instantiation covers it. - branch_check_pattern = re.compile( - r"Branch \(" - r"(\d+:\d+)\):\s*\[(.*?)\]" - ) - covered_branch_dirs_check: Dict[str, set] = {} # branch_id → set of covered directions - uncovered_branch_dirs_check: Dict[str, set] = {} # branch_id → set of uncovered directions - branch_line_map: Dict[str, int] = {} # branch_id → line_num - - for m in branch_check_pattern.finditer(content): - line_num = int(m.group(1)) - branch_id = m.group(2) - branch_content = m.group(3) - branch_line_map[branch_id] = line_num - if branch_id not in covered_branch_dirs_check: - covered_branch_dirs_check[branch_id] = set() - uncovered_branch_dirs_check[branch_id] = set() - for direction in ("True", "False"): - if f"class='None'>{direction}" in branch_content: - covered_branch_dirs_check[branch_id].add(direction) - if f"class='red branch'>{direction}" in branch_content: - uncovered_branch_dirs_check[branch_id].add(direction) - - # Lines with truly uncovered branches (uncovered in ALL instantiations) - lines_with_uncovered_branches: set = set() - for branch_id, uncov_dirs in uncovered_branch_dirs_check.items(): - cov_dirs = covered_branch_dirs_check.get(branch_id, set()) - truly_uncovered = uncov_dirs - cov_dirs - if truly_uncovered: - lines_with_uncovered_branches.add(branch_line_map[branch_id]) - - # Determine which justified lines are stale vs applicable. - # A justification is stale only if the line is covered AND has no uncovered branches. - for line_num, justification in justifications.items(): - status = line_effective_status.get(line_num) - has_uncovered_branches = line_num in lines_with_uncovered_branches - if status == "covered" and not has_uncovered_branches: - file_stats["stale"] += 1 - stale_justifications.append( - { - "file": html_file.stem, - "line": line_num, - "id": justification.get("id", ""), - "reason": "Line is already covered and has no uncovered branches — justification is stale", - } - ) - elif status == "uncovered": - file_stats["justified"] += 1 - applied_justifications.append( - { - "file": html_file.stem, - "line": line_num, - "id": justification.get("id", ""), - "category": justification.get("category", ""), - } - ) - elif status == "covered" and has_uncovered_branches: - # Line is covered but has uncovered branches — justification applies to branches only - applied_justifications.append( - { - "file": html_file.stem, - "line": line_num, - "id": justification.get("id", ""), - "category": justification.get("category", ""), - } - ) - - # Restyle justified lines in the HTML (all occurrences including instantiations). - # Full row pattern to capture and replace the entire row: - # ...
0
...
... - full_row_pattern = re.compile( - r"(
\d+
)" - r"(
)\d+(
)" - r"(
)(.*?)(
)" - ) - - modified = False - - def replace_full_row(match: re.Match) -> str: - nonlocal modified - line_num = int(match.group(2)) - if line_num not in justifications: - return match.group(0) - - justification = justifications[line_num] - reason = justification.get("reason", "").replace("'", "'").replace('"', """) - jid = justification.get("id", "") - tooltip = f"Justified [{jid}]: {reason}" - modified = True - - # Rebuild the row with justified styling: - # 1. Line number td (unchanged) - line_td = match.group(1) - # 2. Count td: change class and show "J" instead of "0" - count_td = f"
J{match.group(4)}"
-        # 3. Code td: replace 'region red' spans with 'region justified'
-        code_start = match.group(5)
-        code_content = match.group(6).replace("class='region red'", "class='region justified'")
-        code_end = match.group(7)
-
-        return line_td + count_td + code_start + code_content + code_end
-
-    new_content = full_row_pattern.sub(replace_full_row, content)
-
-    # Restyle branches on justified lines.
-    # Branch format in expansion-view:
-    # Branch (195:17):
-    #   [True: 0, ...]
-    # We find branches at justified line numbers and restyle red branch → justified branch
-    # Counting: A branch direction is "uncovered" only if ALL instantiations show it as red.
-    # (Same as llvm-cov's logic: covered if ANY instantiation covers it.)
-    branch_pattern = re.compile(
-        r"(Branch \("
-        r"(\d+:\d+)\):\s*\[)(.*?\])"
-    )
-
-    # First pass: determine which branch directions are covered in any instantiation
-    covered_branch_dirs: set = set()  # (line:col, direction) that are covered somewhere
-    for m in branch_pattern.finditer(new_content):
-        line_num = int(m.group(2))
-        if line_num not in justifications:
-            continue
-        branch_id = m.group(3)
-        branch_content = m.group(4)
-        # A direction is covered if it does NOT have 'red branch' class
-        for direction in ("True", "False"):
-            # Check if this direction appears as covered (class='None' means covered)
-            covered_marker = f"class='None'>{direction}"
-            if covered_marker in branch_content:
-                covered_branch_dirs.add((branch_id, direction))
-
-    # Second pass: restyle and count only truly uncovered branch directions
-    justified_branch_ids: set = set()  # Track unique uncovered (line:col, direction) pairs
-
-    def replace_branch(match: re.Match) -> str:
-        nonlocal modified
-        line_num = int(match.group(2))
-        if line_num not in justifications:
-            return match.group(0)
-
-        branch_content = match.group(4)
-        if "class='red branch'" not in branch_content:
-            return match.group(0)
-
-        modified = True
-        branch_id = match.group(3)  # e.g. "68:13"
-
-        # Count unique uncovered branch directions that are NEVER covered in any instantiation
-        for direction in ("True", "False"):
-            if f"class='red branch'>{direction}" in branch_content:
-                uid = (branch_id, direction)
-                if uid not in covered_branch_dirs and uid not in justified_branch_ids:
-                    justified_branch_ids.add(uid)
-                    file_stats["justified_branches"] += 1
-
-        # Restyle: red branch → justified-branch, uncovered-line → justified-line
-        branch_content = branch_content.replace("class='red branch'", "class='justified-branch'")
-        branch_content = branch_content.replace("class='uncovered-line'", "class='justified-line'")
-        return match.group(1) + branch_content
-
-    new_content = branch_pattern.sub(replace_branch, new_content)
-
-    if modified:
-        with open(html_file, "w", encoding="utf-8") as f:
-            f.write(new_content)
-
-    return file_stats
-
-
-def parse_index_page_totals(html_dir: Path) -> Dict[str, Tuple[int, int]]:
-    """Parse the TOTALS row from the llvm-cov index.html to get exact coverage numbers.
-
-    Returns dict with 'lines' and 'branches' keys, each (covered, total).
-    The index page TOTALS row format: "93.55% (17565/18777)" — func, line, branch.
-    """
-    index_file = html_dir / "index.html"
-    if not index_file.exists():
-        return {"lines": (0, 0), "branches": (0, 0)}
-
-    with open(index_file, "r", encoding="utf-8") as f:
-        content = f.read()
-
-    pct_pattern = re.compile(r"(\d+\.\d+)%\s*\((\d+)/(\d+)\)")
-    matches = pct_pattern.findall(content)
-
-    result = {"lines": (0, 0), "branches": (0, 0)}
-
-    if len(matches) >= 3:
-        # Last 3 matches are from TOTALS row: func, line, branch
-        totals_matches = matches[-3:]
-        _, line_covered, line_total = totals_matches[1]
-        result["lines"] = (int(line_covered), int(line_total))
-        _, branch_covered, branch_total = totals_matches[2]
-        result["branches"] = (int(branch_covered), int(branch_total))
-
-    if result["lines"] == (0, 0):
-        print("WARNING: Could not parse coverage totals from index.html", file=sys.stderr)
-
-    return result
-
-
-def inject_justified_css(html_dir: Path) -> None:
-    """Add CSS for justified lines to style.css."""
-    style_file = html_dir / "style.css"
-    if not style_file.exists():
-        return
-
-    justified_css = """
-/* Coverage justification styling */
-.justified-line {
-  text-align: right;
-  color: #a60;
-}
-.region.justified {
-  background-color: #fa04;
-}
-.justified-branch {
-  color: #a60;
-  font-weight: bold;
-}
-tr:has(> td.justified-line) > td.code {
-  background-color: #fff3e0;
-}
-@media (prefers-color-scheme: dark) {
-  .justified-line {
-    color: #fa0;
-  }
-  .justified-branch {
-    color: #fa0;
-  }
-  tr:has(> td.justified-line) > td.code {
-    background-color: #3d2800;
-  }
-  .region.justified {
-    background-color: #fa03;
-  }
-}
-"""
-
-    with open(style_file, "a", encoding="utf-8") as f:
-        f.write(justified_css)
-
-
-def update_index_page(html_dir: Path, stats: Dict[str, Any], per_file_stats: Dict[str, Dict[str, int]]) -> None:
-    """Update the index page with effective coverage info and per-file adjusted percentages."""
-    index_file = html_dir / "index.html"
-    if not index_file.exists():
-        return
-
-    with open(index_file, "r", encoding="utf-8") as f:
-        content = f.read()
-
-    # Banner with overall effective coverage (lines + branches)
-    branch_info = ""
-    if stats.get("justified_branches", 0) > 0:
-        branch_info = (
-            f" | Effective Branch Coverage: {stats['effective_branch_coverage_pct']}%"
-            f" (Raw: {stats['raw_branch_coverage_pct']}%, Justified: {stats['justified_branches']} branches)"
-        )
-
-    banner = (
-        f"
" - f"Effective Line Coverage: {stats['effective_line_coverage_pct']}% " - f"(Raw: {stats['raw_line_coverage_pct']}% | " - f"Justified: {stats['justified_lines']} lines | " - f"Unjustified Uncovered: {stats['unjustified_uncovered_lines']} lines)" - f"{branch_info}" - f"
" - ) - - # Insert after the tag or after the first

- if "

" in content: - content = content.replace("

", banner + "

", 1) - else: - content = content.replace("", f"{banner}", 1) - - # Update per-file rows in the index table. - # For each file with justifications, find its row and update line% and branch% cells. - # Row format:
displayname
- #
  XX.XX% (covered/total)
← function - #
  XX.XX% (covered/total)
← line - #
  XX.XX% (covered/total)
← branch - # - pct_cell_pattern = re.compile(r"
\s*(\d+\.\d+)%\s*\((\d+)/(\d+)\)
") - - for file_path, fstats in per_file_stats.items(): - justified_lines = fstats.get("justified", 0) - justified_branches = fstats.get("justified_branches", 0) - if justified_lines == 0 and justified_branches == 0: - continue - - # Find the row for this file in the index page - # The href contains the full path to the HTML file - if file_path not in content: - continue - - # Find the containing this file path - file_idx = content.find(file_path) - if file_idx < 0: - continue - row_start = content.rfind("", file_idx) - if row_start < 0 or row_end < 0: - continue - - row = content[row_start : row_end + 5] - - # Find all percentage cells in this row (func, line, branch) - cells = list(pct_cell_pattern.finditer(row)) - if len(cells) < 2: - continue - - new_row = row - # Update line coverage cell (second cell, index 1) - if justified_lines > 0 and len(cells) >= 2: - line_cell = cells[1] - covered = int(line_cell.group(3)) - total = int(line_cell.group(4)) - eff_covered = covered + justified_lines - eff_pct = floor_two_decimals(100.0 * eff_covered / total) if total > 0 else 0.0 - color = _get_coverage_color(eff_pct) - old_cell = line_cell.group(0) - new_cell = f"
{eff_pct:>7.2f}% ({eff_covered}/{total})
" - new_row = new_row.replace(old_cell, new_cell) - - # Update branch coverage cell (third cell, index 2) - if justified_branches > 0 and len(cells) >= 3: - branch_cell = cells[2] - covered = int(branch_cell.group(3)) - total = int(branch_cell.group(4)) - eff_covered = covered + justified_branches - eff_pct = floor_two_decimals(100.0 * eff_covered / total) if total > 0 else 0.0 - color = _get_coverage_color(eff_pct) - old_cell = branch_cell.group(0) - new_cell = f"
{eff_pct:>7.2f}% ({eff_covered}/{total})
" - new_row = new_row.replace(old_cell, new_cell) - - if new_row != row: - content = content.replace(row, new_row) - - # Update the TOTALS row - content = _update_totals_row(content, stats) - - with open(index_file, "w", encoding="utf-8") as f: - f.write(content) - - -def _get_coverage_color(pct: float) -> str: - """Return the llvm-cov color class for a coverage percentage.""" - if pct >= 100.0: - return "green" - elif pct >= 80.0: - return "yellow" - else: - return "red" - - -def _update_totals_row(content: str, stats: Dict[str, Any]) -> str: - """Update the TOTALS row in the index page with effective coverage numbers.""" - # Find the TOTALS row — it's the last row before - totals_idx = content.rfind("Totals") - if totals_idx < 0: - return content - - row_start = content.rfind("", totals_idx) - if row_start < 0 or row_end < 0: - return content - - row = content[row_start : row_end + 5] - - pct_cell_pattern = re.compile(r"
\s*(\d+\.\d+)%\s*\((\d+)/(\d+)\)
") - cells = list(pct_cell_pattern.finditer(row)) - - new_row = row - - # Update line coverage in totals (index 1) - if len(cells) >= 2 and stats.get("justified_lines", 0) > 0: - line_cell = cells[1] - eff_covered = stats["covered_lines"] + stats["justified_lines"] - total = stats["total_instrumented_lines"] - eff_pct = stats["effective_line_coverage_pct"] - color = _get_coverage_color(eff_pct) - old_cell = line_cell.group(0) - new_cell = f"
{eff_pct:>7.2f}% ({eff_covered}/{total})
" - new_row = new_row.replace(old_cell, new_cell) - - # Update branch coverage in totals (index 2) - if len(cells) >= 3 and stats.get("justified_branches", 0) > 0: - branch_cell = cells[2] - eff_covered = stats["covered_branches"] + stats["justified_branches"] - total = stats["total_branches"] - eff_pct = stats["effective_branch_coverage_pct"] - color = _get_coverage_color(eff_pct) - old_cell = branch_cell.group(0) - new_cell = f"
{eff_pct:>7.2f}% ({eff_covered}/{total})
" - new_row = new_row.replace(old_cell, new_cell) - - if new_row != row: - content = content.replace(row, new_row) - - return content - - -def find_source_html_files(html_dir: Path) -> List[Path]: - """Find all per-source HTML files (not index.html, style.css, etc.).""" - coverage_dir = html_dir / "coverage" - if not coverage_dir.exists(): - # Some llvm-cov versions put source files directly in html_dir - coverage_dir = html_dir - - files = [] - for html_file in coverage_dir.rglob("*.html"): - if html_file.name in ("index.html",): - continue - files.append(html_file) - return sorted(files) - - -def extract_source_path_from_html(html_file: Path, html_dir: Path) -> str: - """Extract the relative source file path from the HTML file path. - - llvm-cov creates paths like: html_report/coverage/.html - We need to extract the relative path within the project. - """ - rel = str(html_file.relative_to(html_dir)) - # Remove "coverage/" prefix if present - if rel.startswith("coverage/"): - rel = rel[len("coverage/") :] - # Remove .html suffix - if rel.endswith(".html"): - rel = rel[:-5] - return rel - - -def find_matching_justifications( - source_path: str, justified_files: Dict[str, Dict[str, Dict[str, str]]] -) -> Dict[int, Dict[str, str]]: - """Find justifications that match the given source path. - - The source_path from HTML may be an absolute path or relative. - The justified_files keys are relative to source root. - We match by suffix. - """ - result: Dict[int, Dict[str, str]] = {} - - for justified_path, line_justifications in justified_files.items(): - # Match if the source_path ends with the justified_path - if source_path.endswith(justified_path) or justified_path.endswith(source_path): - for line_str, justification in line_justifications.items(): - result[int(line_str)] = justification - - return result - - -def write_summary(path: Path, stats: Dict[str, Any], stale: List[Dict[str, Any]]) -> None: - """Write human-readable summary.""" - with open(path, "w", encoding="utf-8") as f: - f.write("Coverage Justification Summary\n") - f.write("=" * 40 + "\n\n") - f.write(f"Total instrumented lines: {stats['total_instrumented_lines']}\n") - f.write(f"Covered lines: {stats['covered_lines']}\n") - f.write(f"Justified lines: {stats['justified_lines']}\n") - f.write(f"Unjustified uncovered: {stats['unjustified_uncovered_lines']}\n") - f.write("\n") - f.write(f"Raw line coverage: {stats['raw_line_coverage_pct']}%\n") - f.write(f"Effective line coverage: {stats['effective_line_coverage_pct']}%\n") - f.write("\n") - if stats.get("total_branches", 0) > 0: - f.write(f"Total branches: {stats['total_branches']}\n") - f.write(f"Covered branches: {stats['covered_branches']}\n") - f.write(f"Justified branches: {stats['justified_branches']}\n") - f.write(f"Raw branch coverage: {stats['raw_branch_coverage_pct']}%\n") - f.write(f"Effective branch coverage: {stats['effective_branch_coverage_pct']}%\n") - f.write("\n") - if stale: - f.write(f"Stale justifications ({len(stale)}):\n") - for s in stale: - f.write(f" - {s['file']}:{s['line']} [{s['id']}]\n") - f.write("\n") - - -def load_manifest(path: Path) -> Dict[str, Any]: - """Load the justification manifest JSON.""" - if not path.exists(): - print(f"ERROR: Manifest not found: {path}", file=sys.stderr) - sys.exit(1) - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description="Effective coverage calculator and HTML post-processor") - parser.add_argument( - "--html-dir", - type=Path, - required=True, - help="Path to llvm-cov HTML report directory", - ) - parser.add_argument( - "--manifest", - type=Path, - required=True, - help="Path to resolved justification manifest (from justify.py)", - ) - parser.add_argument( - "--output", - type=Path, - required=True, - help="Output path for justification report (JSON)", - ) - parser.add_argument( - "--lcov", - type=Path, - default=None, - help="Optional path to LCOV data file (used for gcovr format totals)", - ) - return parser.parse_args() - - -# ============================================================================= -# Format detection -# ============================================================================= - - -def detect_html_format(html_dir: Path) -> str: - """Detect whether the HTML report was generated by llvm-cov or gcovr. - - Returns 'llvm_cov' or 'gcovr'. - """ - # gcovr produces files named based on --html-details argument. - # We use index.html, so per-source files are index...html - if any(html_dir.glob("index.*.*.html")): - return "gcovr" - if (html_dir / "coverage_details.html").exists(): - return "gcovr" - return "llvm_cov" - - -def _parse_lcov_totals(lcov_path: Path) -> Dict[str, Tuple[int, int]]: - """Parse coverage totals from an LCOV data file. - - Sums LH/LF (line hit/found) and BRH/BRF (branch hit/found) across all records. - """ - total_lines = 0 - hit_lines = 0 - total_branches = 0 - hit_branches = 0 - - with open(lcov_path, "r", encoding="utf-8", errors="replace") as f: - for line in f: - line = line.strip() - if line.startswith("LF:"): - total_lines += int(line[3:]) - elif line.startswith("LH:"): - hit_lines += int(line[3:]) - elif line.startswith("BRF:"): - total_branches += int(line[4:]) - elif line.startswith("BRH:"): - hit_branches += int(line[4:]) - - return { - "lines": (hit_lines, total_lines), - "branches": (hit_branches, total_branches), - } - - -# ============================================================================= -# gcovr support -# ============================================================================= - - -def _main_gcovr(args: argparse.Namespace, html_dir: Path, justified_files: Dict) -> None: - """Main logic for gcovr HTML format (produced by lcov_to_html.py via gcovr).""" - - # Parse coverage totals from LCOV file or gcovr index page. - if args.lcov and args.lcov.exists(): - totals = _parse_lcov_totals(args.lcov) - else: - totals = _parse_gcovr_index_totals(html_dir) - - raw_covered, raw_total = totals["lines"] - raw_branch_covered, raw_branch_total = totals["branches"] - - # Process each source HTML file - total_justified = 0 - total_stale = 0 - total_justified_branches = 0 - applied_justifications: List[Dict[str, Any]] = [] - stale_justifications: List[Dict[str, Any]] = [] - per_file_stats: Dict[str, Dict[str, int]] = {} - - source_html_files = _find_gcovr_source_files(html_dir) - for html_file in source_html_files: - rel_source_path = _extract_gcovr_source_path(html_file) - if not rel_source_path: - continue - - file_justifications = find_matching_justifications(rel_source_path, justified_files) - - file_stats = _process_gcovr_file(html_file, file_justifications, applied_justifications, stale_justifications) - - total_justified += file_stats["justified"] - total_stale += file_stats["stale"] - total_justified_branches += file_stats["justified_branches"] - - if file_stats["justified"] > 0 or file_stats["justified_branches"] > 0: - per_file_stats[rel_source_path] = file_stats - - # Calculate stats - raw_uncovered = raw_total - raw_covered - unjustified_uncovered = raw_uncovered - total_justified - effective_branch_covered = raw_branch_covered + total_justified_branches - - stats = { - "total_instrumented_lines": raw_total, - "covered_lines": raw_covered, - "justified_lines": total_justified, - "unjustified_uncovered_lines": max(0, unjustified_uncovered), - "stale_justifications": total_stale, - "raw_line_coverage_pct": floor_two_decimals(100.0 * raw_covered / raw_total) if raw_total > 0 else 0.0, - "effective_line_coverage_pct": floor_two_decimals(100.0 * (raw_covered + total_justified) / raw_total) - if raw_total > 0 - else 0.0, - "total_branches": raw_branch_total, - "covered_branches": raw_branch_covered, - "justified_branches": total_justified_branches, - "raw_branch_coverage_pct": floor_two_decimals(100.0 * raw_branch_covered / raw_branch_total) - if raw_branch_total > 0 - else 0.0, - "effective_branch_coverage_pct": floor_two_decimals(100.0 * effective_branch_covered / raw_branch_total) - if raw_branch_total > 0 - else 0.0, - } - - # Inject CSS for justified lines - _inject_gcovr_justified_css(html_dir) - - # Update the gcovr index page with effective coverage banner - _update_gcovr_index_page(html_dir, stats) - - # Write output report - report = { - "version": 1, - "summary": stats, - "applied_justifications": applied_justifications, - "stale_justifications": stale_justifications, - } - - if args.output: - with open(args.output, "w", encoding="utf-8") as f: - json.dump(report, f, indent=2) - print(f"Effective coverage report written to: {args.output}") - else: - print(json.dumps(report, indent=2)) - - -def _parse_gcovr_index_totals(html_dir: Path) -> Dict[str, Tuple[int, int]]: - """Parse coverage totals from gcovr's index page. - - gcovr format shows coverage in the summary header with patterns like: - 75.0% - 30 / 40 - """ - index_file = html_dir / "index.html" - if not index_file.exists(): - # Fallback for older naming - index_file = html_dir / "coverage_details.html" - if not index_file.exists(): - return {"lines": (0, 0), "branches": (0, 0)} - - content = index_file.read_text(encoding="utf-8", errors="replace") - - # gcovr summary table has rows with line/branch stats. - # Look for the summary div with class "summary" containing coverage percentages. - # Pattern: "N / M" in table cells for covered/total - lines_covered, lines_total = 0, 0 - branches_covered, branches_total = 0, 0 - - # gcovr index uses meter elements with data attributes or text like "N / M" - # The summary section shows: Line Coverage: XX.X% (covered / total) - ratio_pattern = re.compile(r"(\d+)\s*/\s*(\d+)") - - # Find the summary section — gcovr puts line/branch/function stats in order. - # Look for the summary-coverage section - summary_match = re.search(r'class="[^"]*summary[^"]*".*?', content, re.DOTALL) - if summary_match: - summary_text = summary_match.group(0) - ratios = ratio_pattern.findall(summary_text) - # gcovr order: lines, functions, branches (if branches enabled) - if len(ratios) >= 1: - lines_covered, lines_total = int(ratios[0][0]), int(ratios[0][1]) - if len(ratios) >= 3: - branches_covered, branches_total = int(ratios[2][0]), int(ratios[2][1]) - elif len(ratios) >= 2: - # Might be lines then branches (no functions section) - branches_covered, branches_total = int(ratios[1][0]), int(ratios[1][1]) - - if lines_total == 0: - # Fallback: scan the whole page for ratio patterns - all_ratios = ratio_pattern.findall(content) - if len(all_ratios) >= 1: - lines_covered, lines_total = int(all_ratios[0][0]), int(all_ratios[0][1]) - if len(all_ratios) >= 3: - branches_covered, branches_total = ( - int(all_ratios[2][0]), - int(all_ratios[2][1]), - ) - - return { - "lines": (lines_covered, lines_total), - "branches": (branches_covered, branches_total), - } - - -def _find_gcovr_source_files(html_dir: Path) -> List[Path]: - """Find all per-source HTML files in a gcovr report. - - gcovr --html-details creates files named: - index...html - The index is index.html (no dot-separated parts after "index"). - """ - files = [] - for html_file in sorted(html_dir.glob("index.*.html")): - # Skip function pages (contain ".functions." in name) - if ".functions." in html_file.name: - continue - files.append(html_file) - return files - - -def _extract_gcovr_source_path(html_file: Path) -> str: - """Extract the source file path from a gcovr per-source HTML file. - - gcovr embeds the directory in a "Directory:" table row and the filename - in the Box-header. Combining these gives the full relative source path. - """ - try: - content = html_file.read_text(encoding="utf-8", errors="replace")[:8000] - except OSError: - return "" - - # Extract directory from: Directory:\npath/ - directory = "" - dir_match = re.search(r"Directory:\s*([^<]*)", content) - if dir_match: - directory = dir_match.group(1).strip() - - # Extract filename from the Box-header - filename = "" - name_match = re.search( - r'class="Box-header[^"]*d-flex flex-space-between[^"]*"[^>]*>\s*\n?\s*([^\n<]+)', - content, - ) - if name_match: - filename = name_match.group(1).strip() - - if not filename: - # Fallback: look in - title_match = re.search(r"<title>([^<]+?)(?:\s*-\s*GCC[^<]*)?", content) - if title_match: - filename = title_match.group(1).strip() - - if directory and filename: - return directory + filename - return filename - - -def _process_gcovr_file( - html_file: Path, - justifications: Dict[int, Dict[str, str]], - applied_justifications: List[Dict[str, Any]], - stale_justifications: List[Dict[str, Any]], -) -> Dict[str, int]: - """Process a single gcovr source HTML file. Modifies it in-place. - - gcovr line format: - - {num} - ... - 5 - source code - - - Coverage classes: - - coveredLine: line is covered (count > 0) - - uncoveredLine: line is not covered (count == 0) - - partialCoveredLine: some branches not taken - - excludedLine: excluded from coverage - """ - file_stats = {"justified": 0, "stale": 0, "justified_branches": 0} - - if not justifications: - return file_stats - - with open(html_file, "r", encoding="utf-8") as f: - content = f.read() - - # Parse line coverage status from gcovr HTML. - # Each source line: - # followed by - line_pattern = re.compile( - r']*>.*?' - r' on lines with branch issues - branch_pattern = re.compile( - r']*>.*?(.*?)', - re.DOTALL, - ) - - line_effective_status: Dict[int, str] = {} - lines_with_uncovered_branches: set = set() - - for m in line_pattern.finditer(content): - line_num = int(m.group(1)) - cov_class = m.group(2) - if cov_class == "coveredLine": - line_effective_status[line_num] = "covered" - elif cov_class == "uncoveredLine": - line_effective_status[line_num] = "uncovered" - elif cov_class == "partialCoveredLine": - line_effective_status[line_num] = "covered" - lines_with_uncovered_branches.add(line_num) - - # Also check branch details for not-taken branches - for m in branch_pattern.finditer(content): - line_num = int(m.group(1)) - branch_content = m.group(2) - if "notTakenBranch" in branch_content: - lines_with_uncovered_branches.add(line_num) - - # Determine stale vs applicable justifications - for line_num, justification in justifications.items(): - status = line_effective_status.get(line_num) - has_uncovered_branches = line_num in lines_with_uncovered_branches - if status == "covered" and not has_uncovered_branches: - file_stats["stale"] += 1 - stale_justifications.append( - { - "file": _extract_gcovr_source_path(html_file), - "line": line_num, - "id": justification.get("id", ""), - "reason": "Line is already covered and has no uncovered branches — justification is stale", - } - ) - elif status == "uncovered": - file_stats["justified"] += 1 - applied_justifications.append( - { - "file": _extract_gcovr_source_path(html_file), - "line": line_num, - "id": justification.get("id", ""), - "category": justification.get("category", ""), - } - ) - # Mark the line as justified in the HTML - content = _mark_gcovr_line_justified(content, line_num) - elif status == "covered" and has_uncovered_branches: - file_stats["justified_branches"] += 1 - applied_justifications.append( - { - "file": _extract_gcovr_source_path(html_file), - "line": line_num, - "id": justification.get("id", ""), - "category": justification.get("category", ""), - } - ) - - # Write modified content back - if file_stats["justified"] > 0: - with open(html_file, "w", encoding="utf-8") as f: - f.write(content) - - return file_stats - - -def _mark_gcovr_line_justified(content: str, line_num: int) -> str: - """Replace 'uncoveredLine' with 'justifiedLine' for a specific line in gcovr HTML.""" - # Find the line anchor and replace the coverage class in that row - # Pattern: ...uncoveredLine... (within the same ) - pattern = re.compile( - rf'(]*>.*?)', - re.DOTALL, - ) - match = pattern.search(content) - if match: - original = match.group(0) - modified = original.replace("uncoveredLine", "justifiedLine") - modified = modified.replace("show_uncoveredLine", "show_justifiedLine") - content = content[: match.start()] + modified + content[match.end() :] - return content - - -def _inject_gcovr_justified_css(html_dir: Path) -> None: - """Inject CSS for justified lines into gcovr's stylesheet.""" - # gcovr names its CSS file based on the output filename (coverage_details.css) - css_files = list(html_dir.glob("*.css")) - if not css_files: - return - - justified_css = """ -/* Justified line styling (injected by effective_coverage.py) */ -.justifiedLine { background-color: #ffe4b5 !important; } -.show_justifiedLine { display: table-cell; } -.button_toggle_justifiedLine { background-color: #ffe4b5; border: 1px solid #daa520; } -""" - # Inject into the first CSS file found - with open(css_files[0], "a", encoding="utf-8") as f: - f.write(justified_css) - - -def _update_gcovr_index_page(html_dir: Path, stats: Dict[str, Any]) -> None: - """Update the gcovr index page with an effective coverage banner.""" - index_file = html_dir / "index.html" - if not index_file.exists(): - index_file = html_dir / "coverage_details.html" - if not index_file.exists(): - return - - with open(index_file, "r", encoding="utf-8") as f: - content = f.read() - - branch_info = "" - if stats.get("justified_branches", 0) > 0: - branch_info = ( - f" | Effective Branch Coverage: {stats['effective_branch_coverage_pct']}%" - f" (Raw: {stats['raw_branch_coverage_pct']}%, Justified: {stats['justified_branches']} branches)" - ) - - banner = ( - f'
' - f"Effective Line Coverage: {stats['effective_line_coverage_pct']}% " - f"(Raw: {stats['raw_line_coverage_pct']}% | " - f"Justified: {stats['justified_lines']} lines | " - f"Unjustified Uncovered: {stats['unjustified_uncovered_lines']} lines)" - f"{branch_info}" - f"
" - ) - - # Insert before the file listing - if '
" in content: - content = content.replace("", f"{banner}", 1) - - with open(index_file, "w", encoding="utf-8") as f: - f.write(content) - - -if __name__ == "__main__": - main() diff --git a/quality/coverage/generate_coverage_html.sh b/quality/coverage/generate_coverage_html.sh deleted file mode 100755 index 9a3f17cd14..0000000000 --- a/quality/coverage/generate_coverage_html.sh +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env bash -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -# Generates an HTML coverage report from `bazel coverage` output. -# Supports two coverage pipelines: -# - LLVM (Linux): Custom reporter produces a zip with html_report/ inside. -# - gcov (QNX): Bazel's default reporter produces an LCOV text file; -# we convert it to HTML using lcov_to_html.py. -# -# Usage: -# bazel run //quality/coverage:generate_coverage_html [-- [--archive ] [--platform ] [output-dir]] -# -# Arguments: -# --archive Also create a zip archive named .zip -# containing the HTML report, raw LCOV data and JUnit XMLs. -# --platform Target platform for justification filtering (linux or qnx). -# Default: linux. Also affects the default output directory -# (cpp_coverage_linux for linux, cpp_coverage_qnx for qnx). -# output-dir Directory to write the HTML report to (default: cpp_coverage_) - -set -euo pipefail - -ARCHIVE_NAME="" -PLATFORM="linux" -OUTPUT_DIR="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --archive) - ARCHIVE_NAME="${2:?--archive requires a name argument}" - shift 2 - ;; - --platform) - PLATFORM="${2:?--platform requires a platform argument (linux or qnx)}" - shift 2 - ;; - *) - OUTPUT_DIR="$1" - shift - ;; - esac -done - -# Set default output directory based on platform if not explicitly provided. -if [[ -z "${OUTPUT_DIR}" ]]; then - OUTPUT_DIR="cpp_coverage_${PLATFORM}" -fi - -# Change to the workspace root first so that all subsequent bazel calls -# and relative paths work correctly. -# -# IMPORTANT: bootstrap RUNFILES_DIR from $0 BEFORE the cd, since after cd -# any relative paths would break. We ensure it is absolute here. -# Do NOT use 'readlink -f $0' — that resolves the sh_binary symlink to the -# source .sh file, losing the adjacent .runfiles tree. -_SELF_DIR="$(cd "$(dirname "$0")" && pwd)" -_SELF_NAME="$(basename "$0")" -if [[ -z "${RUNFILES_DIR:-}" || ! -d "${RUNFILES_DIR}" ]]; then - if [[ -d "${_SELF_DIR}/${_SELF_NAME}.runfiles" ]]; then - export RUNFILES_DIR="${_SELF_DIR}/${_SELF_NAME}.runfiles" - fi -fi -unset _SELF_DIR _SELF_NAME - -cd "${BUILD_WORKSPACE_DIRECTORY}" - -# Resolve OUTPUT_DIR to absolute path (relative to workspace root). -OUTPUT_DIR="${BUILD_WORKSPACE_DIRECTORY}/${OUTPUT_DIR}" - -# The coverage report is at _coverage_report.dat. Its format depends on the -# pipeline: -# - LLVM (--config=llvm_cov): zip containing html_report/, lcov_report/, text_report/ -# - gcov (default/QNX): plain LCOV text file -COVERAGE_REPORT="${BUILD_WORKSPACE_DIRECTORY}/bazel-out/_coverage/_coverage_report.dat" - -if [[ ! -f "${COVERAGE_REPORT}" ]]; then - echo "ERROR: Coverage report not found at ${COVERAGE_REPORT}" >&2 - echo " Run 'bazel coverage //... --build_tests_only' first." >&2 - exit 1 -fi - -TMPDIR_EXTRACT="${TMPDIR:-/tmp}/coverage_extract_$$" -mkdir -p "${TMPDIR_EXTRACT}" -trap 'rm -rf "${TMPDIR_EXTRACT}"' EXIT - -rm -rf "${OUTPUT_DIR}" - -# Detect the report format: zip (LLVM pipeline) vs plain text (gcov pipeline). -if file -b "${COVERAGE_REPORT}" | grep -q "Zip archive"; then - # ----------------------------------------------------------------------- - # LLVM pipeline: extract HTML from the zip produced by our custom reporter. - # ----------------------------------------------------------------------- - unzip -q -o "${COVERAGE_REPORT}" -d "${TMPDIR_EXTRACT}" - - if [[ -d "${TMPDIR_EXTRACT}/html_report" ]]; then - cp -r "${TMPDIR_EXTRACT}/html_report" "${OUTPUT_DIR}" - else - echo "ERROR: html_report/ not found in ${COVERAGE_REPORT}" >&2 - exit 1 - fi -else - # ----------------------------------------------------------------------- - # gcov pipeline: _coverage_report.dat is an LCOV text file. - # Convert to HTML using lcov_to_html (genhtml-compatible Python tool). - # ----------------------------------------------------------------------- - - # Copy the raw LCOV data for later archiving. - cp "${COVERAGE_REPORT}" "${TMPDIR_EXTRACT}/lcov.dat" - - echo "Building collected coverage allowlist for QNX..." - # coverage_scope depends on Linux-only dependable element indices. - # Build it in the host/default config and reuse the workspace-relative allowlist for QNX filtering. - bazel build --config=qnx //quality/coverage:coverage_scope --output_groups=allowlist - ALLOWLIST_FILE="$(bazel info bazel-bin)/quality/coverage/coverage_scope_allowlist.txt" - if [[ ! -f "${ALLOWLIST_FILE}" ]]; then - echo "ERROR: Allowlist file not found at ${ALLOWLIST_FILE}" >&2 - exit 1 - fi - - # Collect zero-coverage baseline LCOV records so that files not exercised by - # any test still appear in the HTML report with 0% coverage, rather than - # being silently omitted. Two sources contribute: (a) baseline_coverage.dat - # files placed alongside each coverage.dat by the Bazel coverage runner, and - # (b) _cc_coverage.dat files produced by dedicated baseline_coverage_test - # targets for C++ translation units that the GCOV runner handles separately. - BASELINE_LCOV="${TMPDIR_EXTRACT}/baseline_lcov.dat" - BASELINE_COUNT=0 - LCOV_INPUT_LIST="${BUILD_WORKSPACE_DIRECTORY}/bazel-out/_coverage/lcov_files.tmp" - if [[ -f "${LCOV_INPUT_LIST}" ]]; then - # Collect baseline coverage next to each coverage.dat listed by Bazel. - # Some runners list baseline_coverage.dat directly, others only coverage.dat. - while IFS= read -r lcov_input; do - if [[ "${lcov_input}" == *"/baseline_coverage.dat" ]]; then - if [[ -f "${lcov_input}" ]]; then - printf "\n" >> "${BASELINE_LCOV}" - cat "${lcov_input}" >> "${BASELINE_LCOV}" - printf "\n" >> "${BASELINE_LCOV}" - BASELINE_COUNT=$((BASELINE_COUNT + 1)) - fi - continue - fi - if [[ "${lcov_input}" == *"/coverage.dat" ]]; then - baseline_input="${lcov_input%coverage.dat}baseline_coverage.dat" - if [[ -f "${baseline_input}" ]]; then - printf "\n" >> "${BASELINE_LCOV}" - cat "${baseline_input}" >> "${BASELINE_LCOV}" - printf "\n" >> "${BASELINE_LCOV}" - BASELINE_COUNT=$((BASELINE_COUNT + 1)) - fi - fi - done < <(sort -u "${LCOV_INPUT_LIST}") - fi - - # Some baseline records with full DA line data are emitted through dedicated - # baseline_coverage_test targets and only exist as _cc_coverage.dat files. - # Merge them as additional baseline input. - while IFS= read -r cc_baseline; do - printf "\n" >> "${BASELINE_LCOV}" - cat "${cc_baseline}" >> "${BASELINE_LCOV}" - printf "\n" >> "${BASELINE_LCOV}" - BASELINE_COUNT=$((BASELINE_COUNT + 1)) - done < <(find -L "${BUILD_WORKSPACE_DIRECTORY}/bazel-out" \ - -path "*/testlogs/*/baseline_coverage_test/_coverage/_cc_coverage.dat" \ - -type f | sort -u) - - echo "Generating HTML report from LCOV data..." - LCOV_TO_HTML_ARGS=( - --lcov "${TMPDIR_EXTRACT}/lcov.dat" - --output-dir "${OUTPUT_DIR}" - --source-root "${BUILD_WORKSPACE_DIRECTORY}" - --allowlist "${ALLOWLIST_FILE}" - ) - - if [[ -s "${BASELINE_LCOV}" ]]; then - echo "Merging ${BASELINE_COUNT} baseline_coverage.dat files into report input..." - LCOV_TO_HTML_ARGS+=(--baseline-lcov "${BASELINE_LCOV}") - fi - - bazel run //quality/coverage:lcov_to_html -- "${LCOV_TO_HTML_ARGS[@]}" -fi - -echo "Coverage report written to: ${OUTPUT_DIR}" - -# --------------------------------------------------------------------------- -# Run coverage justification processing. -# --------------------------------------------------------------------------- -JUSTIFICATION_YAML="${BUILD_WORKSPACE_DIRECTORY}/quality/coverage/coverage_justifications.yaml" - -if [[ -f "${JUSTIFICATION_YAML}" ]]; then - echo "" - echo "Running coverage justification processing..." - - JUSTIFICATION_DIR="${TMPDIR_EXTRACT}/justification_report" - mkdir -p "${JUSTIFICATION_DIR}" - - # Run justify.py via Bazel to produce the resolved manifest. - if bazel run //quality/coverage:justify -- \ - --yaml "${JUSTIFICATION_YAML}" \ - --source-root "${BUILD_WORKSPACE_DIRECTORY}" \ - --platform "${PLATFORM}" \ - --output "${JUSTIFICATION_DIR}/manifest.json"; then - - # Run effective_coverage.py via Bazel to post-process HTML and calculate effective coverage. - EFFECTIVE_COV_ARGS=( - --html-dir "${OUTPUT_DIR}" - --manifest "${JUSTIFICATION_DIR}/manifest.json" - --output "${JUSTIFICATION_DIR}/report.json" - ) - # For gcov pipeline, pass the LCOV data for reliable totals parsing. - if [[ -f "${TMPDIR_EXTRACT}/lcov.dat" ]]; then - EFFECTIVE_COV_ARGS+=(--lcov "${TMPDIR_EXTRACT}/lcov.dat") - fi - bazel run //quality/coverage:effective_coverage -- "${EFFECTIVE_COV_ARGS[@]}" - fi - - # Display effective coverage summary. - if [[ -f "${JUSTIFICATION_DIR}/summary.txt" ]]; then - echo "" - cat "${JUSTIFICATION_DIR}/summary.txt" - - # Extract effective coverage percentage for threshold check. - EFFECTIVE_PCT=$(grep -oP 'Effective line coverage:\s+\K[0-9.]+' \ - "${JUSTIFICATION_DIR}/summary.txt" 2>/dev/null || echo "0") - - # Threshold check (default: 100%) - THRESHOLD="${COVERAGE_THRESHOLD:-100}" - if awk "BEGIN {exit (${EFFECTIVE_PCT} >= ${THRESHOLD}) ? 0 : 1}"; then - : - else - echo "WARNING: Effective coverage ${EFFECTIVE_PCT}% is below threshold ${THRESHOLD}%" >&2 - fi - fi -else - echo "INFO: No coverage_justifications.yaml found, skipping justification processing." -fi - -# --------------------------------------------------------------------------- -# Optional: create a zip archive with the HTML report, raw LCOV data and -# JUnit XML test results. -# --------------------------------------------------------------------------- -if [[ -n "${ARCHIVE_NAME}" ]]; then - mkdir -p artifacts - - # Copy JUnit XML test results preserving directory structure - find bazel-testlogs/score/ -name 'test.xml' -exec cp --parents {} artifacts/ \; - - # Copy the HTML coverage report - cp -r "${OUTPUT_DIR}" artifacts/ - - # Include the LCOV .dat file (from zip for LLVM, or directly for gcov). - if [[ -f "${TMPDIR_EXTRACT}/lcov_report/lcov.dat" ]]; then - cp "${TMPDIR_EXTRACT}/lcov_report/lcov.dat" artifacts/coverage_report.dat - elif [[ -f "${TMPDIR_EXTRACT}/lcov.dat" ]]; then - cp "${TMPDIR_EXTRACT}/lcov.dat" artifacts/coverage_report.dat - fi - - zip -r "${ARCHIVE_NAME}.zip" artifacts/ - rm -rf artifacts/ - echo "Coverage archive written to: ${ARCHIVE_NAME}.zip" -fi diff --git a/quality/coverage/justify.py b/quality/coverage/justify.py deleted file mode 100644 index 488b7c55bb..0000000000 --- a/quality/coverage/justify.py +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Coverage justification processor. - -Parses the YAML justification database and source files for COV_JUSTIFIED markers. -Resolves all justified lines and produces a manifest mapping file:line → justification. - -Usage: - python justify.py --yaml --source-root --output - -Supports two ways to specify justified lines: -1. YAML locations: directly specify file + line ranges in the YAML -2. In-code markers: COV_JUSTIFIED , COV_JUSTIFIED_START / COV_JUSTIFIED_STOP -""" - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any, Dict, List, Set, Tuple - -import yaml - - -# Marker patterns -COV_JUSTIFIED_LINE_RE = re.compile(r"COV_JUSTIFIED\s+([\w-]+)") -COV_JUSTIFIED_START_RE = re.compile(r"COV_JUSTIFIED_START\s+([\w-]+)") -COV_JUSTIFIED_STOP_RE = re.compile(r"COV_JUSTIFIED_STOP") - -VALID_CATEGORIES = { - "defensive_programming", - "tool_false_positive", - "platform_specific", - "other", -} - -VALID_PLATFORMS = { - "linux", - "qnx", -} - - -def main() -> None: - """Main entry point.""" - args = parse_args() - - justifications_data = load_yaml(args.yaml) - validate_yaml(justifications_data) - - # Build lookup: id -> justification entry - justifications_by_id: Dict[str, Dict[str, Any]] = {} - for entry in justifications_data.get("justifications", []): - justifications_by_id[entry["id"]] = entry - - # Filter justifications by platform if --platform is specified. - if args.platform: - justifications_by_id = { - jid: entry for jid, entry in justifications_by_id.items() if _matches_platform(entry, args.platform) - } - - # Resolve all justified lines - resolved: Dict[str, Dict[int, Dict[str, str]]] = {} - warnings: List[str] = [] - errors: List[str] = [] - - # 1. Process YAML direct locations - for jid, entry in justifications_by_id.items(): - for location in entry.get("locations", []): - file_path = location["file"] - full_path = Path(args.source_root) / file_path - - if not full_path.exists(): - errors.append(f"File not found for justification '{entry['id']}': {file_path}") - continue - - lines = resolve_location_lines(location) - if file_path not in resolved: - resolved[file_path] = {} - for line in lines: - resolved[file_path][line] = { - "id": entry["id"], - "category": entry["category"], - "reason": entry["reason"].strip(), - } - - # 2. Scan source files for in-code COV_JUSTIFIED markers - source_files = collect_source_files(args.source_root, args.file_filter) - for source_file in source_files: - rel_path = str(source_file.relative_to(args.source_root)) - scan_warnings, scan_lines = scan_file_for_markers(source_file, rel_path, justifications_by_id) - warnings.extend(scan_warnings) - - if scan_lines: - if rel_path not in resolved: - resolved[rel_path] = {} - for line_num, justification_info in scan_lines.items(): - resolved[rel_path][line_num] = justification_info - - # Output manifest - manifest = { - "version": 1, - "source_root": str(args.source_root), - "justified_files": { - filepath: {str(k): v for k, v in lines.items()} for filepath, lines in sorted(resolved.items()) - }, - "warnings": warnings, - "errors": errors, - } - - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, indent=2) - - # Print diagnostics - total_justified_lines = sum(len(lines) for lines in resolved.values()) - print( - f"INFO: Resolved {total_justified_lines} justified lines across {len(resolved)} files.", - file=sys.stderr, - ) - if warnings: - for w in warnings: - print(f"WARNING: {w}", file=sys.stderr) - if errors: - for e in errors: - print(f"ERROR: {e}", file=sys.stderr) - sys.exit(1) - - -def resolve_location_lines(location: Dict[str, Any]) -> List[int]: - """Resolve line numbers from a YAML location entry.""" - if "lines" in location: - return location["lines"] - elif "line_start" in location and "line_end" in location: - return list(range(location["line_start"], location["line_end"] + 1)) - elif "line" in location: - return [location["line"]] - return [] - - -def _matches_platform(entry: Dict[str, Any], platform: str) -> bool: - """Check if a justification entry applies to the given platform. - - The ``platforms`` field is mandatory and validated by ``validate_yaml``. - """ - platforms = entry.get("platforms", []) - return platform in platforms - - -def scan_file_for_markers( - file_path: Path, - rel_path: str, - justifications_by_id: Dict[str, Dict[str, Any]], -) -> Tuple[List[str], Dict[int, Dict[str, str]]]: - """Scan a source file for COV_JUSTIFIED markers.""" - warnings = [] - justified_lines: Dict[int, Dict[str, str]] = {} - - try: - with open(file_path, "r", encoding="utf-8", errors="replace") as f: - lines = f.readlines() - except (IOError, OSError): - return warnings, justified_lines - - region_stack: List[Tuple[int, str]] = [] # (start_line, justification_id) - - for line_num, line in enumerate(lines, start=1): - # Check for COV_JUSTIFIED_START - start_match = COV_JUSTIFIED_START_RE.search(line) - if start_match: - jid = start_match.group(1) - if jid not in justifications_by_id: - warnings.append(f"{rel_path}:{line_num}: COV_JUSTIFIED_START references unknown ID '{jid}'") - else: - region_stack.append((line_num, jid)) - continue - - # Check for COV_JUSTIFIED_STOP - stop_match = COV_JUSTIFIED_STOP_RE.search(line) - if stop_match: - if not region_stack: - warnings.append(f"{rel_path}:{line_num}: COV_JUSTIFIED_STOP without matching START") - else: - start_line, jid = region_stack.pop() - if jid in justifications_by_id: - entry = justifications_by_id[jid] - for ln in range(start_line + 1, line_num): - justified_lines[ln] = { - "id": jid, - "category": entry["category"], - "reason": entry["reason"].strip(), - } - continue - - # Check for single-line COV_JUSTIFIED (but not START/STOP) - if "COV_JUSTIFIED_START" not in line and "COV_JUSTIFIED_STOP" not in line: - line_match = COV_JUSTIFIED_LINE_RE.search(line) - if line_match: - jid = line_match.group(1) - if jid not in justifications_by_id: - warnings.append(f"{rel_path}:{line_num}: COV_JUSTIFIED references unknown ID '{jid}'") - else: - entry = justifications_by_id[jid] - justified_lines[line_num] = { - "id": jid, - "category": entry["category"], - "reason": entry["reason"].strip(), - } - - # Check for unclosed regions - for start_line, jid in region_stack: - warnings.append(f"{rel_path}:{start_line}: COV_JUSTIFIED_START '{jid}' without matching STOP") - - return warnings, justified_lines - - -def collect_source_files(source_root: Path, file_filter: str) -> List[Path]: - """Collect source files to scan for markers.""" - extensions = file_filter.split(",") if file_filter else ["cpp", "h", "hpp", "cc", "rs"] - files = [] - for ext in extensions: - for path in source_root.rglob(f"*.{ext.strip()}"): - # Skip Bazel convenience symlinks (bazel-bin, bazel-out, bazel-, ...) - # so the marker scan does not descend into build outputs. - rel_parts = path.relative_to(source_root).parts - if rel_parts and rel_parts[0].startswith("bazel-"): - continue - files.append(path) - return sorted(files) - - -def load_yaml(yaml_path: Path) -> Dict[str, Any]: - """Load YAML justification database.""" - if not yaml_path.exists(): - print(f"ERROR: Justification YAML not found: {yaml_path}", file=sys.stderr) - sys.exit(1) - - with open(yaml_path, "r", encoding="utf-8") as f: - content = f.read() - - return yaml.safe_load(content) - - -def validate_yaml(data: Dict[str, Any]) -> None: - """Validate the justification YAML structure and types.""" - try: - errors = [] - - if not isinstance(data, dict): - print("ERROR: YAML validation: root must be a mapping", file=sys.stderr) - sys.exit(1) - - if "version" not in data: - errors.append("Missing 'version' field") - elif not isinstance(data["version"], int): - errors.append(f"'version' must be an integer, got {type(data['version']).__name__}") - - if "justifications" not in data: - errors.append("Missing 'justifications' field") - for e in errors: - print(f"ERROR: {e}", file=sys.stderr) - sys.exit(1) - - if not isinstance(data["justifications"], list): - errors.append(f"'justifications' must be a list, got {type(data['justifications']).__name__}") - for e in errors: - print(f"ERROR: YAML validation: {e}", file=sys.stderr) - sys.exit(1) - - seen_ids: Set[str] = set() - for i, entry in enumerate(data["justifications"]): - prefix = f"justifications[{i}]" - - if not isinstance(entry, dict): - errors.append(f"{prefix}: must be a mapping, got {type(entry).__name__}") - continue - - if "id" not in entry: - errors.append(f"{prefix}: missing 'id'") - continue - - jid = entry["id"] - if not isinstance(jid, str): - errors.append(f"{prefix}: 'id' must be a string, got {type(jid).__name__}") - continue - - if jid in seen_ids: - errors.append(f"{prefix}: duplicate ID '{jid}'") - seen_ids.add(jid) - - if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", jid): - errors.append(f"{prefix}: ID '{jid}' must be kebab-case") - - if "category" not in entry: - errors.append(f"{prefix}: missing 'category'") - elif not isinstance(entry["category"], str): - errors.append(f"{prefix}: 'category' must be a string, got {type(entry['category']).__name__}") - elif entry["category"] not in VALID_CATEGORIES: - errors.append( - f"{prefix}: invalid category '{entry['category']}'. Must be one of: {sorted(VALID_CATEGORIES)}" - ) - - if "platforms" not in entry: - errors.append(f"{prefix}: missing 'platforms'") - elif not isinstance(entry["platforms"], list): - errors.append(f"{prefix}: 'platforms' must be a list, got {type(entry['platforms']).__name__}") - elif not entry["platforms"]: - errors.append(f"{prefix}: 'platforms' must not be empty") - else: - for p in entry["platforms"]: - if not isinstance(p, str): - errors.append(f"{prefix}: 'platforms' entries must be strings, got {type(p).__name__}") - elif p not in VALID_PLATFORMS: - errors.append(f"{prefix}: invalid platform '{p}'. Must be one of: {sorted(VALID_PLATFORMS)}") - - if "reason" not in entry: - errors.append(f"{prefix}: missing 'reason'") - elif not isinstance(entry["reason"], str): - errors.append(f"{prefix}: 'reason' must be a string, got {type(entry['reason']).__name__}") - elif not entry["reason"].strip(): - errors.append(f"{prefix}: 'reason' must not be empty") - - if "locations" in entry: - if not isinstance(entry["locations"], list): - errors.append(f"{prefix}: 'locations' must be a list, got {type(entry['locations']).__name__}") - else: - for j, loc in enumerate(entry["locations"]): - loc_prefix = f"{prefix}.locations[{j}]" - if not isinstance(loc, dict): - errors.append(f"{loc_prefix}: must be a mapping, got {type(loc).__name__}") - continue - if "file" not in loc: - errors.append(f"{loc_prefix}: missing 'file'") - elif not isinstance(loc["file"], str): - errors.append(f"{loc_prefix}: 'file' must be a string, got {type(loc['file']).__name__}") - for int_field in ("line", "line_start", "line_end"): - if int_field in loc and not isinstance(loc[int_field], int): - errors.append( - f"{loc_prefix}: '{int_field}' must be an integer, " - f"got {type(loc[int_field]).__name__}" - ) - if "lines" in loc: - if not isinstance(loc["lines"], list): - errors.append( - f"{loc_prefix}: 'lines' must be a list, got {type(loc['lines']).__name__}" - ) - elif not all(isinstance(ln, int) for ln in loc["lines"]): - errors.append(f"{loc_prefix}: 'lines' must contain only integers") - - if errors: - for e in errors: - print(f"ERROR: YAML validation: {e}", file=sys.stderr) - sys.exit(1) - except Exception as error: - print(f"ERROR: YAML validation: {error}", file=sys.stderr) - sys.exit(1) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description="Coverage justification processor") - parser.add_argument( - "--yaml", - type=Path, - required=True, - help="Path to coverage_justifications.yaml", - ) - parser.add_argument( - "--source-root", - type=Path, - required=True, - help="Root directory of source files", - ) - parser.add_argument( - "--output", - type=Path, - required=True, - help="Output path for resolved justification manifest (JSON)", - ) - parser.add_argument( - "--file-filter", - type=str, - default="cpp,h,hpp,cc,rs", - help="Comma-separated file extensions to scan (default: cpp,h,hpp,cc,rs)", - ) - parser.add_argument( - "--platform", - type=str, - default=None, - choices=sorted(VALID_PLATFORMS), - help="Target platform for filtering justifications (default: all platforms apply)", - ) - return parser.parse_args() - - -if __name__ == "__main__": - main() diff --git a/quality/coverage/lcov_to_html.py b/quality/coverage/lcov_to_html.py deleted file mode 100644 index 0f1df07eb2..0000000000 --- a/quality/coverage/lcov_to_html.py +++ /dev/null @@ -1,492 +0,0 @@ -#!/usr/bin/env python3 -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Generate HTML coverage reports from LCOV data using gcovr. - -Converts LCOV trace data to gcovr's JSON format (v0.14), then invokes gcovr -to produce syntax-highlighted HTML reports with full source code annotation. - -The output uses gcovr's native HTML format with --html-details, producing: - / - coverage_details.html <- Index page - coverage_details...html <- Per-source annotated pages - style.css, *.js <- Assets - -Usage: - python lcov_to_html.py --lcov --output-dir \ - [--source-root ] [--filter-regexes ] -""" - -import argparse -import json -import os -import re -import sys -import tempfile -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Set, Tuple - -PROC_SELF_CWD_PREFIX = "/proc/self/cwd/" - - -def parse_lcov(lcov_path: Path) -> List[Dict[str, Any]]: - """Parse an LCOV trace file into gcovr JSON v0.14 file entries. - - Returns a list of dicts, each representing one file in the gcovr JSON - format with keys: "file", "lines", "functions". - """ - files: List[Dict[str, Any]] = [] - current_file: Optional[str] = None - current_lines: Dict[int, Dict[str, Any]] = {} - current_branches: Dict[int, List[Dict[str, Any]]] = {} - fn_lines: Dict[str, int] = {} - fn_counts: Dict[str, int] = {} - - with open(lcov_path, encoding="utf-8") as f: - for raw_line in f: - line = raw_line.strip() - - if line.startswith("SF:"): - if current_file is not None: - files.append( - _build_file_entry( - current_file, - current_lines, - current_branches, - fn_lines, - fn_counts, - ) - ) - current_file = line[3:] - current_lines = {} - current_branches = {} - fn_lines = {} - fn_counts = {} - - elif current_file is None: - continue - - elif line.startswith("DA:"): - parts = line[3:].split(",") - if len(parts) >= 2: - lineno = int(parts[0]) - count = max(0, int(parts[1])) - current_lines[lineno] = {"line_number": lineno, "count": count} - - elif line.startswith("BRDA:"): - parts = line[5:].split(",") - if len(parts) >= 4: - lineno = int(parts[0]) - taken_str = parts[3] - if taken_str == "-": - taken = 0 - else: - taken = max(0, int(taken_str)) - branch_list = current_branches.setdefault(lineno, []) - branch = { - "count": taken, - "fallthrough": False, - "throw": False, - "source_block_id": int(parts[1]), - "destination_block_id": int(parts[2]), - } - branch_list.append(branch) - - elif line.startswith("FN:"): - parts = line[3:].split(",", 1) - if len(parts) == 2: - fn_lines[parts[1]] = int(parts[0]) - - elif line.startswith("FNDA:"): - parts = line[5:].split(",", 1) - if len(parts) == 2: - fn_counts[parts[1]] = int(parts[0]) - - elif line == "end_of_record": - if current_file is not None: - files.append( - _build_file_entry( - current_file, - current_lines, - current_branches, - fn_lines, - fn_counts, - ) - ) - current_file = None - current_lines = {} - current_branches = {} - fn_lines = {} - fn_counts = {} - - if current_file is not None: - files.append(_build_file_entry(current_file, current_lines, current_branches, fn_lines, fn_counts)) - - return files - - -def _load_allowlist(path: Path) -> Set[str]: - """Load workspace-relative file paths from an allowlist file.""" - if not path.exists(): - print(f"WARNING: Allowlist file not found: {path}", file=sys.stderr) - return set() - - entries = set() - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#"): - entries.add(line) - return entries - - -def _normalize_entry_path(filename: str, source_root: str) -> str: - """Normalize LCOV paths to workspace-relative paths for comparison/output.""" - normalized_source_root = source_root.rstrip("/") - if normalized_source_root and filename.startswith(normalized_source_root + "/"): - return filename[len(normalized_source_root) + 1 :] - if filename.startswith(PROC_SELF_CWD_PREFIX): - return filename[len(PROC_SELF_CWD_PREFIX) :] - if filename.startswith("./"): - return filename[2:] - return filename - - -def _normalize_file_entries( - file_entries: Sequence[Dict[str, Any]], - source_root: str, -) -> List[Dict[str, Any]]: - """Normalize all file paths in LCOV entries.""" - normalized: List[Dict[str, Any]] = [] - for entry in file_entries: - updated = dict(entry) - updated["file"] = _normalize_entry_path(entry["file"], source_root) - normalized.append(updated) - return normalized - - -def _filter_file_entries_by_allowlist( - file_entries: Sequence[Dict[str, Any]], - allowlist: Set[str], - source_root: str, -) -> List[Dict[str, Any]]: - """Keep LCOV entries in allowlist; return all entries unchanged when allowlist is empty.""" - if not allowlist: - return list(file_entries) - - filtered: List[Dict[str, Any]] = [] - for entry in file_entries: - normalized = _normalize_entry_path(entry["file"], source_root) - if normalized in allowlist: - updated = dict(entry) - updated["file"] = normalized - filtered.append(updated) - return filtered - - -def _merge_file_entries( - primary_entries: Sequence[Dict[str, Any]], - baseline_entries: Sequence[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """Merge primary LCOV entries with baseline entries. - - Primary entries keep precedence for execution counts; baseline contributes - missing files/lines with zero-hit data. - """ - merged_by_file: Dict[str, Dict[str, Any]] = {} - order: List[str] = [] - - for entry in list(primary_entries) + list(baseline_entries): - filename = entry["file"] - if filename not in merged_by_file: - merged_by_file[filename] = { - "file": filename, - "lines": [], - "functions": [], - } - order.append(filename) - _merge_single_file_entry(merged_by_file[filename], entry) - - return [merged_by_file[filename] for filename in order] - - -def _merge_single_file_entry(target: Dict[str, Any], incoming: Dict[str, Any]) -> None: - """Merge one file entry into another in-place.""" - target["lines"] = _merge_line_entries( - target.get("lines", []), - incoming.get("lines", []), - ) - target["functions"] = _merge_function_entries( - target.get("functions", []), - incoming.get("functions", []), - ) - - -def _clone_line_entry(line_entry: Dict[str, Any]) -> Dict[str, Any]: - """Copy line entries with branch data to avoid mutating shared dictionaries.""" - cloned = dict(line_entry) - cloned["branches"] = [dict(branch) for branch in line_entry.get("branches", [])] - return cloned - - -def _merge_line_entries( - existing_lines: Sequence[Dict[str, Any]], - incoming_lines: Sequence[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """Merge line coverage and branch data for a single source file. - - We keep the maximum execution count per line/branch identity instead of summing: - the same line can appear in multiple LCOV inputs for identical instrumentation, - and summing those overlapping records inflates execution counts. - """ - merged_lines = {line["line_number"]: _clone_line_entry(line) for line in existing_lines} - for incoming_line in incoming_lines: - line_number = incoming_line["line_number"] - incoming_copy = _clone_line_entry(incoming_line) - - if line_number not in merged_lines: - merged_lines[line_number] = incoming_copy - continue - - existing = merged_lines[line_number] - existing["count"] = max(existing.get("count", 0), incoming_copy.get("count", 0)) - existing["branches"] = _merge_branch_entries( - existing.get("branches", []), - incoming_copy.get("branches", []), - ) - - return sorted(merged_lines.values(), key=lambda line: line["line_number"]) - - -def _merge_function_entries( - existing_functions: Sequence[Dict[str, Any]], - incoming_functions: Sequence[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """Merge function coverage for a single source file.""" - merged_functions = {func["name"]: dict(func) for func in existing_functions} - for incoming_func in incoming_functions: - name = incoming_func["name"] - incoming_lineno = incoming_func.get("lineno", 0) - incoming_execution_count = incoming_func.get("execution_count", 0) - - if name not in merged_functions: - merged_functions[name] = dict(incoming_func) - continue - - existing_func = merged_functions[name] - if existing_func.get("lineno", 0) == 0 and incoming_lineno != 0: - existing_func["lineno"] = incoming_lineno - existing_func["execution_count"] = max( - existing_func.get("execution_count", 0), - incoming_execution_count, - ) - - return sorted(merged_functions.values(), key=lambda func: func["name"]) - - -def _merge_branch_entries( - existing_branches: Sequence[Dict[str, Any]], - incoming_branches: Sequence[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """Merge branch lists and keep max execution count per branch identity.""" - merged: Dict[Tuple[Any, Any, Any, Any], Dict[str, Any]] = {} - for branch in list(existing_branches) + list(incoming_branches): - key = ( - branch.get("source_block_id"), - branch.get("destination_block_id"), - branch.get("fallthrough", False), - branch.get("throw", False), - ) - branch_copy = dict(branch) - if key in merged: - merged[key]["count"] = max(merged[key].get("count", 0), branch_copy.get("count", 0)) - else: - merged[key] = branch_copy - return list(merged.values()) - - -def _build_file_entry( - filename: str, - lines: Dict[int, Dict[str, Any]], - branches: Dict[int, List[Dict[str, Any]]], - fn_lines: Dict[str, int], - fn_counts: Dict[str, int], -) -> Dict[str, Any]: - """Build a single gcovr JSON v0.14 file entry.""" - # Attach branches to their respective lines. - json_lines = [] - for lineno in sorted(lines.keys()): - line_entry = dict(lines[lineno]) - line_entry["branches"] = branches.get(lineno, []) - json_lines.append(line_entry) - - # Build functions list. - json_functions = [] - all_fn_names = set(fn_lines.keys()) | set(fn_counts.keys()) - for name in sorted(all_fn_names): - func_entry: Dict[str, Any] = {"name": name} - if name in fn_lines: - func_entry["lineno"] = fn_lines[name] - else: - func_entry["lineno"] = 0 - if name in fn_counts: - func_entry["execution_count"] = fn_counts[name] - json_functions.append(func_entry) - - return { - "file": filename, - "lines": json_lines, - "functions": json_functions, - } - - -def _load_filter_regexes(path: Path) -> List[re.Pattern]: - """Load filter regexes from a file (one regex per line, # comments ignored).""" - if not path.exists(): - print(f"WARNING: Filter regexes file not found: {path}", file=sys.stderr) - return [] - patterns = [] - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#"): - patterns.append(re.compile(line)) - return patterns - - -def main() -> None: - """Main entry point.""" - args = parse_args() - - lcov_path = Path(args.lcov) - if not lcov_path.exists(): - print(f"ERROR: LCOV file not found: {lcov_path}", file=sys.stderr) - sys.exit(1) - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - # Parse LCOV into gcovr JSON file entries. - file_entries = parse_lcov(lcov_path) - if not file_entries: - print("WARNING: No coverage data found in LCOV file.", file=sys.stderr) - - # Determine source root for gcovr (--root). - source_root = args.source_root if args.source_root else os.getcwd() - file_entries = _normalize_file_entries(file_entries, source_root) - - if args.baseline_lcov: - baseline_lcov_path = Path(args.baseline_lcov) - if not baseline_lcov_path.exists(): - print( - f"ERROR: Baseline LCOV file not found: {baseline_lcov_path}", - file=sys.stderr, - ) - sys.exit(1) - baseline_entries = parse_lcov(baseline_lcov_path) - if baseline_entries: - baseline_entries = _normalize_file_entries(baseline_entries, source_root) - file_entries = _merge_file_entries(file_entries, baseline_entries) - print(f"Merged baseline LCOV from {baseline_lcov_path} ({len(baseline_entries)} file records)") - - allowlist: Set[str] = set() - if args.allowlist: - allowlist = _load_allowlist(Path(args.allowlist)) - if not allowlist: - print(f"ERROR: Allowlist is empty: {args.allowlist}", file=sys.stderr) - sys.exit(1) - before = len(file_entries) - file_entries = _filter_file_entries_by_allowlist(file_entries, allowlist, source_root) - removed = before - len(file_entries) - if removed > 0: - print(f"Filtered out {removed} files outside the allowlist ({len(file_entries)} remaining)") - - # Apply filename filters (exclude files matching any regex). - if args.filter_regexes: - filter_patterns = _load_filter_regexes(Path(args.filter_regexes)) - if filter_patterns: - before = len(file_entries) - file_entries = [ - entry for entry in file_entries if not any(p.search(entry["file"]) for p in filter_patterns) - ] - excluded = before - len(file_entries) - if excluded > 0: - print(f"Filtered out {excluded} files ({len(file_entries)} remaining)") - - # Write gcovr JSON v0.14 tracefile. - gcovr_json = { - "gcovr/format_version": "0.14", - "files": file_entries, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="lcov_to_gcovr_", delete=False) as tmp: - json.dump(gcovr_json, tmp) - tracefile_path = tmp.name - - try: - # Invoke gcovr to generate HTML from the JSON tracefile. - output_file = str(output_dir / "index.html") - gcovr_args = [ - "gcovr", - "--add-tracefile", - tracefile_path, - "--html-details", - output_file, - "--root", - source_root, - ] - - print(f"Running gcovr with {len(file_entries)} files...") - sys.argv = gcovr_args - from gcovr.__main__ import main as gcovr_main - - try: - gcovr_main() - except SystemExit as e: - if e.code != 0: - print(f"ERROR: gcovr exited with code {e.code}", file=sys.stderr) - sys.exit(e.code) - - print(f"HTML coverage report written to: {output_dir}") - finally: - os.unlink(tracefile_path) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description="Generate HTML coverage report from LCOV data using gcovr") - parser.add_argument("--lcov", required=True, help="Path to LCOV .dat file") - parser.add_argument("--output-dir", required=True, help="Directory for HTML output") - parser.add_argument( - "--source-root", - default="", - help="Source root for resolving relative paths (default: cwd)", - ) - parser.add_argument( - "--filter-regexes", - default="", - help="Path to file with regexes (one per line) to exclude from report", - ) - parser.add_argument( - "--allowlist", - default="", - help="Path to workspace-relative file allowlist (one path per line)", - ) - parser.add_argument( - "--baseline-lcov", - default="", - help="Path to LCOV baseline tracefile to merge before filtering", - ) - return parser.parse_args() - - -if __name__ == "__main__": - main() diff --git a/quality/coverage/llvm_cov/BUILD b/quality/coverage/llvm_cov/BUILD deleted file mode 100644 index 64ae936301..0000000000 --- a/quality/coverage/llvm_cov/BUILD +++ /dev/null @@ -1,42 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -load("@rules_python//python:defs.bzl", "py_binary") -load("//quality/coverage/llvm_cov:reporter_wrapper.bzl", "reporter_wrapper") - -py_binary( - name = "merger", - srcs = ["merger.py"], -) - -py_binary( - name = "reporter", - testonly = True, - srcs = ["reporter.py"], - data = [ - "//:MODULE.bazel", - "@llvm_toolchain//:llvm-cov", - "@llvm_toolchain//:llvm-profdata", - # Demangler for C++ (Itanium) and Rust (v0/legacy) symbol names. - "@llvm_toolchain_llvm//:bin/llvm-cxxfilt", - ], - deps = ["@rules_python//python/runfiles"], -) - -reporter_wrapper( - name = "reporter_wrapper", - testonly = True, - coverage_scope = "//quality/coverage:coverage_scope", - module_bazel = "//:MODULE.bazel", - reporter = ":reporter", -) diff --git a/quality/coverage/llvm_cov/README.md b/quality/coverage/llvm_cov/README.md deleted file mode 100644 index cb2274c565..0000000000 --- a/quality/coverage/llvm_cov/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# llvm-cov Coverage Tools - -This directory contains the Python tools that power the coverage pipeline. They are invoked by Bazel (merger + reporter) and by the `generate_coverage_html.sh` orchestrator script (justify + effective_coverage). - -## Tool Overview - -| Tool | Invoked By | Purpose | -|------|-----------|---------| -| `merger.py` | Bazel (`--coverage_output_generator`) | Per-test: profraw → profdata + metadata zip | -| `reporter.py` | Bazel (`--coverage_report_generator`) | Final: merge all profdata → HTML + LCOV + summary | -| `justify.py` | `generate_coverage_html.sh` | Resolve YAML + code markers → justification manifest | -| `effective_coverage.py` | `generate_coverage_html.sh` | Post-process HTML report + calculate effective coverage | - -## Data Flow - -``` -Test execution - │ - ▼ -┌──────────┐ profraw files -│ merger.py │ ◄── from each test -└────┬─────┘ - │ Per-test zip: {profdata, metadata.json} - ▼ -┌────────────┐ All per-test zips -│ reporter.py │ ◄── listed in --reports_file -└────┬───────┘ - │ _coverage_report.dat (zip): - │ ├── html_report/ (llvm-cov show --format=html) - │ ├── lcov_report/lcov.dat (llvm-cov export --format=lcov) - │ └── text_report/summary.txt (llvm-cov report) - ▼ -┌───────────────────────────┐ -│ generate_coverage_html.sh │ ◄── bazel run //quality/coverage:generate_coverage_html -└────┬──────────────────────┘ - │ Extracts html_report/ → cpp_coverage_/ - ▼ -┌─────────────┐ coverage_justifications.yaml -│ justify.py │ ◄── + source files (COV_JUSTIFIED markers) -└────┬────────┘ - │ manifest.json: {file → {line → justification}} - ▼ -┌───────────────────────┐ -│ effective_coverage.py │ ◄── manifest.json + html_report/ -└────┬──────────────────┘ - │ • Modifies HTML in-place (restyled justified lines/branches) - │ • report.json + summary.txt - ▼ - Console output: effective coverage summary -``` - -Right now we do not perform the justification and effective coverage calculation in the reporter, as it will not have -access to the whole code base, which makes the integration more difficult. This can maybe be a future improvement. - ---- - -## merger.py — Per-Test Coverage Output Generator - -**Bazel role:** `--coverage_output_generator` (replaces the default `collect_coverage.sh` output step) - -**What it does:** - -1. Receives `.profraw` files from a single test execution -2. Finds the instrumented object files from the source manifest -3. Runs `llvm-profdata merge` to create a `.profdata` file -4. Collects metadata (llvm-tools path, workspace root, excluded source patterns) -5. Packages `{profdata, metadata.json}` into a zip file for the reporter - -**Interface (called by Bazel's `collect_coverage.sh`):** - -``` -merger.py --coverage_dir= \ - --output_file= \ - --source_file_manifest= \ - --filter_sources= \ # repeatable - [--sources_to_replace_file=] -``` - -**Key behaviors:** - -- Resolves the actual workspace root by following Bazel sandbox symlinks (important for `--path-equivalence` in later stages) -- Cleans up dangling symlinks in the coverage directory that can cause `llvm-profdata` to fail -- Extracts `--ignore-filename-regex` patterns from `--filter_sources` for source filtering - ---- - -## reporter.py — Final Combined Report Generator - -**Bazel role:** `--coverage_report_generator` (replaces the default lcov-based reporter) - -**What it does:** - -1. Reads the list of per-test zip files from `--reports_file` -2. Extracts profdata + metadata from each zip -3. Merges all per-test profdata artifacts into a single `merged_coverage.profdata` via `llvm-profdata merge`. For baseline-only archives (files not covered by test binaries), the reporter uses `llvm-cov --empty-profile` so baseline coverage no longer depends on synthesizing a baseline profdata file. If no reports are present (or no valid profdata/object files are extracted), the reporter exits early with an empty output file, matching Bazel's empty-coverage behavior. -4. Generates three output formats: - - **HTML report** via `llvm-cov show --format=html` with branch counts and expansion views - - **LCOV data** via `llvm-cov export --format=lcov` (backward compatibility with dashboards) - - **Text summary** via `llvm-cov report --summary-only` -5. Packages everything into a zip file at `_coverage_report.dat` - -**Interface (called by Bazel):** - -``` -reporter.py --reports_file= \ - --output_file= -``` - -When invoked via the Bazel wrapper, these core args are accompanied by wrapper-injected arguments (such as `--coverage_allowlist`, `--baseline_objects`, and `--workspace_root`). - -**Source filtering:** - -The reporter applies `--ignore-filename-regex` to all `llvm-cov` commands to exclude: -- Test files and benchmarks -- External/third-party code -- Any paths matching patterns collected from `--filter_sources` during the merger phase - -These patterns are propagated via `metadata.json` in each per-test zip. - -**llvm-cov show options:** - -| Option | Purpose | -|--------|---------| -| `--show-branches=count` | Show branch coverage with execution counts | -| `--show-expansions` | Expand template instantiations inline | -| `--coverage-watermark=100,50` | Green ≥100%, yellow ≥50%, red <50% | -| `--path-equivalence=/proc/self/cwd/,` | Map sandbox paths to real source paths | -| `--Xdemangler=llvm-cxxfilt` | Demangle C++ symbol names | - ---- - -## justify.py — Justification Resolver - -**What it does:** - -Resolves all justified lines from two sources and produces a unified manifest: - -1. **YAML locations** — `file` + `line_start`/`line_end` entries in `coverage_justifications.yaml` -2. **In-code markers** — `COV_JUSTIFIED `, `COV_JUSTIFIED_START ` / `COV_JUSTIFIED_STOP` comments - -**Interface:** - -``` -python3 justify.py --yaml \ - --source-root \ - --output \ - [--platform ] -``` - -When `--platform` is specified, only justifications that apply to that platform are included -in the output manifest. Each justification must declare its `platforms` explicitly. - -**Output format (manifest.json):** - -```json -{ - "version": 1, - "justified_files": { - "score/mw/com/impl/some_file.cpp": { - "42": {"id": "my-id", "category": "defensive_programming", "reason": "..."}, - "43": {"id": "my-id", "category": "defensive_programming", "reason": "..."} - } - } -} -``` - -**Validation rules:** - -- Justification IDs must be unique and kebab-case (lowercase + hyphens) -- Every justification must have a non-empty `reason` -- Category must be one of: `defensive_programming`, `tool_false_positive`, `platform_specific`, `other` -- Platforms are required and must be a non-empty list containing `linux` and/or `qnx` -- In-code `COV_JUSTIFIED ` markers must reference an ID defined in the YAML - -**In-code marker patterns:** - -| Pattern | Scope | -|---------|-------| -| `// COV_JUSTIFIED ` | Justifies the current line | -| `// COV_JUSTIFIED_START ` | Starts a justified region | -| `// COV_JUSTIFIED_STOP` | Ends the justified region | - ---- - -## effective_coverage.py — HTML Post-Processor & Coverage Calculator - -**What it does:** - -1. Loads the justification manifest from `justify.py` -2. Post-processes the llvm-cov HTML report in-place: - - **Lines:** Uncovered justified lines get class `justified-line`, count shows "J", code background turns orange - - **Branches:** Uncovered branches on justified lines get class `justified-branch` (orange text) -3. Updates the index page: - - Adds an effective coverage banner (line + branch) - - Updates per-file line% and branch% cells to show effective (raw + justified) coverage - - Updates the TOTALS row -4. Calculates and reports effective coverage metrics -5. Detects stale justifications - -**Interface:** - -``` -python3 effective_coverage.py --html-dir \ - --manifest \ - --output -``` - -**Output files:** - -| File | Content | -|------|---------| -| `report.json` | Machine-readable report: summary stats, applied justifications, stale warnings | -| `summary.txt` | Human-readable coverage summary | - -**Template instantiation handling:** - -C++ templates produce multiple instantiations of the same source line in the HTML. The tool handles this correctly: -- **Line coverage:** A line is "covered" if ANY instantiation covers it. All instantiation occurrences of a justified line are restyled. -- **Branch coverage:** A branch direction (True/False) is "truly uncovered" only if NO instantiation covers it. Only truly uncovered branch directions count toward justified branches. -- **Statistics:** Raw coverage numbers are parsed directly from the llvm-cov index page TOTALS row, guaranteeing an exact match with llvm-cov's own calculations. - -**Stale justification detection:** - -A justification is "stale" when BOTH: -- The line is already covered by tests, AND -- All branches at that line are covered - -If the line is covered but has uncovered branches, the justification is still needed (for the branches) and is NOT stale. - -**CSS classes injected into style.css:** - -| Class | Applied To | Visual | -|-------|-----------|--------| -| `.justified-line` | Count cell (``) | Right-aligned orange text, shows "J" | -| `.region.justified` | Code spans (replacing `.region.red`) | Orange background | -| `.justified-branch` | Branch direction spans (replacing `.red.branch`) | Bold orange text | -| `tr:has(> td.justified-line) > td.code` | Entire code cell for justified rows | Light orange background | - ---- - -## Bazel Build Targets - -Defined in `BUILD`: - -```python -py_binary(name = "merger", ...) # Per-test coverage output generator -py_binary(name = "reporter", ...) # Final combined report generator -py_binary(name = "justify", ...) # Justification resolver -py_binary(name = "effective_coverage", ...) # HTML post-processor -``` - -The `reporter` target includes `justify.py` and `effective_coverage.py` as `data` dependencies for best-effort in-sandbox justification processing. - -## Dependencies - -- **Python 3** (system Python, no virtualenv needed) -- **PyYAML** — for parsing `coverage_justifications.yaml` (available via pip or system package) -- **llvm-profdata** — for merging profraw/profdata files (from LLVM toolchain) -- **llvm-cov** — for generating HTML, LCOV, and text reports (from LLVM toolchain) diff --git a/quality/coverage/llvm_cov/merger.py b/quality/coverage/llvm_cov/merger.py deleted file mode 100644 index 059b7b3d8a..0000000000 --- a/quality/coverage/llvm_cov/merger.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Per-test coverage output generator using llvm-cov. - -This script is invoked by Bazel as the --coverage_output_generator for each test. -It receives profraw files from test execution, merges them into profdata, generates -an HTML coverage report using llvm-cov show, and packages everything into a zip file -that the reporter can later aggregate. - -Expected Bazel interface (from collect_coverage.sh): - --coverage_dir= Directory containing *.profraw files - --output_file= Where to write the output (zip) - --source_file_manifest= File listing instrumented sources and object files - --filter_sources= Source path regexes to exclude (repeatable) - [--sources_to_replace_file=] Optional source mapping file -""" - -import argparse -import json -import os -import subprocess -import sys -import zipfile -from pathlib import Path -from typing import List, Set - - -def main() -> None: - args = parse_args() - - # Get object files from the manifest. - object_files = get_object_files_from_manifest(args.source_file_manifest) - if not object_files: - print( - "INFO: No instrumented object files found, skipping coverage.", - file=sys.stderr, - ) - cleanup_dangling_symlinks(args.coverage_dir) - sys.exit(0) - - # Find profraw files. - profraw_files = sorted(args.coverage_dir.glob("*.profraw")) - if not profraw_files: - print("INFO: No *.profraw files found, skipping coverage.", file=sys.stderr) - cleanup_dangling_symlinks(args.coverage_dir) - sys.exit(0) - - llvm_profdata = find_llvm_profdata() - - # Merge profraw → profdata. - profdata_dir = args.coverage_dir / "profdata" - profdata_dir.mkdir(exist_ok=True) - profdata_file = profdata_dir / "target.profdata" - - run_command( - [ - llvm_profdata, - "merge", - "--sparse", - "--output", - str(profdata_file), - ] - + [str(f) for f in profraw_files] - ) - - # Create meta.json with object files for the reporter. - meta_dir = args.coverage_dir / "meta" - meta_dir.mkdir(exist_ok=True) - meta = { - "object_files": [os.path.realpath(f) for f in sorted(object_files)], - } - with open(meta_dir / "meta.json", "w", encoding="utf-8") as f: - json.dump(meta, f) - - # Package into zip at output_file. - create_zip( - root=args.coverage_dir, - directories=[profdata_dir, meta_dir], - output_file=args.output_file, - ) - - # Clean up dangling symlinks in coverage_dir that would cause Bazel tree - # artifact validation to fail (e.g. the 'gcov' symlink created by - # collect_cc_coverage.sh's init_gcov() pointing into the destroyed sandbox). - cleanup_dangling_symlinks(args.coverage_dir) - - target = os.environ.get("TEST_TARGET", "unknown") - print(f"INFO: Coverage merger completed for '{target}'", file=sys.stderr) - - -def find_llvm_profdata() -> str: - """Locate the llvm-profdata binary, terminating with an error when absent. - - C++ tests: Bazel exports LLVM_PROFDATA from the cc toolchain. - Rust tests: rules_rust exports RUST_LLVM_PROFDATA (an execroot-relative - path to the rust_toolchain's llvm_profdata) instead; resolve it against - the current directory, ROOT and the runfiles dir. - """ - direct = os.environ.get("LLVM_PROFDATA") - if direct and Path(direct).exists(): - return direct - - rust = os.environ.get("RUST_LLVM_PROFDATA") - if rust: - exec_root = Path(os.environ.get("ROOT", ".")) - runfiles_dir = Path(os.environ.get("RUNFILES_DIR", "")) / os.environ.get("TEST_WORKSPACE", "_main") - for candidate in [Path(rust), exec_root / rust, runfiles_dir / rust]: - if candidate.exists(): - return str(candidate) - - print( - "ERROR: llvm-profdata not found. Checked LLVM_PROFDATA " - f"({direct or 'unset'}) and RUST_LLVM_PROFDATA ({rust or 'unset'}). " - "For C++ tests the cc toolchain must export LLVM_PROFDATA; for Rust " - "tests the rust_toolchain must declare llvm_profdata " - "(score_toolchains_rust >= 0.9.2 with coverage-tools >= 1.3.0).", - file=sys.stderr, - ) - sys.exit(1) - - -def cleanup_dangling_symlinks(directory: Path) -> None: - """Remove symlinks in the coverage directory that would become dangling. - - Bazel's tree artifact validation rejects directories containing dangling - symlinks. The 'gcov' symlink created by collect_cc_coverage.sh's init_gcov() - points into the sandbox which is torn down before validation runs. Since we - use llvm-cov directly, this symlink is not needed. - """ - gcov_link = directory / "gcov" - if gcov_link.is_symlink(): - gcov_link.unlink() - - # Also remove any other symlinks pointing into sandbox paths. - for entry in directory.iterdir(): - if entry.is_symlink(): - target = os.readlink(entry) - if "sandbox" in target: - entry.unlink() - - -def get_object_files_from_manifest(source_file_manifest: Path) -> Set[str]: - """Parse the coverage manifest to find instrumented object files.""" - runfiles_dir = Path(os.environ.get("RUNFILES_DIR", "")) / os.environ.get("TEST_WORKSPACE", "_main") - exec_root = Path(os.environ.get("ROOT")) - - object_files = set() - with open(source_file_manifest, encoding="utf-8") as f: - manifests = [line.strip() for line in f.readlines()] - - for manifest in manifests: - if "objects_list.txt" in manifest: - with open(manifest, encoding="utf-8") as f: - for line in f: - obj_path = line.strip() - if not obj_path: - continue - # Try runfiles first, then exec_root. - candidate = runfiles_dir / obj_path - if candidate.exists(): - object_files.add(str(candidate)) - else: - object_files.add(str(exec_root / obj_path)) - else: - # Rust tests: rules_rust lists the instrumented test executable - # itself in the manifest (via coverage metadata_files) instead of - # an objects_list.txt. Pick up manifest entries that are ELF - # binaries directly. Skip external/ entries: the rust_toolchain - # also lists its llvm-cov/llvm-profdata binaries as metadata - # files, and those are not instrumented objects. - if manifest.startswith("external/") or "/external/" in manifest: - continue - for candidate in [ - runfiles_dir / manifest, - exec_root / manifest, - Path(manifest), - ]: - if candidate.is_file() and is_elf(candidate): - object_files.add(str(candidate)) - break - - return object_files - - -def is_elf(path: Path) -> bool: - """Return True if the file at path is an ELF binary.""" - try: - with open(path, "rb") as f: - return f.read(4) == b"\x7fELF" - except OSError: - return False - - -def run_command(cmd: List[str]) -> subprocess.CompletedProcess: - """Run a command and exit on failure.""" - try: - return subprocess.run( - cmd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - except subprocess.CalledProcessError as e: - print(f"ERROR: Command failed with code {e.returncode}:", file=sys.stderr) - print(f" {' '.join(cmd)}", file=sys.stderr) - if e.stdout: - print(e.stdout, file=sys.stderr) - sys.exit(1) - - -def create_zip(root: Path, directories: List[Path], output_file: Path) -> None: - """Create a zip file from the given directories relative to root.""" - with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf: - for directory in directories: - if not directory.exists(): - continue - for dirpath, _, files in os.walk(directory): - for filename in files: - file_path = Path(dirpath) / filename - arcname = file_path.relative_to(root) - zf.write(file_path, arcname) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments matching the Bazel LCOV_MERGER interface.""" - parser = argparse.ArgumentParser(description="LLVM coverage merger for Bazel") - parser.add_argument("--coverage_dir", type=Path, required=True) - parser.add_argument("--output_file", type=Path, required=True) - parser.add_argument("--source_file_manifest", type=Path, required=True) - parser.add_argument("--filter_sources", action="append", default=[]) - parser.add_argument("--sources_to_replace_file", type=str, default=None) - return parser.parse_args() - - -if __name__ == "__main__": - main() diff --git a/quality/coverage/llvm_cov/reporter.py b/quality/coverage/llvm_cov/reporter.py deleted file mode 100644 index 42b17a4fbb..0000000000 --- a/quality/coverage/llvm_cov/reporter.py +++ /dev/null @@ -1,691 +0,0 @@ -#!/usr/bin/env python3 -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -"""Final coverage report generator using llvm-cov. - -This script is invoked by Bazel as the --coverage_report_generator after all tests -complete. It reads the per-test zip files produced by the merger, merges all profdata -into one, and generates the final combined HTML report. - -Expected Bazel interface: - --reports_file= Text file listing paths to all per-test coverage outputs - --output_file= Where to write the final report (zip) -""" - -import argparse -import json -import os -import re -import subprocess -import sys -import zipfile -from pathlib import Path -from typing import List, Optional, Set, Tuple -from python.runfiles import Runfiles - - -def main() -> None: - """Main entry point.""" - args = parse_args() - r = Runfiles.Create() - - # Read the list of per-test report files. - reports = read_reports_file(args.reports_file) - if not reports: - print("INFO: No coverage reports found.", file=sys.stderr) - write_empty_output(args.output_file) - sys.exit(0) - - # Extract profdata and object files from each per-test zip. - valid_profdata_files, valid_object_files = extract_reports(reports) - - if not valid_profdata_files or not valid_object_files: - # This is expected when the covered target set contains no - # instrumented C++/Rust code: the merger legitimately produces no - # profdata/object files for such targets. Treat it like Bazel's - # own empty-coverage behavior instead of failing the build. - print("INFO: No valid profdata or object files found.", file=sys.stderr) - write_empty_output(args.output_file) - sys.exit(0) - - sorted_objects = sorted(valid_object_files) - - # Get llvm tools via runfiles. - llvm_bin_path = Path(r.Rlocation("llvm_toolchain/llvm-cov")) - - # Merge all per-test profdata files. - merged_profdata = Path.cwd() / "merged_coverage.profdata" - merge_inputs = sorted(set(valid_profdata_files)) - run_command( - [ - r.Rlocation("llvm_toolchain/llvm-profdata"), - "merge", - "--output", - str(merged_profdata), - ] - + merge_inputs - ) - - # Load baseline objects (production library archives) for zero-coverage baseline. - baseline_objects = load_baseline_objects(r, args.baseline_objects, args.workspace_root) - - # Rust rlib archives (exposed as .a symlinks by rules_rust) start with a - # lib.rmeta member, which makes llvm-cov reject the whole archive with - # "no coverage data found" even though the .o members carry the covmap. - # Expand such archives into their object members. - baseline_objects = expand_rlib_archives(baseline_objects, Path.cwd() / "rlib_baseline_objects") - - # Determine filter regexes: prefer allowlist-based filtering, fall back to manual regexes. - workspace_root = args.workspace_root - allowlist_files = [] - filter_regexes = [] - baseline_only_archives = [] - baseline_only_files = set() - - if args.coverage_allowlist: - allowlist_files = load_coverage_allowlist(r, args.coverage_allowlist) - if allowlist_files: - print( - f"INFO: Using coverage allowlist with {len(allowlist_files)} source files.", - file=sys.stderr, - ) - allowlist_set = set(allowlist_files) - - # Get files covered by test binaries. - test_covered_files = get_covered_files(llvm_bin_path, sorted_objects, str(merged_profdata), workspace_root) - print( - f"INFO: Test binaries cover {len(test_covered_files)} files.", - file=sys.stderr, - ) - - # Get files from baseline archives via a SEPARATE llvm-cov run. - # Combining archives with test binaries in a single llvm-cov invocation - # causes some files to vanish (suspected llvm-cov deduplication issue). - # Some archives may have oversized coverage mappings ("malformed coverage - # data"), so we iteratively remove bad ones. - baseline_files = set() - if baseline_objects: - baseline_files = get_covered_files(llvm_bin_path, baseline_objects, None, workspace_root) - print( - f"INFO: Baseline archives contain {len(baseline_files)} files.", - file=sys.stderr, - ) - - # Files only in baseline archives (not in any test binary). - baseline_only_files = (baseline_files & allowlist_set) - test_covered_files - baseline_only_archives = [] - if baseline_only_files: - print( - f"INFO: {len(baseline_only_files)} allowlisted files only in baseline " - f"(e.g., {sorted(baseline_only_files)[:5]})", - file=sys.stderr, - ) - # Use all valid baseline archives for LCOV generation. - # The _filter_lcov function will filter to only baseline-only files. - baseline_only_archives = list(baseline_objects) - - # Union of test + baseline for exclude-set calculation. - all_covered_files = test_covered_files | baseline_files - files_to_exclude = all_covered_files - allowlist_set - filter_regexes = [re.escape(f) + "$" for f in sorted(files_to_exclude)] - print( - f"INFO: Excluding {len(filter_regexes)} files not in allowlist.", - file=sys.stderr, - ) - else: - print( - "ERROR: Coverage allowlist is empty, falling back to filter_regexes.txt.", - file=sys.stderr, - ) - sys.exit(-1) - common_args = { - "llvm_bin_path": llvm_bin_path, - "objects": sorted_objects, - "instr_profile": str(merged_profdata), - "filter_regexes": sorted(filter_regexes), - "workspace_root": workspace_root, - } - - # Generate HTML report including baseline-only files when valid archives are available. - html_report_dir = Path.cwd() / "html_report" - if baseline_only_archives: - all_html_objects = sorted_objects + baseline_only_archives - html_args = { - **common_args, - "objects": all_html_objects, - } - try: - run_llvm_cov_show( - **html_args, - output_format="html", - html_report_dir=html_report_dir, - ) - except SystemExit: - # Some baseline archives caused llvm-cov show to fail; retry with test binaries only. - print( - "WARNING: HTML generation with baseline archives failed; falling back to test-only HTML.", - file=sys.stderr, - ) - run_llvm_cov_show( - **common_args, - output_format="html", - html_report_dir=html_report_dir, - ) - else: - run_llvm_cov_show( - **common_args, - output_format="html", - html_report_dir=html_report_dir, - ) - - # Generate LCOV report from test binaries. - lcov_report_dir = Path.cwd() / "lcov_report" - lcov_report_dir.mkdir(exist_ok=True) - lcov_result = run_llvm_cov_export(**common_args) - lcov_content = lcov_result.stdout - - # If there are baseline-only files, generate a separate baseline LCOV and merge. - if baseline_only_archives: - baseline_lcov_args = { - "llvm_bin_path": llvm_bin_path, - "objects": baseline_only_archives, - "instr_profile": None, - "filter_regexes": [], # No filtering — we only have the needed archives. - "workspace_root": workspace_root, - } - baseline_lcov = run_llvm_cov_export(**baseline_lcov_args) - if baseline_lcov.stdout: - # Filter baseline LCOV to only include baseline-only files. - filtered_baseline = _filter_lcov(baseline_lcov.stdout, baseline_only_files) - if filtered_baseline: - lcov_content += filtered_baseline - print( - f"INFO: Merged baseline LCOV for {len(baseline_only_files)} files.", - file=sys.stderr, - ) - - with open(lcov_report_dir / "lcov.dat", "w", encoding="utf-8") as f: - f.write(lcov_content) - - # Generate text summary. - text_report_dir = Path.cwd() / "text_report" - text_report_dir.mkdir(exist_ok=True) - summary = run_llvm_cov_report(**common_args) - with open(text_report_dir / "summary.txt", "w", encoding="utf-8") as f: - f.write(summary.stdout) - print(summary.stdout, file=sys.stderr) - - # Package everything into the output zip. - directories = [html_report_dir, lcov_report_dir, text_report_dir] - create_zip( - root=Path.cwd(), - directories=directories, - output_file=args.output_file, - ) - - print( - f"INFO: Coverage reporter completed. Output: {args.output_file}", - file=sys.stderr, - ) - - -def _filter_lcov(lcov_content: str, target_files: set) -> str: - """Filter LCOV content to only include records for target files. - - LCOV format: SF: starts a record, end_of_record ends it. - """ - result = [] - current_record = [] - include = False - - for line in lcov_content.splitlines(keepends=True): - if line.startswith("SF:"): - current_record = [line] - filepath = line[3:].strip() - # Check if the file path (or its suffix) matches any target file. - include = any(filepath.endswith(f) for f in target_files) - elif line.strip() == "end_of_record": - current_record.append(line) - if include: - result.extend(current_record) - current_record = [] - include = False - else: - current_record.append(line) - - return "".join(result) - - -def get_covered_files( - llvm_bin_path: Path, - objects: List[str], - instr_profile: Optional[str], - workspace_root: str, -) -> set: - """Run a quick llvm-cov report to discover all files with coverage data. - - Returns a set of workspace-relative file paths. - """ - cmd = [ - str(llvm_bin_path), - "report", - f"--path-equivalence=/proc/self/cwd/,{workspace_root}", - ] - if instr_profile is None: - cmd.append("--empty-profile") - else: - cmd.extend(["--instr-profile", instr_profile]) - cmd.append(objects[0]) - for obj in objects[1:]: - cmd.extend(["--object", obj]) - - result = run_command(cmd) - if result.returncode != 0: - return set() - - files = set() - in_files = False - for line in result.stdout.splitlines(): - if line.startswith("---"): - in_files = True - continue - if line.startswith("TOTAL"): - break - if not in_files: - continue - # Extract filename (everything before first multi-space + digit sequence) - match = re.match(r"^(.+?)\s{2,}\d+", line) - if match: - filename = match.group(1).strip() - # Normalize to workspace-relative form. llvm-cov report prints the - # raw covmap path: for C++ that is the recorded compilation dir - # /proc/self/cwd/, --path-equivalence does not rewrite the - # DISPLAYED path. Rust covmap paths are already exec-root relative. - for prefix in (workspace_root, "/proc/self/cwd/"): - if filename.startswith(prefix): - filename = filename[len(prefix) :] - break - files.add(filename) - - return files - - -def run_llvm_cov_show( - llvm_bin_path: Path, - objects: List[str], - instr_profile: Optional[str], - filter_regexes: List[str], - workspace_root: str, - output_format: str, - html_report_dir: Path = None, -) -> subprocess.CompletedProcess: - """Run llvm-cov show.""" - cmd = [ - str(llvm_bin_path), - "show", - f"--format={output_format}", - f"--path-equivalence=/proc/self/cwd/,{workspace_root}", - f"--compilation-dir={workspace_root}", - "--show-branches=count", - "--show-region-summary=0", - ] - - cxxfilt = find_cxxfilt(llvm_bin_path) - if cxxfilt: - cmd.append(f"--Xdemangler={cxxfilt}") - - for regex in filter_regexes: - cmd.append(f"--ignore-filename-regex={regex}") - - if html_report_dir: - cmd.append(f"--output-dir={html_report_dir}") - cmd.append("--coverage-watermark=100,50") - cmd.append("--show-expansions") - - if instr_profile is None: - cmd.append("--empty-profile") - else: - cmd.extend(["--instr-profile", instr_profile]) - cmd.append(objects[0]) - for obj in objects[1:]: - cmd.extend(["--object", obj]) - - return run_command(cmd) - - -def run_llvm_cov_export( - llvm_bin_path: Path, - objects: List[str], - instr_profile: Optional[str], - filter_regexes: List[str], - workspace_root: str, -) -> subprocess.CompletedProcess: - """Run llvm-cov export to produce LCOV format.""" - cmd = [ - str(llvm_bin_path), - "export", - "--format=lcov", - f"--path-equivalence=/proc/self/cwd/,{workspace_root}", - f"--compilation-dir={workspace_root}", - ] - - for regex in filter_regexes: - cmd.append(f"--ignore-filename-regex={regex}") - - if instr_profile is None: - cmd.append("--empty-profile") - else: - cmd.extend(["--instr-profile", instr_profile]) - cmd.append(objects[0]) - for obj in objects[1:]: - cmd.extend(["--object", obj]) - - return run_command(cmd) - - -def run_llvm_cov_report( - llvm_bin_path: Path, - objects: List[str], - instr_profile: Optional[str], - filter_regexes: List[str], - workspace_root: str, -) -> subprocess.CompletedProcess: - """Run llvm-cov report for a text summary.""" - cmd = [ - str(llvm_bin_path), - "report", - f"--path-equivalence=/proc/self/cwd/,{workspace_root}", - "--show-region-summary=0", - "--show-branch-summary=1", - ] - - for regex in filter_regexes: - cmd.append(f"--ignore-filename-regex={regex}") - - if instr_profile is None: - cmd.append("--empty-profile") - else: - cmd.extend(["--instr-profile", instr_profile]) - cmd.append(objects[0]) - for obj in objects[1:]: - cmd.extend(["--object", obj]) - - return run_command(cmd) - - -def extract_reports(reports: List[str]) -> Tuple[Set[str], Set[str]]: - """Extract profdata and object files from per-test zip files.""" - valid_profdata_files = set() - valid_object_files = set() - - for i, report_path in enumerate(reports): - # Skip baseline_coverage files (LCOV format, not our zip). - if "baseline_coverage" in report_path: - continue - - report = Path(report_path) - if not report.exists() or report.stat().st_size == 0: - continue - - # Check if it's a valid zip. - if not zipfile.is_zipfile(report): - continue - - profdata_name = f"coverage_report_{i:08d}.profdata" - - try: - with zipfile.ZipFile(report, "r") as archive: - # Extract meta. - meta_json = archive.read("meta/meta.json") - target_meta = json.loads(meta_json) - - # Extract profdata. - profdata_content = archive.read("profdata/target.profdata") - profdata_path = Path.cwd() / profdata_name - with open(profdata_path, "wb") as f: - f.write(profdata_content) - - valid_profdata_files.add(str(profdata_path)) - - # Collect object files. - for obj in target_meta.get("object_files", []): - if obj and Path(obj).exists(): - valid_object_files.add(os.path.realpath(obj)) - - except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e: - print(f"WARNING: Skipping invalid report {report_path}: {e}", file=sys.stderr) - continue - - return valid_profdata_files, valid_object_files - - -def read_reports_file(reports_file: Path) -> List[str]: - """Read the reports file listing all per-test coverage outputs.""" - with open(reports_file, encoding="utf-8") as f: - return [line.strip() for line in f if line.strip()] - - -def _read_ar_members(path: str) -> List[tuple]: - """Parse a Unix ar archive, returning (name, data_offset, size) tuples. - - Handles the GNU long-name table ("//" member with "/" references). - Returns an empty list when the file is not an ar archive. - """ - members = [] - longnames = b"" - with open(path, "rb") as f: - if f.read(8) != b"!\n": - return [] - while True: - header = f.read(60) - if len(header) < 60: - break - name = header[0:16].decode(errors="replace").rstrip() - try: - size = int(header[48:58].decode().strip() or "0") - except ValueError: - break - data_offset = f.tell() - if name == "//": - longnames = f.read(size) - else: - if name.startswith("/") and name[1:].isdigit(): - start = int(name[1:]) - end = longnames.find(b"\n", start) - name = longnames[start:end].decode(errors="replace").rstrip("/") - elif name.endswith("/"): - name = name[:-1] - members.append((name, data_offset, size)) - f.seek(size, 1) - if size % 2 == 1: - f.seek(1, 1) - return members - - -def expand_rlib_archives(objects: List[str], workdir: Path) -> List[str]: - """Replace Rust rlib archives with their extracted object members. - - llvm-cov rejects rlib archives ("no coverage data found") because of the - leading lib.rmeta member, even though the .o members carry the coverage - mapping. Non-rlib entries (C++ .a archives, executables) pass through - unchanged. - """ - result = [] - extracted = 0 - for obj in objects: - members = _read_ar_members(obj) if obj.endswith((".a", ".rlib")) else [] - if not any(name == "lib.rmeta" for name, _, _ in members): - result.append(obj) - continue - workdir.mkdir(parents=True, exist_ok=True) - with open(obj, "rb") as f: - for index, (name, offset, size) in enumerate(members): - if not name.endswith(".o"): - continue - f.seek(offset) - out_path = workdir / f"{Path(obj).stem}.{index}.o" - out_path.write_bytes(f.read(size)) - result.append(str(out_path)) - extracted += 1 - if extracted: - print( - f"INFO: Expanded {extracted} object(s) from Rust rlib baseline archives.", - file=sys.stderr, - ) - return result - - -def find_cxxfilt(llvm_bin_path: Path) -> str: - """Locate llvm-cxxfilt for demangling (C++ Itanium and Rust v0/legacy symbols). - - Tries the directory of llvm-cov first, then the @llvm_toolchain_llvm - distribution via runfiles (toolchains_llvm declares no alias for - llvm-cxxfilt, so it is wired as a direct data dependency). - Terminates with an error when unavailable: the binary is a declared data - dependency of the reporter, so its absence indicates a broken setup. - """ - sibling = llvm_bin_path.parent / "llvm-cxxfilt" - if sibling.exists(): - return str(sibling) - r = Runfiles.Create() - if r: - location = r.Rlocation("llvm_toolchain_llvm/bin/llvm-cxxfilt") - if location and Path(location).exists(): - return location - print( - "ERROR: llvm-cxxfilt not found (checked next to llvm-cov and in the " - "@llvm_toolchain_llvm runfiles). It is a declared data dependency of " - "the reporter; check //quality/coverage/llvm_cov:reporter.", - file=sys.stderr, - ) - sys.exit(1) - - -def load_coverage_allowlist(runfiles: Runfiles, rlocation_path: str) -> List[str]: - """Load coverage allowlist (package paths) from a file via Bazel runfiles.""" - path = runfiles.Rlocation(rlocation_path) - if not path or not Path(path).exists(): - return [] - - lines = Path(path).read_text(encoding="utf-8").splitlines() - return [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")] - - -def load_baseline_objects( - runfiles: Runfiles, - rlocation_path: str, - workspace_root: str, -) -> List[str]: - """Load baseline object archive paths and resolve them to absolute paths. - - The objects manifest lists relative paths to .a files. When the reporter runs - in the exec config, the manifest paths use the exec config dir - (e.g., k8-opt-exec-*). - """ - if not rlocation_path: - return [] - - path = runfiles.Rlocation(rlocation_path) - if not path or not Path(path).exists(): - print( - f"WARNING: Baseline objects manifest not found: {rlocation_path}", - file=sys.stderr, - ) - return [] - - lines = Path(path).read_text(encoding="utf-8").splitlines() - resolved = [] - for line in lines: - line = line.strip() - if not line or line.startswith("#"): - continue - # Dynamically gets the canonical repository name - repo_root = runfiles.CurrentRepository() - if not repo_root: - repo_root = "_main" # Safe Bzlmod root fallback - - # Cleanly stitch the path together - path = runfiles.Rlocation(os.path.join(repo_root, line)) - if os.path.exists(path): - resolved.append(path) - else: - print(f"ERROR: Baseline object not found: {line}", file=sys.stderr) - sys.exit(-1) - return sorted(resolved) - - -def write_empty_output(output_file: Path) -> None: - """Write an empty file as output when there's nothing to report.""" - with open(output_file, "w", encoding="utf-8") as f: - f.write("") - - -def run_command(cmd: List[str]) -> subprocess.CompletedProcess: - """Run a command and exit on failure.""" - try: - return subprocess.run( - cmd, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - except subprocess.CalledProcessError as e: - print(f"ERROR: Command failed with code {e.returncode}:", file=sys.stderr) - print(f" {' '.join(cmd[:10])}{'...' if len(cmd) > 10 else ''}", file=sys.stderr) - if e.stdout: - print(e.stdout, file=sys.stderr) - sys.exit(1) - - -def create_zip(root: Path, directories: List[Path], output_file: Path) -> None: - """Create a zip file from the given directories relative to root.""" - with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf: - for directory in directories: - if not directory.exists(): - continue - for dirpath, _, files in os.walk(directory): - for filename in files: - file_path = Path(dirpath) / filename - arcname = file_path.relative_to(root) - zf.write(file_path, arcname) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments matching the Bazel coverage_report_generator interface.""" - parser = argparse.ArgumentParser(description="LLVM coverage reporter for Bazel") - parser.add_argument("--output_file", type=Path, required=True) - parser.add_argument("--reports_file", type=Path, required=True) - parser.add_argument( - "--coverage_allowlist", - type=str, - default=None, - help="Rlocation path to the coverage allowlist file (preferred over filter_regexes)", - ) - parser.add_argument( - "--baseline_objects", - type=str, - default=None, - help="Rlocation path to the baseline objects manifest (archive .a files)", - ) - parser.add_argument( - "--workspace_root", - type=str, - required=True, - help="Real workspace root path for source path mapping", - ) - return parser.parse_args() - - -if __name__ == "__main__": - main() diff --git a/quality/coverage/llvm_cov/reporter_wrapper.bzl b/quality/coverage/llvm_cov/reporter_wrapper.bzl deleted file mode 100644 index 8149c0302a..0000000000 --- a/quality/coverage/llvm_cov/reporter_wrapper.bzl +++ /dev/null @@ -1,87 +0,0 @@ -# ******************************************************************************* -# Copyright (c) 2026 Contributors to the Eclipse Foundation -# -# See the NOTICE file(s) distributed with this work for additional -# information regarding copyright ownership. -# -# This program and the accompanying materials are made available under the -# terms of the Apache License Version 2.0 which is available at -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* - -"""Executable wrapper rule for coverage reporter.""" - -visibility(["//..."]) - -def _reporter_wrapper_impl(ctx): - launcher = ctx.actions.declare_file(ctx.label.name + ".sh") - - reporter = ctx.executable.reporter - module_bazel = ctx.file.module_bazel - coverage_scope = ctx.attr.coverage_scope - allowlist_group = coverage_scope[OutputGroupInfo].allowlist.to_list() - objects_group = coverage_scope[OutputGroupInfo].objects.to_list() - object_files = coverage_scope[OutputGroupInfo].object_files - - if len(allowlist_group) != 1: - fail("coverage_scope must provide exactly one allowlist file") - if len(objects_group) != 1: - fail("coverage_scope must provide exactly one objects manifest file") - - allowlist = allowlist_group[0] - baseline_objects = objects_group[0] - - script = """#!/usr/bin/env bash -set -euo pipefail -if [[ -z "${{RUNFILES_DIR:-}}" ]]; then - if [[ -d "$0.runfiles" ]]; then - export RUNFILES_DIR="$0.runfiles" - fi -fi -WORKSPACE_ROOT="$(cd "$(dirname "$(readlink -f "${{RUNFILES_DIR}}/{module_bazel}")")" && pwd)/" -exec "${{RUNFILES_DIR}}/{reporter}" \\ - --coverage_allowlist="{allowlist}" \\ - --baseline_objects="{baseline_objects}" \\ - --workspace_root="${{WORKSPACE_ROOT}}" \\ - "$@" -""".format( - module_bazel = "_main/" + module_bazel.short_path, - reporter = "_main/" + reporter.short_path, - allowlist = "_main/" + allowlist.short_path, - baseline_objects = "_main/" + baseline_objects.short_path, - ) - - ctx.actions.write( - output = launcher, - content = script, - is_executable = True, - ) - - runfiles = ctx.runfiles( - files = [reporter, allowlist, baseline_objects, module_bazel], - transitive_files = object_files, - ).merge(ctx.attr.reporter[DefaultInfo].default_runfiles) - - return [DefaultInfo( - executable = launcher, - runfiles = runfiles, - )] - -reporter_wrapper = rule( - implementation = _reporter_wrapper_impl, - executable = True, - attrs = { - "reporter": attr.label( - executable = True, - cfg = "exec", - ), - "coverage_scope": attr.label( - cfg = "target", - ), - "module_bazel": attr.label( - allow_single_file = True, - ), - }, -) diff --git a/quality/dashboard/dashboard.html.j2 b/quality/dashboard/dashboard.html.j2 index 5c378f3c9a..f178da94f7 100644 --- a/quality/dashboard/dashboard.html.j2 +++ b/quality/dashboard/dashboard.html.j2 @@ -54,6 +54,22 @@

No coverage data available.

{% endif %} +{# ── QNX coverage cards ── #} +

Coverage (QNX)

+{% if cov_qnx %} +
+ {% for key, hk, label in [('line_pct','qnx_line_cov','Line Coverage'),('func_pct','qnx_func_cov','Function Coverage'),('branch_pct','qnx_branch_cov','Branch Coverage')] %} +
+
{{ '%.1f'|format(cov_qnx[key]) }}%
+ {% if prev and prev[hk] is not none %}
{{ delta(cov_qnx[key], prev[hk], true) }}
{% endif %} +
{{ label }}
+
+ {% endfor %} +
+{% else %} +

No QNX coverage data available.

+{% endif %} + {# ── Clang-Tidy summary cards ── #}

Clang-Tidy

{% if clang_tidy %} diff --git a/quality/dashboard/generate_dashboard.py b/quality/dashboard/generate_dashboard.py index 5f950e8352..ba9d670071 100644 --- a/quality/dashboard/generate_dashboard.py +++ b/quality/dashboard/generate_dashboard.py @@ -293,7 +293,7 @@ def save_history(path: pathlib.Path, history: list[dict]) -> None: # ── HTML rendering ──────────────────────────────────────────────────────────── -def render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history, timestamp) -> str: +def render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history, timestamp, cov_qnx=None) -> str: env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True) env.globals["cov_colour"] = _cov_colour env.globals["delta"] = _delta_badge @@ -304,6 +304,7 @@ def render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history timestamp=timestamp, cov=cov_summary or None, cov_files=cov_files, + cov_qnx=cov_qnx or None, clang_tidy=clang_tidy, clippy=clippy, codeql=codeql, @@ -315,7 +316,7 @@ def render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history # ── GitHub Actions step summary ─────────────────────────────────────────────── -def write_github_summary(cov_summary, clang_tidy, clippy, codeql, history, summary_path) -> None: +def write_github_summary(cov_summary, clang_tidy, clippy, codeql, history, summary_path, cov_qnx=None) -> None: lines = ["## Quality Dashboard\n"] lines.append("### Coverage\n") @@ -330,6 +331,18 @@ def write_github_summary(cov_summary, clang_tidy, clippy, codeql, history, summa else: lines.append("Coverage data not available.\n") + lines.append("\n### Coverage (QNX)\n") + if cov_qnx: + lines += [ + "| Metric | Value |", + "|--------|-------|", + f"| Lines | {cov_qnx['line_pct']:.1f}% ({cov_qnx['lines']}) |", + f"| Functions | {cov_qnx['func_pct']:.1f}% ({cov_qnx['funcs']}) |", + f"| Branches | {cov_qnx['branch_pct']:.1f}% ({cov_qnx['branches']}) |", + ] + else: + lines.append("QNX coverage data not available.\n") + lines.append("\n### Clang-Tidy\n") if clang_tidy: err_icon = ":x:" if clang_tidy["errors"] > 0 else ":white_check_mark:" @@ -382,6 +395,9 @@ def write_github_summary(cov_summary, clang_tidy, clippy, codeql, history, summa ("Line coverage %", "line_cov", True), ("Function coverage %", "func_cov", True), ("Branch coverage %", "branch_cov", True), + ("QNX line coverage %", "qnx_line_cov", True), + ("QNX function coverage %", "qnx_func_cov", True), + ("QNX branch coverage %", "qnx_branch_cov", True), ("Clang-Tidy errors", "ct_errors", False), ("Clang-Tidy warnings", "ct_warnings", False), ("Clippy errors", "clippy_errors", False), @@ -414,6 +430,12 @@ def main() -> int: default="", help="Path to LCOV .dat coverage data file", ) + parser.add_argument( + "--lcov-qnx", + default="", + dest="lcov_qnx", + help="Path to QNX LCOV .dat coverage data file (optional)", + ) parser.add_argument( "--clang-tidy", default="", @@ -458,6 +480,8 @@ def main() -> int: html_path.parent.mkdir(parents=True, exist_ok=True) cov_summary, cov_files = load_lcov(lcov_path) + lcov_qnx_path = pathlib.Path(args.lcov_qnx) if args.lcov_qnx else pathlib.Path("") + cov_qnx, _ = load_lcov(lcov_qnx_path) clang_tidy = load_linter_findings(ct_path) clippy = load_linter_findings(clippy_path) codeql = load_codeql_sarif(codeql_path) @@ -470,6 +494,9 @@ def main() -> int: "line_cov": cov_summary.get("line_pct") if cov_summary else None, "func_cov": cov_summary.get("func_pct") if cov_summary else None, "branch_cov": cov_summary.get("branch_pct") if cov_summary else None, + "qnx_line_cov": cov_qnx.get("line_pct") if cov_qnx else None, + "qnx_func_cov": cov_qnx.get("func_pct") if cov_qnx else None, + "qnx_branch_cov": cov_qnx.get("branch_pct") if cov_qnx else None, "ct_errors": clang_tidy["errors"] if clang_tidy else None, "ct_warnings": clang_tidy["warnings"] if clang_tidy else None, "clippy_errors": clippy["errors"] if clippy else None, @@ -485,7 +512,7 @@ def main() -> int: save_history(hist_path, history) html_path.write_text( - render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history, timestamp), + render_dashboard(cov_summary, cov_files, clang_tidy, clippy, codeql, history, timestamp, cov_qnx), encoding="utf-8", ) @@ -496,6 +523,12 @@ def main() -> int: print(f" Branches: {cov_summary['branch_pct']:.1f}% ({cov_summary['branches']})") else: print(" Coverage: N/A") + if cov_qnx: + print( + f" QNX Lines: {cov_qnx['line_pct']:.1f}% Functions: {cov_qnx['func_pct']:.1f}% Branches: {cov_qnx['branch_pct']:.1f}%" + ) + else: + print(" QNX Coverage: N/A") if clang_tidy: print(f" Clang-Tidy errors: {clang_tidy['errors']} warnings: {clang_tidy['warnings']}") else: @@ -519,6 +552,7 @@ def main() -> int: codeql, history, os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), + cov_qnx, ) return 0 diff --git a/quality/quality.md b/quality/quality.md index 883d7f3d8b..734889f5f0 100644 --- a/quality/quality.md +++ b/quality/quality.md @@ -215,11 +215,9 @@ The following markdown files are automatically created in `bazel-out/analysis_re ## Coverage -Code coverage is generated using LLVM's source-based coverage instrumentation. The instrumentation filter is configured in [`quality/coverage/coverage.bazelrc`](coverage/coverage.bazelrc) to cover `//score/message_passing` and `//score/mw/com` while excluding test and benchmark code. +Code coverage is produced by the qualified S-CORE coverage tool, [`score_coverage`](https://eclipse-score.github.io/coverage_tool/main/): LLVM source-based coverage for C++ and Rust on Linux, gcov-based coverage for the C++ tests that run on QNX inside QEMU. The coverage scope (`//score/message_passing`, `//score/mw/com`, `//score/mw/com/rust:score_com`) is declared in [`quality/coverage/BUILD`](coverage/BUILD); everything outside it (tests, mocks, external dependencies) is filtered out of the report. -HTML reports are generated directly by `llvm-cov show`. - -For detailed documentation of the pipeline architecture, tools, and requirements, see [`quality/coverage/README.md`](coverage/README.md) and [`quality/coverage/llvm_cov/README.md`](coverage/llvm_cov/README.md). +For the repository-specific configuration see [`quality/coverage/README.md`](coverage/README.md); for the pipeline itself, its requirements and its verification report, see the tool documentation. ### Running Coverage @@ -239,15 +237,18 @@ The coverage report generator produces a zip file at To extract the HTML report (works for both full and single-target runs): ```bash -bazel run //quality/coverage:generate_coverage_html +bazel run @score_coverage//:generate_coverage_html -- \ + --yaml quality/coverage/coverage_justifications.yaml --testlogs-subdir score ``` -The report is written to `cpp_coverage_/index.html`. Open it with: +The report is written to `coverage_/index.html`. Open it with: ```bash -xdg-open cpp_coverage_linux/index.html +xdg-open coverage_linux/index.html ``` +QNX on target (C++ only): `bazel coverage --config=qnx //score/... --build_tests_only`, then the same command with `--platform qnx`. + ### Coverage Justifications To achieve 100% effective coverage, lines and branches that cannot be covered by tests (defensive programming, tool false positives, etc.) can be *justified*. Justified lines and branches appear in **yellow/orange** in the HTML report (vs green=covered, red=uncovered). diff --git a/quality/scripts/extract_coverage_artifact.sh b/quality/scripts/extract_coverage_artifact.sh index 9a7b566287..b660066b4b 100644 --- a/quality/scripts/extract_coverage_artifact.sh +++ b/quality/scripts/extract_coverage_artifact.sh @@ -15,19 +15,22 @@ # quality output directory for the dashboard generator. # # Usage: -# bash quality/scripts/extract_coverage_artifact.sh [zip-dir] [output-dir] +# bash quality/scripts/extract_coverage_artifact.sh [zip-dir] [output-dir] [report-dir-name] # # Arguments: -# zip-dir Directory containing the downloaded coverage zip (default: /tmp/coverage_zip) -# output-dir Directory to copy the HTML report into (default: ${GITHUB_WORKSPACE}/_quality/coverage) +# zip-dir Directory containing the downloaded coverage zip (default: /tmp/coverage_zip) +# output-dir Directory to copy the HTML report into (default: ${GITHUB_WORKSPACE}/_quality/coverage) +# report-dir-name Name of the HTML report directory inside the archive +# (default: cpp_coverage_linux; use cpp_coverage_qnx for QNX artifacts) set -euo pipefail ZIP_DIR="${1:-/tmp/coverage_zip}" OUTPUT_DIR="${2:-${GITHUB_WORKSPACE}/_quality/coverage}" +REPORT_DIR_NAME="${3:-cpp_coverage_linux}" cd "${ZIP_DIR}" unzip *.zip -d extracted mkdir -p "${OUTPUT_DIR}" -cp -r extracted/artifacts/cpp_coverage_linux/. "${OUTPUT_DIR}/" +cp -r "extracted/artifacts/${REPORT_DIR_NAME}/." "${OUTPUT_DIR}/" diff --git a/quality/visibility_guard/public_targets.golden b/quality/visibility_guard/public_targets.golden index 1587dc05d4..4a1782f089 100644 --- a/quality/visibility_guard/public_targets.golden +++ b/quality/visibility_guard/public_targets.golden @@ -6,7 +6,6 @@ //quality/compiler_warnings:fortify_source //quality/compiler_warnings:optimize_code //quality/compiler_warnings:treat_warnings_as_errors -//quality/coverage:enable_llvm_coverage_for_death_tests //score/memory/shared/fake:fake_memory_resources //score/memory/shared/fake:my_bounded_shared_memory_resource //score/memory/shared/flock:exclusive_flock_mutex diff --git a/requirements.in b/requirements.in index 6c378099ed..de73d3f544 100644 --- a/requirements.in +++ b/requirements.in @@ -22,6 +22,3 @@ sphinx_needs sphinxcontrib_plantuml pydata_sphinx_theme myst_parser - -# Python dependencies for coverage report generation (QNX) -gcovr diff --git a/requirements_lock.txt b/requirements_lock.txt index 796f19c72b..cd4e0a29c7 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -207,10 +207,6 @@ charset-normalizer==3.5.2 \ --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f # via requests -colorlog==6.12.0 \ - --hash=sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f \ - --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e - # via gcovr docker==7.1.0 \ --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ --hash=sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0 @@ -223,10 +219,6 @@ docutils==0.22.4 \ # pydata-sphinx-theme # sphinx # sphinx-rtd-theme -gcovr==8.6 \ - --hash=sha256:b2e7042abca9321cadbab8a06eb34d19f801b831557b28cdc30a029313de8b9e \ - --hash=sha256:dbf9d87c38042752ad6f530aa8210427e22b526611bb7b7bfed0e81977d1f1ef - # via -r requirements.in idna==3.20 \ --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c @@ -243,7 +235,6 @@ jinja2==3.1.6 \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 # via - # gcovr # myst-parser # pydata-sphinx-theme # sphinx @@ -277,184 +268,6 @@ jsonschema-rs==0.52.1 \ --hash=sha256:ef551bc76041b967df43897d4397ba69dd7fb934a7579b86c3d071d6aa9cdaad \ --hash=sha256:f709d4666f7fc728829503ff653c8a578634ab1ed54e9f11097da89dd18baf75 # via sphinx-needs -lxml==6.1.3 \ - --hash=sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4 \ - --hash=sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9 \ - --hash=sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e \ - --hash=sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5 \ - --hash=sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe \ - --hash=sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc \ - --hash=sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748 \ - --hash=sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08 \ - --hash=sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5 \ - --hash=sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8 \ - --hash=sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741 \ - --hash=sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87 \ - --hash=sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6 \ - --hash=sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6 \ - --hash=sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633 \ - --hash=sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a \ - --hash=sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d \ - --hash=sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa \ - --hash=sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e \ - --hash=sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70 \ - --hash=sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867 \ - --hash=sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f \ - --hash=sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12 \ - --hash=sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156 \ - --hash=sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6 \ - --hash=sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5 \ - --hash=sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75 \ - --hash=sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48 \ - --hash=sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739 \ - --hash=sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37 \ - --hash=sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626 \ - --hash=sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015 \ - --hash=sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274 \ - --hash=sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165 \ - --hash=sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e \ - --hash=sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79 \ - --hash=sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d \ - --hash=sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d \ - --hash=sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b \ - --hash=sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026 \ - --hash=sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad \ - --hash=sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11 \ - --hash=sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9 \ - --hash=sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385 \ - --hash=sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7 \ - --hash=sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd \ - --hash=sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f \ - --hash=sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c \ - --hash=sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a \ - --hash=sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221 \ - --hash=sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167 \ - --hash=sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a \ - --hash=sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3 \ - --hash=sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054 \ - --hash=sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245 \ - --hash=sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21 \ - --hash=sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6 \ - --hash=sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e \ - --hash=sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13 \ - --hash=sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b \ - --hash=sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75 \ - --hash=sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b \ - --hash=sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d \ - --hash=sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0 \ - --hash=sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69 \ - --hash=sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414 \ - --hash=sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d \ - --hash=sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed \ - --hash=sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f \ - --hash=sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf \ - --hash=sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2 \ - --hash=sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c \ - --hash=sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2 \ - --hash=sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158 \ - --hash=sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d \ - --hash=sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d \ - --hash=sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c \ - --hash=sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861 \ - --hash=sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd \ - --hash=sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0 \ - --hash=sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d \ - --hash=sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5 \ - --hash=sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3 \ - --hash=sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0 \ - --hash=sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805 \ - --hash=sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a \ - --hash=sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8 \ - --hash=sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf \ - --hash=sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559 \ - --hash=sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d \ - --hash=sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c \ - --hash=sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a \ - --hash=sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65 \ - --hash=sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039 \ - --hash=sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92 \ - --hash=sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765 \ - --hash=sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1 \ - --hash=sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0 \ - --hash=sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1 \ - --hash=sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2 \ - --hash=sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758 \ - --hash=sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473 \ - --hash=sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310 \ - --hash=sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c \ - --hash=sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4 \ - --hash=sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3 \ - --hash=sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17 \ - --hash=sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e \ - --hash=sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9 \ - --hash=sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48 \ - --hash=sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94 \ - --hash=sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a \ - --hash=sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2 \ - --hash=sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55 \ - --hash=sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238 \ - --hash=sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e \ - --hash=sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56 \ - --hash=sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0 \ - --hash=sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0 \ - --hash=sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623 \ - --hash=sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e \ - --hash=sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1 \ - --hash=sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a \ - --hash=sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c \ - --hash=sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed \ - --hash=sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6 \ - --hash=sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4 \ - --hash=sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745 \ - --hash=sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae \ - --hash=sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6 \ - --hash=sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb \ - --hash=sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128 \ - --hash=sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9 \ - --hash=sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5 \ - --hash=sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9 \ - --hash=sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415 \ - --hash=sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8 \ - --hash=sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11 \ - --hash=sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8 \ - --hash=sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2 \ - --hash=sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a \ - --hash=sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300 \ - --hash=sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0 \ - --hash=sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145 \ - --hash=sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889 \ - --hash=sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9 \ - --hash=sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7 \ - --hash=sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559 \ - --hash=sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962 \ - --hash=sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682 \ - --hash=sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e \ - --hash=sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb \ - --hash=sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd \ - --hash=sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc \ - --hash=sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8 \ - --hash=sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53 \ - --hash=sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e \ - --hash=sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed \ - --hash=sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d \ - --hash=sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32 \ - --hash=sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477 \ - --hash=sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023 \ - --hash=sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887 \ - --hash=sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41 \ - --hash=sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6 \ - --hash=sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376 \ - --hash=sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702 \ - --hash=sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07 \ - --hash=sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5 \ - --hash=sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2 \ - --hash=sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4 \ - --hash=sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011 \ - --hash=sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458 \ - --hash=sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e \ - --hash=sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0 \ - --hash=sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a - # via gcovr markdown-it-py==4.2.0 \ --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a @@ -597,7 +410,6 @@ pygments==2.21.0 \ --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c # via # accessible-pygments - # gcovr # pydata-sphinx-theme # pytest # sphinx