From 6dce2fe199fc00ae6b6b1869ea4a3a819a9b2498 Mon Sep 17 00:00:00 2001 From: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:25:26 +0300 Subject: [PATCH 1/2] scope: a cc_binary executable is the baseline object of its sources A cc_binary links its own translation units straight into the executable; they sit in no archive, so an untested main.cpp had no object carrying its coverage mapping, was absent from the report and listed as no-data in unmapped_files.txt (lifecycle launch_manager, #40). The aspect now adds the coverage-built executable to the baseline objects, as it does for rust_binary. The gcov baseline notes of untested files are built in the exec configuration and take the host options; the consumer config gains --host_cxxopt=-O0 next to --cxxopt=-O0 so their line tables match the test builds (GCC folds a constant return into the entry line when optimising). Analysis test for the executable in the objects manifest; the end-to-end workspace gets an untested cc_binary whose main.cpp is checked at exact 0 % on both backends (hand-derived goldens, cross-checked with gcov on the gcno). Manual, release notes 0.4.2, verification report counts. Closes #40 Signed-off-by: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> --- docs/manual/user_manual.rst | 11 +++++++ docs/release/release_notes.rst | 31 +++++++++++++++++++ docs/verification/verification_report.rst | 8 ++--- score_coverage/coverage_scope.bzl | 12 +++++++ tests/end_to_end/consumer/.bazelrc | 4 +++ tests/end_to_end/consumer/src/BUILD | 8 +++++ tests/end_to_end/consumer/src/main.cpp | 19 ++++++++++++ .../end_to_end/consumer/tools/coverage/BUILD | 3 ++ tests/end_to_end/expected/expected_lcov.dat | 11 +++++++ .../expected/expected_lcov_gcov.dat | 7 +++++ tests/end_to_end/run_end_to_end_test.sh | 1 + tests/end_to_end/testcases/test_summary.py | 3 +- tests/unit/starlark/coverage_scope_tests.bzl | 13 ++++++++ tests/unit/starlark/fixtures/BUILD | 10 ++++++ tests/unit/starlark/fixtures/bin_main.cpp | 18 +++++++++++ 15 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 tests/end_to_end/consumer/src/main.cpp create mode 100644 tests/unit/starlark/fixtures/bin_main.cpp diff --git a/docs/manual/user_manual.rst b/docs/manual/user_manual.rst index eb34918..f527556 100644 --- a/docs/manual/user_manual.rst +++ b/docs/manual/user_manual.rst @@ -226,6 +226,16 @@ configuration where that platform's toolchains are not registered and fail toolchain resolution. The coverage run names the reporter, and through it the scope, explicitly, so the tag does not affect it. +Binaries in the scope +~~~~~~~~~~~~~~~~~~~~~ + +A ``cc_binary`` or ``rust_binary`` root is handled like a library: its +sources and those of its in-workspace dependencies are in scope. Its own +translation units are linked into the executable rather than into an +archive, so the coverage-built executable is their zero-coverage baseline; +an untested ``main.cpp`` therefore appears in the report at 0 % and not in +``unmapped_files.txt`` (0.4.2). + Step 4: import the bazelrc config --------------------------------- @@ -324,6 +334,7 @@ collector and points the final step at that reporter (copy and adapt the coverage:qnx --test_lang_filters=cc coverage:qnx --instrument_test_targets coverage:qnx --instrumentation_filter=^//score[/:] + coverage:qnx --host_cxxopt=-O0 # baseline .gcno of untested files are built with the HOST options coverage:qnx --noexperimental_use_llvm_covmap coverage:qnx --noexperimental_generate_llvm_lcov coverage:qnx --test_env=GENERATE_LLVM_LCOV --test_env=COVERAGE_GCOV_PATH --test_env=LLVM_PROFILE_CONTINUOUS_MODE diff --git a/docs/release/release_notes.rst b/docs/release/release_notes.rst index 1876269..1755728 100644 --- a/docs/release/release_notes.rst +++ b/docs/release/release_notes.rst @@ -23,6 +23,37 @@ Release notes :security: NO :realizes: wp__module_sw_release_note +0.4.2 (unreleased) +------------------ + +In plain words +~~~~~~~~~~~~~~ + +**The sources of a binary show up at 0 % like everything else.** A +``cc_binary`` links its own files, typically ``main.cpp``, straight into the +executable instead of into a library archive. The reporter took its +zero-coverage baseline from archives only, so an untested binary's own files +were listed in ``unmapped_files.txt`` as ``no-data`` and were absent from the +report (lifecycle's ``launch_manager``, coverage_tool#40). The coverage-built +executable now serves as their baseline object, as it already did for +``rust_binary``. + +Details for integrators +~~~~~~~~~~~~~~~~~~~~~~~ + +- Scope aspect: for a ``cc_binary`` in or below the scope roots, the + executable joins the baseline objects; libraries are unchanged. Nothing to + configure. +- Analysis test for the executable in the objects manifest; the end-to-end + workspace gains an untested ``cc_binary`` whose ``main.cpp`` is checked at + exact 0 % on both backends. +- gcov backend: the ``.gcno`` notes of untested files are built in the exec + configuration, which takes the **host** options. Add + ``--host_cxxopt=-O0`` next to ``--cxxopt=-O0`` in the gcov config (user + manual step 4b), or GCC optimises those units and their line tables differ + from the ``-O0`` test builds; a folded ``return 0`` then lacks its line. + The LLVM backend is unaffected (front-end mapping). + 0.4.1 (2026-10-13) ------------------ diff --git a/docs/verification/verification_report.rst b/docs/verification/verification_report.rst index 4ad73a1..a917f63 100644 --- a/docs/verification/verification_report.rst +++ b/docs/verification/verification_report.rst @@ -77,15 +77,15 @@ Test inventory * - ``//tests/unit/score_coverage:coverage_summary_test`` - 19 - summary_first - * - ``//tests/unit/starlark:coverage_scope_tests`` (17 analysis tests) - - 17 + * - ``//tests/unit/starlark:coverage_scope_tests`` (18 analysis tests) + - 18 - scope_transitive, scope_excludes, scope_baseline_objects, scope_gcno, scope_platform * - ``//tests/end_to_end/testcases:blackbox_test`` - 4 - gate_no_verdict, gate_exit_codes, summary_first - * - ``tests/end_to_end/run_end_to_end_test.sh`` (24 end-to-end checks) - - 24 + * - ``tests/end_to_end/run_end_to_end_test.sh`` (25 end-to-end checks) + - 25 - validation_ground_truth, instrumentation_hint, scope_platform, report_baseline_zero, report_relative_paths, report_allowlist, report_unmapped, gcov_merge, gcov_baseline, gcov_html, gate_exit_codes, gate_no_verdict, just_unknown_id, artifacts, diff --git a/score_coverage/coverage_scope.bzl b/score_coverage/coverage_scope.bzl index af8f147..b45769e 100644 --- a/score_coverage/coverage_scope.bzl +++ b/score_coverage/coverage_scope.bzl @@ -24,6 +24,8 @@ transitive deps. At each node it collects the actual source files. (zero-coverage) reporting. - rust_binary (no CcInfo): source files come from CrateInfo.srcs; the coverage-built executable itself serves as the baseline object. +- cc_binary: srcs and hdrs as for cc_library; its own objects are in no + archive, so the coverage-built executable is the baseline object too. The rule writes three text files the reporter consumes: @@ -168,6 +170,16 @@ def _coverage_scope_aspect_impl(target, ctx): if archive and "/external/" not in archive.path and not archive.path.startswith("external/"): direct_archives.append(archive) break + + # A cc_binary links its own translation units (main.cpp) straight + # into the executable: they sit in no archive. The coverage-built + # executable is the only object carrying their mapping, so it is + # the baseline object for them, as for rust_binary + # (eclipse-score/coverage_tool#40). + if ctx.rule.kind == "cc_binary": + exe = target[DefaultInfo].files_to_run.executable + if exe and "/external/" not in exe.path and not exe.path.startswith("external/"): + direct_archives.append(exe) elif CrateInfo in target: # rust_binary: no CcInfo, collect .rs sources from CrateInfo and use the # coverage-built executable as the baseline object. diff --git a/tests/end_to_end/consumer/.bazelrc b/tests/end_to_end/consumer/.bazelrc index 2ce2e35..38cb4d6 100644 --- a/tests/end_to_end/consumer/.bazelrc +++ b/tests/end_to_end/consumer/.bazelrc @@ -93,6 +93,10 @@ coverage:gcov --nocache_test_results # compiled with instrumentation through an instrumented dependency. coverage:gcov --instrumentation_filter=^// coverage:gcov --cxxopt=-O0 +# The scope's baseline objects (the .gcno notes of untested files) are built +# in the exec configuration, which takes the host options: without this, GCC +# optimises them and their line tables no longer match the -O0 test builds. +coverage:gcov --host_cxxopt=-O0 coverage:gcov --combined_report=lcov coverage:gcov --dynamic_mode=off coverage:gcov --host_platform=@score_bazel_platforms//:x86_64-linux-gcc_12.2.0-posix diff --git a/tests/end_to_end/consumer/src/BUILD b/tests/end_to_end/consumer/src/BUILD index e4728e3..73a33c5 100644 --- a/tests/end_to_end/consumer/src/BUILD +++ b/tests/end_to_end/consumer/src/BUILD @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_cc//cc:cc_test.bzl", "cc_test") @@ -95,6 +96,13 @@ cc_library( srcs = ["target_root.cpp"], ) +# Shipped binary without a test: its main.cpp is linked straight into the +# executable, not into any archive, and must still show at 0 % (coverage_tool#40). +cc_binary( + name = "tool", + srcs = ["main.cpp"], +) + cc_test( name = "coverable_test", srcs = ["coverable_test.cpp"], diff --git a/tests/end_to_end/consumer/src/main.cpp b/tests/end_to_end/consumer/src/main.cpp new file mode 100644 index 0000000..c14321d --- /dev/null +++ b/tests/end_to_end/consumer/src/main.cpp @@ -0,0 +1,19 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +// Entry point of a shipped binary. Nothing tests it; the report must still +// list it at 0 % (coverage_tool#40). Linked straight into the executable, so +// its mapping lives in no archive: the executable is its baseline object. +int main() +{ + return 0; +} diff --git a/tests/end_to_end/consumer/tools/coverage/BUILD b/tests/end_to_end/consumer/tools/coverage/BUILD index 6092d62..2e5a7cf 100644 --- a/tests/end_to_end/consumer/tools/coverage/BUILD +++ b/tests/end_to_end/consumer/tools/coverage/BUILD @@ -34,6 +34,9 @@ SCOPE_DEPS = [ # Platform-dependent library (coverage_tool#23): the scope must list # the variant of the platform the coverage run targets. "//src:platform_dep", + # Untested cc_binary: own sources have no archive, the executable is the + # baseline object (coverage_tool#40). + "//src:tool", ] + select({ # One root per platform, the host root incompatible with the gcov # platform: this select() is resolved for the scope's platform. diff --git a/tests/end_to_end/expected/expected_lcov.dat b/tests/end_to_end/expected/expected_lcov.dat index 5900834..bc2e2cd 100644 --- a/tests/end_to_end/expected/expected_lcov.dat +++ b/tests/end_to_end/expected/expected_lcov.dat @@ -197,3 +197,14 @@ BRH:0 LF:6 LH:3 end_of_record +# src/main.cpp: own source of the untested cc_binary //src:tool (coverage_tool#40). +# Function body { ... } on lines 17-19 after the 12-line header, 1 blank, 3 comment lines. +SF:src/main.cpp +DA:17,0 +DA:18,0 +DA:19,0 +BRF:0 +BRH:0 +LF:3 +LH:0 +end_of_record diff --git a/tests/end_to_end/expected/expected_lcov_gcov.dat b/tests/end_to_end/expected/expected_lcov_gcov.dat index 3d4f050..63712cd 100644 --- a/tests/end_to_end/expected/expected_lcov_gcov.dat +++ b/tests/end_to_end/expected/expected_lcov_gcov.dat @@ -134,3 +134,10 @@ DA:19,1 LF:2 LH:2 end_of_record +# src/main.cpp: gcov counts the signature line (function entry) and the return. +SF:src/main.cpp +DA:16,0 +DA:18,0 +LF:2 +LH:0 +end_of_record diff --git a/tests/end_to_end/run_end_to_end_test.sh b/tests/end_to_end/run_end_to_end_test.sh index d1a7e58..79c593a 100755 --- a/tests/end_to_end/run_end_to_end_test.sh +++ b/tests/end_to_end/run_end_to_end_test.sh @@ -102,6 +102,7 @@ check_zero_coverage() { } check_zero_coverage "src/uncovered.cpp" +check_zero_coverage "src/main.cpp" # own source of a cc_binary (coverage_tool#40) check_zero_coverage "rust/main.rs" echo "=== LCOV must match the hand-verified ground truth exactly ===" diff --git a/tests/end_to_end/testcases/test_summary.py b/tests/end_to_end/testcases/test_summary.py index 06d75d5..b873176 100644 --- a/tests/end_to_end/testcases/test_summary.py +++ b/tests/end_to_end/testcases/test_summary.py @@ -45,7 +45,8 @@ def test_markdown_summary_contains_coverage_and_source_categories( "| Lines |", "Raw vs effective", "Coverage by directory", - "Files at exact 0% (3)", + "Files at exact 0% (4)", # uncovered.cpp, host_root.cpp, main.cpp, rust/main.rs + "- `src/main.cpp`", "| In-scope files without coverage data | 2 |", "In-scope files without coverage data (2)", "- `src/unused_api.h`", diff --git a/tests/unit/starlark/coverage_scope_tests.bzl b/tests/unit/starlark/coverage_scope_tests.bzl index ec6522e..cd50821 100644 --- a/tests/unit/starlark/coverage_scope_tests.bzl +++ b/tests/unit/starlark/coverage_scope_tests.bzl @@ -217,6 +217,18 @@ def _test_rust_binary_collects_crate_sources_and_executable_impl(env, target): # --- providers / output groups -------------------------------------------- +def _test_cc_binary_collects_main_and_executable(name): + coverage_scope(name = name + "_subject", testonly = True, deps = [_FIX + ":cbin"]) + analysis_test(name = name, impl = _test_cc_binary_collects_main_and_executable_impl, target = name + "_subject") + +def _test_cc_binary_collects_main_and_executable_impl(env, target): + allowlist = _allowlist(env, target) + allowlist.contains(_PKG + "/fixtures/bin_main.cpp") + allowlist.contains(_PKG + "/fixtures/leaf.cpp") + objects = _objects(env, target) + objects.contains(_PKG + "/fixtures/cbin") # the coverage-built executable: baseline for bin_main.cpp + objects.contains("libleaf.a") + def _test_output_groups(name): coverage_scope(name = name + "_subject", testonly = True, deps = [_FIX + ":mid"]) analysis_test(name = name, impl = _test_output_groups_impl, target = name + "_subject") @@ -326,6 +338,7 @@ def coverage_scope_test_suite(name): _test_forwarded_external_library_stays_out, _test_rust_library_sources_and_archive, _test_rust_binary_collects_crate_sources_and_executable, + _test_cc_binary_collects_main_and_executable, _test_output_groups, _test_gcno_manifest_and_output_groups, _test_platform_selects_the_target_variant, diff --git a/tests/unit/starlark/fixtures/BUILD b/tests/unit/starlark/fixtures/BUILD index dc2708b..1991adc 100644 --- a/tests/unit/starlark/fixtures/BUILD +++ b/tests/unit/starlark/fixtures/BUILD @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") load(":forward.bzl", "forward_cc") @@ -124,6 +125,15 @@ rust_library( tags = MANUAL, ) +# A binary's own translation unit is linked straight into the executable +# (no archive): the executable is its baseline object (coverage_tool#40). +cc_binary( + name = "cbin", + srcs = ["bin_main.cpp"], + tags = MANUAL, + deps = [":leaf"], +) + rust_binary( name = "rbin", srcs = ["main.rs"], diff --git a/tests/unit/starlark/fixtures/bin_main.cpp b/tests/unit/starlark/fixtures/bin_main.cpp new file mode 100644 index 0000000..bcad618 --- /dev/null +++ b/tests/unit/starlark/fixtures/bin_main.cpp @@ -0,0 +1,18 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +int leaf(); // leaf.h is a comment-only fixture header + +int main() +{ + return leaf(); +} From 7d215403267a9ea853f81e467303e41ea2afe13d Mon Sep 17 00:00:00 2001 From: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:02:09 +0300 Subject: [PATCH 2/2] reporter: render baseline-only files in their own HTML run The HTML came from one llvm-cov show over the test binaries plus the baseline objects with the merged test profile. An executable among the baseline objects carries a main like every test binary: with the same structural hash llvm-cov attributed a test's counts to it (lifecycle's untested main.cpp at 100 %), with a different hash it dropped the file (no row in the end-to-end index). The LCOV was right, it already took baseline-only files from a separate --empty-profile run. The HTML now does the same: baseline-only files are rendered with --empty-profile over the baseline objects, their pages and index rows are spliced into the report, and the Totals row the effective-coverage gate reads is recomputed over all rows. Unit tests for the splice; end-to-end checks of the index rows of the untested C++ sources, the binary's main.cpp and the Rust binary's main.rs at 0 %. Signed-off-by: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> --- docs/release/release_notes.rst | 7 ++ docs/verification/verification_report.rst | 6 +- score_coverage/reporter.py | 117 ++++++++++++++++++--- tests/end_to_end/run_end_to_end_test.sh | 19 ++++ tests/unit/score_coverage/reporter_test.py | 88 ++++++++++++++++ 5 files changed, 222 insertions(+), 15 deletions(-) diff --git a/docs/release/release_notes.rst b/docs/release/release_notes.rst index 1755728..3bc2627 100644 --- a/docs/release/release_notes.rst +++ b/docs/release/release_notes.rst @@ -47,6 +47,13 @@ Details for integrators - Analysis test for the executable in the objects manifest; the end-to-end workspace gains an untested ``cc_binary`` whose ``main.cpp`` is checked at exact 0 % on both backends. +- HTML: baseline-only files are rendered in their own ``--empty-profile`` + run and spliced into the index, the way the LCOV path already worked. In + the single combined run an executable's ``main`` collided with the test + binaries' ``main``: with the same structural hash llvm-cov attributed a + test's counts to it (100 % for an untested file), with a different hash it + dropped the file. The index totals, which the effective-coverage gate + reads, are recomputed over all rows; end-to-end checks of the index rows. - gcov backend: the ``.gcno`` notes of untested files are built in the exec configuration, which takes the **host** options. Add ``--host_cxxopt=-O0`` next to ``--cxxopt=-O0`` in the gcov config (user diff --git a/docs/verification/verification_report.rst b/docs/verification/verification_report.rst index a917f63..b4a868e 100644 --- a/docs/verification/verification_report.rst +++ b/docs/verification/verification_report.rst @@ -53,7 +53,7 @@ Test inventory - 20 - merge_profraw, merge_no_data, merge_tool_error * - ``//tests/unit/score_coverage:reporter_test`` - - 71 + - 74 - report_merged_profile, report_allowlist, report_baseline_zero, report_rlib_expansion, report_missing_baseline, report_relative_paths, report_outputs, report_unmapped, scope_transitive, @@ -84,8 +84,8 @@ Test inventory * - ``//tests/end_to_end/testcases:blackbox_test`` - 4 - gate_no_verdict, gate_exit_codes, summary_first - * - ``tests/end_to_end/run_end_to_end_test.sh`` (25 end-to-end checks) - - 25 + * - ``tests/end_to_end/run_end_to_end_test.sh`` (28 end-to-end checks) + - 28 - validation_ground_truth, instrumentation_hint, scope_platform, report_baseline_zero, report_relative_paths, report_allowlist, report_unmapped, gcov_merge, gcov_baseline, gcov_html, gate_exit_codes, gate_no_verdict, just_unknown_id, artifacts, diff --git a/score_coverage/reporter.py b/score_coverage/reporter.py index cde0860..2f0d7b1 100644 --- a/score_coverage/reporter.py +++ b/score_coverage/reporter.py @@ -125,25 +125,41 @@ def show_html(objects: list[str]) -> None: cxxfilt=cxxfilt, ) - # Generate HTML report including baseline-only files when valid archives are available. + # HTML from the test binaries and the merged profile. Baseline-only files + # are rendered in a run of their own below: an executable among the + # baseline objects carries a ``main`` like every test binary, and against + # the merged profile llvm-cov either attributes a test's counts to it (same + # structural hash) or drops it as mismatched (coverage_tool#40). html_report_dir = Path.cwd() / "html_report" + show_html(sorted_objects) + + # File the pages under canonical, machine-independent paths so the index + # links resolve wherever the archive is unpacked, and name the sources + # the same way in the page titles. + relocate_html_pages(html_report_dir, root, path_map) + if baseline_only_archives: + baseline_html_dir = Path.cwd() / "html_baseline" try: - show_html(sorted_objects + baseline_only_archives) + run_llvm_cov_show( + llvm_bin_path, + baseline_only_archives, + None, + regexes, + root, + output_format="html", + html_report_dir=baseline_html_dir, + cxxfilt=cxxfilt, + ) + relocate_html_pages(baseline_html_dir, root, path_map) + spliced = merge_baseline_html(html_report_dir, baseline_html_dir, selection.baseline_only) + print(f"INFO: Merged baseline HTML for {spliced} files.", file=sys.stderr) 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.", + "WARNING: HTML generation for the baseline-only files failed; their pages are missing.", file=sys.stderr, ) - show_html(sorted_objects) - else: - show_html(sorted_objects) - - # File the pages under canonical, machine-independent paths so the index - # links resolve wherever the archive is unpacked, and name the sources - # the same way in the page titles. - relocate_html_pages(html_report_dir, root, path_map) + shutil.rmtree(baseline_html_dir, ignore_errors=True) _make_html_paths_relative(html_report_dir, root, path_map) # Generate LCOV report from test binaries. @@ -555,6 +571,83 @@ def relocate_html_pages(html_dir: Path, source_root: str, path_map: dict[str, st return moves +_INDEX_FILE_ROW_RE = re.compile(r".*?", re.S) +_INDEX_TOTALS_RE = re.compile(r"
Totals
.*?", re.S) +_INDEX_CELL_RE = re.compile(r"
\s*(?:[\d.]+%|-) \((\d+)/(\d+)\)
") +_INDEX_LINK_RE = re.compile(r"") +_INDEX_LABEL_RE = re.compile(r"
(?:]*>)?([^<]*)(?:)?
") + + +def _index_rows(text: str) -> dict[str, str]: + """{canonical file name: row html} for the file rows of an llvm-cov index.""" + rows: dict[str, str] = {} + for row in _INDEX_FILE_ROW_RE.findall(text): + link = _INDEX_LINK_RE.search(row) + label = _INDEX_LABEL_RE.search(row) + name = link.group(1) if link else (label.group(1).strip() if label else None) + if name: + rows[name] = row + return rows + + +def _index_cell(covered: int, total: int, high: int = 100, low: int = 50) -> str: + """One index cell the way llvm-cov renders it (``--coverage-watermark=,``).""" + if total == 0: + return "
- (0/0)
" + pct = 100.0 * covered / total + colour = "green" if pct >= high else ("yellow" if pct >= low else "red") + return f"
{pct:7.2f}% ({covered}/{total})
" + + +def _totals_row(rows: list[str]) -> str: + sums = [[0, 0], [0, 0], [0, 0]] + for row in rows: + for column, (covered, total) in enumerate(_INDEX_CELL_RE.findall(row)[:3]): + sums[column][0] += int(covered) + sums[column][1] += int(total) + cells = "".join(_index_cell(covered, total) for covered, total in sums) + return f"
Totals
{cells}" + + +def merge_baseline_html(html_dir: Path, baseline_dir: Path, baseline_only: set[str]) -> int: + """Add the pages and index rows of the baseline-only files to the test-binary HTML. + + Both directories must already be relocated (pages under + ``coverage/.html``). Only names in ``baseline_only`` that the + main index lacks are taken, so a file the tests cover is never + duplicated. The Totals row, which the effective-coverage gate reads, is + recomputed over all rows. Returns the number of files spliced in. + """ + index = html_dir / "index.html" + baseline_index = baseline_dir / "index.html" + if not index.is_file() or not baseline_index.is_file(): + return 0 + text = index.read_text(encoding="utf-8", errors="replace") + main_rows = _index_rows(text) + baseline_rows = _index_rows(baseline_index.read_text(encoding="utf-8", errors="replace")) + added: list[str] = [] + for name in sorted(baseline_only): + row = baseline_rows.get(name) + if row is None or name in main_rows: + continue + page = baseline_dir / "coverage" / (name + ".html") + if page.is_file(): + target = html_dir / "coverage" / (name + ".html") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(page), str(target)) + added.append(row) + if not added: + return 0 + totals = _INDEX_TOTALS_RE.search(text) + new_totals = _totals_row(list(main_rows.values()) + added) + if totals: + text = text[: totals.start()] + "".join(added) + new_totals + text[totals.end() :] + else: + text = text.replace("", "".join(added) + new_totals + "", 1) + index.write_text(text, encoding="utf-8") + return len(added) + + def _canonicalize_summary(text: str, source_root: str, path_map: dict[str, str] | None = None) -> str: """Name the files of an llvm-cov text report the way LCOV and HTML do.""" prefixes = (source_root.rstrip("/") + "/", "/proc/self/cwd/") diff --git a/tests/end_to_end/run_end_to_end_test.sh b/tests/end_to_end/run_end_to_end_test.sh index 79c593a..e6a90d6 100755 --- a/tests/end_to_end/run_end_to_end_test.sh +++ b/tests/end_to_end/run_end_to_end_test.sh @@ -105,6 +105,25 @@ check_zero_coverage "src/uncovered.cpp" check_zero_coverage "src/main.cpp" # own source of a cc_binary (coverage_tool#40) check_zero_coverage "rust/main.rs" +echo "=== Untested files must appear at 0% in the HTML index too (coverage_tool#40) ===" +unzip -p coverage_artifacts.zip artifacts/coverage_linux/index.html > index.html +check_zero_index_row() { + local file="$1" lines="$2" row + row=$(tr -d '\n' < index.html | grep -o "
${file}
.*" | sed 's#.*##' || true) + if [[ -z "${row}" ]]; then + echo "ERROR: ${file} has no row in the HTML index" >&2 + exit 1 + fi + if ! grep -q "0.00% (0/${lines})" <<<"${row}"; then + echo "ERROR: ${file} index row is not at 0% of ${lines} lines: ${row}" >&2 + exit 1 + fi + echo "OK: ${file} in the HTML index at 0% (0/${lines})" +} +check_zero_index_row "src/uncovered.cpp" 6 +check_zero_index_row "src/main.cpp" 3 # own source of a cc_binary: baseline-only, rendered in its own run +check_zero_index_row "rust/main.rs" 3 + echo "=== LCOV must match the hand-verified ground truth exactly ===" # Normalise: drop function records, keep one record per file sorted by SF, so # the comparison is independent of record order and of symbol names. diff --git a/tests/unit/score_coverage/reporter_test.py b/tests/unit/score_coverage/reporter_test.py index 346b075..dea2f29 100644 --- a/tests/unit/score_coverage/reporter_test.py +++ b/tests/unit/score_coverage/reporter_test.py @@ -557,6 +557,94 @@ def test_failure_exits(self): reporter.run_command([sys.executable, "-c", "import sys; sys.exit(4)"]) +def _index(rows: list[tuple[str, str, str, str]], totals: tuple[str, str, str]) -> str: + def cell(value: str) -> str: + colour = "gray" if value.startswith("-") else ("red" if value.startswith(" 0") else "yellow") + return f"
{value}
" + + body = "".join( + f"
{name}
" + + cell(fn) + + cell(ln) + + cell(br) + + "" + for name, fn, ln, br in rows + ) + tot = "
Totals
" + "".join(cell(v) for v in totals) + "" + head = "" + return head + body + tot + "
Filename
" + + +@verifies("tool_req__coverage_report_baseline_zero") +class MergeBaselineHtmlTest(unittest.TestCase): + """Baseline-only pages come from their own --empty-profile run and are spliced into the index.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.html = Path(self.tmp.name) / "html" + self.base = Path(self.tmp.name) / "baseline" + for d in (self.html / "coverage" / "src", self.base / "coverage" / "src"): + d.mkdir(parents=True) + (self.html / "index.html").write_text( + _index( + [("src/a.cpp", " 50.00% (1/2)", " 80.00% (8/10)", "- (0/0)")], + (" 50.00% (1/2)", " 80.00% (8/10)", "- (0/0)"), + ), + encoding="utf-8", + ) + (self.html / "coverage" / "src" / "a.cpp.html").write_text("page a", encoding="utf-8") + (self.base / "index.html").write_text( + _index( + [ + ("src/a.cpp", " 0.00% (0/2)", " 0.00% (0/10)", "- (0/0)"), # also covered by tests: not taken + ("src/main.cpp", " 0.00% (0/1)", " 0.00% (0/3)", "- (0/0)"), + ("src/other.cpp", " 0.00% (0/1)", " 0.00% (0/4)", " 0.00% (0/2)"), # not baseline-only + ], + (" 0.00% (0/4)", " 0.00% (0/17)", " 0.00% (0/2)"), + ), + encoding="utf-8", + ) + for name in ("a", "main", "other"): + (self.base / "coverage" / "src" / f"{name}.cpp.html").write_text(f"baseline page {name}", encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def test_baseline_only_rows_and_pages_are_added_and_totals_recomputed(self): + added = reporter.merge_baseline_html(self.html, self.base, {"src/main.cpp", "src/a.cpp"}) + self.assertEqual(added, 1) + index = (self.html / "index.html").read_text(encoding="utf-8") + rows = reporter._index_rows(index) + self.assertEqual(list(rows), ["src/a.cpp", "src/main.cpp"]) + self.assertIn("src/main.cpp", index) + self.assertIn(" 0.00% (0/3)", rows["src/main.cpp"]) + # Totals over both rows: functions 1/3, lines 8/13, branches 0/0. + match = reporter._INDEX_TOTALS_RE.search(index) + self.assertIsNotNone(match) + assert match is not None # for the type checker + totals = match.group(0) + self.assertIn(" 33.33% (1/3)", totals) + self.assertIn(" 61.54% (8/13)", totals) + self.assertIn("- (0/0)", totals) + # The test-covered page is untouched, the baseline page moved in. + self.assertEqual((self.html / "coverage" / "src" / "a.cpp.html").read_text(encoding="utf-8"), "page a") + self.assertEqual( + (self.html / "coverage" / "src" / "main.cpp.html").read_text(encoding="utf-8"), "baseline page main" + ) + self.assertFalse((self.html / "coverage" / "src" / "other.cpp.html").exists()) + + def test_nothing_to_add_leaves_the_index_alone(self): + before = (self.html / "index.html").read_text(encoding="utf-8") + self.assertEqual(reporter.merge_baseline_html(self.html, self.base, {"src/a.cpp"}), 0) + self.assertEqual((self.html / "index.html").read_text(encoding="utf-8"), before) + + def test_index_cell_follows_the_watermark(self): + self.assertIn("column-entry-green", reporter._index_cell(3, 3)) + self.assertIn("column-entry-yellow", reporter._index_cell(1, 2)) + self.assertIn("column-entry-red", reporter._index_cell(0, 3)) + self.assertEqual(reporter._index_cell(0, 0), "
- (0/0)
") + + @verifies( "tool_req__coverage_report_allowlist", "tool_req__coverage_report_baseline_zero",