From 154de8bb1280158feff23fcaa20e63eaf20326a8 Mon Sep 17 00:00:00 2001 From: Jan Schlosser Date: Mon, 13 Jul 2026 21:14:37 +0200 Subject: [PATCH 1/3] feat(coverage): derive scope allowlist for llvm-cov reporting Replace static filename-regex filtering with a Bazel-derived coverage scope: - add `coverage_scope` rule/aspect to collect dependable-element source files - generate allowlist + baseline object manifest for the reporter - add `reporter_wrapper` rule to wire scope artifacts into reporter invocation - update llvm-cov reporter to consume allowlist/baseline objects and support baseline-only coverage via `--empty-profile` - remove obsolete `filter_regexes.txt` and update coverage docs/config comments --- quality/coverage/BUILD | 11 + quality/coverage/README.md | 2 +- quality/coverage/coverage.bazelrc | 10 +- quality/coverage/coverage_scope.bzl | 186 ++++++++++ quality/coverage/llvm_cov/BUILD | 38 +- quality/coverage/llvm_cov/README.md | 4 +- quality/coverage/llvm_cov/filter_regexes.txt | 20 -- quality/coverage/llvm_cov/reporter.py | 338 ++++++++++++++---- .../coverage/llvm_cov/reporter_wrapper.bzl | 85 +++++ 9 files changed, 574 insertions(+), 120 deletions(-) create mode 100644 quality/coverage/coverage_scope.bzl delete mode 100644 quality/coverage/llvm_cov/filter_regexes.txt create mode 100644 quality/coverage/llvm_cov/reporter_wrapper.bzl diff --git a/quality/coverage/BUILD b/quality/coverage/BUILD index 309d9cb5b..17dd8d68a 100644 --- a/quality/coverage/BUILD +++ b/quality/coverage/BUILD @@ -15,6 +15,7 @@ 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", @@ -64,3 +65,13 @@ cc_feature( feature_name = "enable_llvm_coverage_for_death_tests", visibility = ["//visibility:public"], ) + +coverage_scope( + name = "coverage_scope", + testonly = True, + visibility = ["//quality/coverage:__subpackages__"], + deps = [ + "//score/mw/com/dependability:mw_com_index", + "//score/message_passing/dependability:dependable_element_message_passing_index", + ], +) diff --git a/quality/coverage/README.md b/quality/coverage/README.md index 896c4da71..bde2fa369 100644 --- a/quality/coverage/README.md +++ b/quality/coverage/README.md @@ -145,7 +145,7 @@ bazel coverage //... │ • Packages profdata + metadata into a zip │ └── Final: reporter.py (--coverage_report_generator) - • Merges all per-test profdata into one + • 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 diff --git a/quality/coverage/coverage.bazelrc b/quality/coverage/coverage.bazelrc index 2142c4877..0c7dcef5d 100644 --- a/quality/coverage/coverage.bazelrc +++ b/quality/coverage/coverage.bazelrc @@ -16,7 +16,6 @@ # ============================================================================ coverage --nocache_test_results coverage --cxxopt=-O0 -coverage --instrumentation_filter="^//score/message_passing[/:],^//score/mw/com/(impl|gateway|dependability|design|example|mocking|doc)" coverage --combined_report=lcov coverage --experimental_fetch_all_coverage_outputs @@ -34,11 +33,10 @@ coverage --dynamic_mode=off # 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 -# regardless of --instrumentation_filter. The actual source filtering happens -# in the merger/reporter via --ignore-filename-regex. The instrumentation_filter -# is kept for documentation purposes and in case this Bazel limitation is fixed -# in the future. +# 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 diff --git a/quality/coverage/coverage_scope.bzl b/quality/coverage/coverage_scope.bzl new file mode 100644 index 000000000..aa86de179 --- /dev/null +++ b/quality/coverage/coverage_scope.bzl @@ -0,0 +1,186 @@ +# ******************************************************************************* +# 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. +""" + +# ============================================================================= +# 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 + + # 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): + print(ctx.configuration.short_id) + print(ctx.configuration.coverage_enabled) + """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/llvm_cov/BUILD b/quality/coverage/llvm_cov/BUILD index bf2d5fcc0..3a0d26ad4 100644 --- a/quality/coverage/llvm_cov/BUILD +++ b/quality/coverage/llvm_cov/BUILD @@ -12,7 +12,7 @@ # ******************************************************************************* load("@rules_python//python:defs.bzl", "py_binary") -load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load("//quality/coverage/llvm_cov:reporter_wrapper.bzl", "reporter_wrapper") py_binary( name = "merger", @@ -21,9 +21,9 @@ py_binary( py_binary( name = "reporter", + testonly = True, srcs = ["reporter.py"], data = [ - "filter_regexes.txt", "//:MODULE.bazel", "@llvm_toolchain//:llvm-cov", "@llvm_toolchain//:llvm-profdata", @@ -31,34 +31,10 @@ py_binary( deps = ["@rules_python//python/runfiles"], ) -genrule( - name = "reporter_wrapper_gen", - srcs = [ - "filter_regexes.txt", - "//:MODULE.bazel", - ], - outs = ["reporter_wrapper.sh"], - cmd = """ -cat > $@ << EOF -#!/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}/$(rlocationpath //:MODULE.bazel)")")" && pwd)/" -exec "\\$${RUNFILES_DIR}/_main/quality/coverage/llvm_cov/reporter" \\\\ - --filter_regexes="$(rlocationpath filter_regexes.txt)" \\\\ - --workspace_root="\\$${WORKSPACE_ROOT}" \\\\ - "\\$$@" -EOF -chmod +x $@ -""", -) - -sh_binary( +reporter_wrapper( name = "reporter_wrapper", - srcs = [":reporter_wrapper_gen"], - data = [":reporter"], + 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 index d550e4107..cb2274c56 100644 --- a/quality/coverage/llvm_cov/README.md +++ b/quality/coverage/llvm_cov/README.md @@ -92,7 +92,7 @@ merger.py --coverage_dir= \ 1. Reads the list of per-test zip files from `--reports_file` 2. Extracts profdata + metadata from each zip -3. Merges all profdata into a single `merged_coverage.profdata` via `llvm-profdata merge` +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) @@ -106,6 +106,8 @@ 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: diff --git a/quality/coverage/llvm_cov/filter_regexes.txt b/quality/coverage/llvm_cov/filter_regexes.txt deleted file mode 100644 index c8ce6427c..000000000 --- a/quality/coverage/llvm_cov/filter_regexes.txt +++ /dev/null @@ -1,20 +0,0 @@ -# Coverage filter regexes (one per line). -# Lines matching any of these patterns are excluded from the coverage report -# via llvm-cov's --ignore-filename-regex option. -# -# NOTE: --experimental_use_llvm_covmap causes Bazel to instrument ALL targets -# regardless of --instrumentation_filter. Therefore, filtering MUST happen here -# at the report level. - -# Exclude mock files. -.*_mock.*\.(h|hpp|cpp)$ - -# Exclude external dependencies (anything under external/). -external/.* - -# Exclude test files and test directories. -.*_test\.(cpp|h|hpp)$ -.*/test/.* - -# Exclude performance benchmarks. -.*/performance_benchmarks/.* diff --git a/quality/coverage/llvm_cov/reporter.py b/quality/coverage/llvm_cov/reporter.py index fdceed616..e1a2fdad4 100644 --- a/quality/coverage/llvm_cov/reporter.py +++ b/quality/coverage/llvm_cov/reporter.py @@ -25,89 +25,171 @@ import argparse import json import os +import re import subprocess import sys import zipfile from pathlib import Path -from typing import List, Set, Tuple +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) + print("ERROR: No coverage reports found.", file=sys.stderr) + sys.exit(-1) # 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: print("INFO: No valid profdata or object files found.", file=sys.stderr) - write_empty_output(args.output_file) - sys.exit(0) + sys.exit(-1) + + sorted_objects = sorted(valid_object_files) # Get llvm tools via runfiles. - r = Runfiles.Create() llvm_bin_path = Path(r.Rlocation("llvm_toolchain/llvm-cov")) - # Merge all profdata files. + # 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", - "--sparse", "--output", str(merged_profdata), - ] + sorted(valid_profdata_files)) + ] + merge_inputs) - # Build coverage arguments. - coverage_args = ["--instr-profile", str(merged_profdata)] - for obj in sorted(valid_object_files): - coverage_args.extend(["--object", obj]) + # Load baseline objects (production library archives) for zero-coverage baseline. + baseline_objects = load_baseline_objects( + r, args.baseline_objects, args.workspace_root) - # Get filter regexes and workspace root. - filter_regexes = load_filter_regexes(r, args.filter_regexes) + # Determine filter regexes: prefer allowlist-based filtering, fall back to manual regexes. workspace_root = args.workspace_root - - common_show_args = { + 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, - "coverage_args": coverage_args, + "objects": sorted_objects, + "instr_profile": str(merged_profdata), "filter_regexes": sorted(filter_regexes), "workspace_root": workspace_root, } - # Generate HTML report. + # Generate HTML report including baseline-only files when valid archives are available. html_report_dir = Path.cwd() / "html_report" - run_llvm_cov_show( - **common_show_args, - output_format="html", - html_report_dir=html_report_dir, - ) + 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 (for backward compatibility with dashboards). + # 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( - llvm_bin_path=llvm_bin_path, - coverage_args=coverage_args, - filter_regexes=sorted(filter_regexes), - workspace_root=workspace_root, - ) + 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_result.stdout) + 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( - llvm_bin_path=llvm_bin_path, - coverage_args=coverage_args, - filter_regexes=sorted(filter_regexes), - ) + 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) @@ -123,9 +205,89 @@ def main() -> None: 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() + # Strip workspace_root prefix if present + if filename.startswith(workspace_root): + filename = filename[len(workspace_root):] + files.add(filename) + + return files + + + + + def run_llvm_cov_show( llvm_bin_path: Path, - coverage_args: List[str], + objects: List[str], + instr_profile: Optional[str], filter_regexes: List[str], workspace_root: str, output_format: str, @@ -147,21 +309,28 @@ def run_llvm_cov_show( cmd.append(f"--Xdemangler={cxxfilt}") for regex in filter_regexes: - adjusted = regex.replace("/proc/self/cwd/", workspace_root) - cmd.append(f"--ignore-filename-regex={adjusted}") + 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") - cmd.extend(coverage_args) + 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, - coverage_args: List[str], + objects: List[str], + instr_profile: Optional[str], filter_regexes: List[str], workspace_root: str, ) -> subprocess.CompletedProcess: @@ -175,23 +344,31 @@ def run_llvm_cov_export( ] for regex in filter_regexes: - adjusted = regex.replace("/proc/self/cwd/", workspace_root) - cmd.append(f"--ignore-filename-regex={adjusted}") + 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]) - cmd.extend(coverage_args) return run_command(cmd) def run_llvm_cov_report( llvm_bin_path: Path, - coverage_args: List[str], + objects: List[str], + instr_profile: Optional[str], filter_regexes: List[str], + workspace_root: str, ) -> subprocess.CompletedProcess: - """Run llvm-cov report for a summary.""" + """Run llvm-cov report for a text summary.""" cmd = [ str(llvm_bin_path), "report", - "--summary-only", + f"--path-equivalence=/proc/self/cwd/,{workspace_root}", "--show-region-summary=0", "--show-branch-summary=1", ] @@ -199,7 +376,14 @@ def run_llvm_cov_report( for regex in filter_regexes: cmd.append(f"--ignore-filename-regex={regex}") - cmd.extend(coverage_args) + 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) @@ -254,22 +438,52 @@ def read_reports_file(reports_file: Path) -> List[str]: return [line.strip() for line in f if line.strip()] -def load_filter_regexes(runfiles: Runfiles, rlocation_path: str) -> List[str]: - """Load filter regexes from filter_regexes.txt via Bazel runfiles.""" +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(): - print(f"WARNING: {rlocation_path} not found in runfiles, no source filtering applied", - file=sys.stderr) 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 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 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 run_command(cmd: List[str]) -> subprocess.CompletedProcess: @@ -284,7 +498,7 @@ def run_command(cmd: List[str]) -> subprocess.CompletedProcess: ) except subprocess.CalledProcessError as e: print(f"ERROR: Command failed with code {e.returncode}:", file=sys.stderr) - print(f" {' '.join(cmd)}", 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) @@ -308,8 +522,10 @@ def parse_args() -> argparse.Namespace: 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("--filter_regexes", type=str, required=True, - help="Rlocation path to the filter regexes file") + 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() diff --git a/quality/coverage/llvm_cov/reporter_wrapper.bzl b/quality/coverage/llvm_cov/reporter_wrapper.bzl new file mode 100644 index 000000000..3dd26c8e9 --- /dev/null +++ b/quality/coverage/llvm_cov/reporter_wrapper.bzl @@ -0,0 +1,85 @@ +# ******************************************************************************* +# 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.""" + +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, + ), + }, +) From 6259d4f1dc64c7af77c0847ef2f980dfbba023ed Mon Sep 17 00:00:00 2001 From: Jan Schlosser Date: Fri, 10 Jul 2026 15:33:52 +0200 Subject: [PATCH 2/3] feat(coverage): use collected allowlist for QNX gcovr scope filtering Switch the QNX gcovr report path away from regex-based file filtering to using the Bazel-collected coverage allowlist from coverage_scope. Changes: - generate_coverage_html.sh: Build //quality/coverage:coverage_scope in default config, resolve its allowlist.txt and pass it to lcov_to_html.py via the new --allowlist flag. Remove the dependency on quality/coverage/llvm_cov/filter_regexes.txt for QNX. - lcov_to_html.py: Add --allowlist argument; load the allowlist, normalize LCOV SF: paths to workspace-relative form, and filter entries against the allowlist before handing off to gcovr. Fail fast if an explicit allowlist resolves to an empty set. - lcov_to_html_test.py: Add regression tests for allowlist loading, path normalization/filtering, and the empty-allowlist guard. - BUILD: Add py_test for lcov_to_html_test. - README.md: Document that QNX scope is now driven by the collected allowlist, not regex filters. The coverage_scope target is built without --config=qnx because the dependable element indices it depends on are Linux-only; the resulting workspace-relative allowlist is equally valid for QNX report scoping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- quality/coverage/BUILD | 4 +- quality/coverage/README.md | 6 +- quality/coverage/generate_coverage_html.sh | 74 ++++++- quality/coverage/lcov_to_html.py | 226 ++++++++++++++++++++- 4 files changed, 290 insertions(+), 20 deletions(-) diff --git a/quality/coverage/BUILD b/quality/coverage/BUILD index 17dd8d68a..84051c9c7 100644 --- a/quality/coverage/BUILD +++ b/quality/coverage/BUILD @@ -71,7 +71,7 @@ coverage_scope( testonly = True, visibility = ["//quality/coverage:__subpackages__"], deps = [ - "//score/mw/com/dependability:mw_com_index", - "//score/message_passing/dependability:dependable_element_message_passing_index", + "//score/message_passing", + "//score/mw/com", ], ) diff --git a/quality/coverage/README.md b/quality/coverage/README.md index bde2fa369..0ca48ba51 100644 --- a/quality/coverage/README.md +++ b/quality/coverage/README.md @@ -95,7 +95,7 @@ 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 converts the LCOV data to HTML using `lcov_to_html.py` (which internally uses gcovr). +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: @@ -174,12 +174,14 @@ 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: Convert LCOV → HTML via lcov_to_html.py (gcovr) → cpp_coverage_qnx/ + │ 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 diff --git a/quality/coverage/generate_coverage_html.sh b/quality/coverage/generate_coverage_html.sh index bbb0a41f5..9a3f17cd1 100755 --- a/quality/coverage/generate_coverage_html.sh +++ b/quality/coverage/generate_coverage_html.sh @@ -117,12 +117,76 @@ else # 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..." - bazel run //quality/coverage:lcov_to_html -- \ - --lcov "${TMPDIR_EXTRACT}/lcov.dat" \ - --output-dir "${OUTPUT_DIR}" \ - --source-root "${BUILD_WORKSPACE_DIRECTORY}" \ - --filter-regexes "${BUILD_WORKSPACE_DIRECTORY}/quality/coverage/llvm_cov/filter_regexes.txt" + 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}" diff --git a/quality/coverage/lcov_to_html.py b/quality/coverage/lcov_to_html.py index 5bc92e1f9..dd4e7cd9b 100644 --- a/quality/coverage/lcov_to_html.py +++ b/quality/coverage/lcov_to_html.py @@ -34,7 +34,9 @@ import sys import tempfile from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +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]]: @@ -123,6 +125,187 @@ def parse_lcov(lcov_path: Path) -> List[Dict[str, Any]]: 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]], @@ -190,16 +373,33 @@ def main() -> None: # Determine source root for gcovr (--root). source_root = args.source_root if args.source_root else os.getcwd() - - # Strip source root from filenames if they're absolute paths. - root_prefix = source_root.rstrip("/") + "/" - for entry in file_entries: - if entry["file"].startswith(root_prefix): - entry["file"] = entry["file"][len(root_prefix):] - elif entry["file"].startswith("/"): - # Absolute path not under source_root — keep as-is, gcovr will - # resolve relative to --root. - pass + 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} " + f"({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: @@ -263,6 +463,10 @@ def parse_args() -> argparse.Namespace: 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() From 26aaf8bd378e880083a38f8cbf88cdbb43e440db Mon Sep 17 00:00:00 2001 From: Jan Schlosser Date: Mon, 13 Jul 2026 21:34:16 +0200 Subject: [PATCH 3/3] build(toolchain): bump LLVM toolchain to 22.1.7 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MODULE.bazel | 2 +- quality/compiler_warnings/test/BUILD | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 4bff52d87..aafc36306 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -301,7 +301,7 @@ llvm.toolchain( # This is the agreed way to ensure linking for targets using std::atomic operations. "-latomic", ]}, - llvm_version = "19.1.7", + llvm_version = "22.1.7", stdlib = {"": "stdc++"}, ) llvm.sysroot( diff --git a/quality/compiler_warnings/test/BUILD b/quality/compiler_warnings/test/BUILD index 2422e0fd9..65000ca90 100644 --- a/quality/compiler_warnings/test/BUILD +++ b/quality/compiler_warnings/test/BUILD @@ -65,7 +65,7 @@ build_test( src = "has_warnings/{}.cpp".format(case), compile_stderr = matcher.contains_extended_regex( # Convert e.g. -Wsign-compare to a flexible regex for stderr matching. - "Werror.*{}".format( + "error.*{}".format( warning[0][2:].replace("-", ".*"), ), ),