diff --git a/score_coverage/BUILD b/score_coverage/BUILD index 900226e..b32949d 100644 --- a/score_coverage/BUILD +++ b/score_coverage/BUILD @@ -37,13 +37,19 @@ py_binary( py_binary( name = "reporter", srcs = ["reporter.py"], - deps = ["@rules_python//python/runfiles"], + deps = [ + ":coverage_selection_lib", + ":coverage_sources_lib", + "@rules_python//python/runfiles", + ], ) py_binary( name = "gcov_reporter", srcs = ["gcov_reporter.py"], deps = [ + ":coverage_selection_lib", + ":coverage_sources_lib", ":reporter_lib", "@rules_python//python/runfiles", requirement("gcovr"), @@ -92,7 +98,29 @@ py_library( name = "reporter_lib", srcs = ["reporter.py"], imports = [".."], - deps = ["@rules_python//python/runfiles"], + deps = [ + ":coverage_selection_lib", + ":coverage_sources_lib", + "@rules_python//python/runfiles", + ], +) + +# Both report backends need the same decisions about which source paths belong +# in a report. This library normalizes compiler-recorded paths, applies the +# consumer's allowlist, and describes files without coverage data. +py_library( + name = "coverage_selection_lib", + srcs = ["coverage_selection.py"], + imports = [".."], +) + +# Both backends also need to find source files in Bazel runfiles and make them +# available at the paths recorded by coverage tools. Keeping this as a separate +# library lets the backends share that work without importing each other. +py_library( + name = "coverage_sources_lib", + srcs = ["coverage_sources.py"], + imports = [".."], ) py_library( @@ -100,6 +128,8 @@ py_library( srcs = ["gcov_reporter.py"], imports = [".."], deps = [ + ":coverage_selection_lib", + ":coverage_sources_lib", ":reporter_lib", "@rules_python//python/runfiles", requirement("gcovr"), diff --git a/score_coverage/coverage_selection.py b/score_coverage/coverage_selection.py new file mode 100644 index 0000000..f52b5de --- /dev/null +++ b/score_coverage/coverage_selection.py @@ -0,0 +1,264 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Select and name the source files included in a coverage report. + +Both coverage backends use these helpers to map compiler-recorded paths to +source paths, apply the consumer's scope, and describe files with no data. +""" + +import os +import re +import sys +from dataclasses import dataclass, field + +# Configuration-specific root of a generated file's exec path, e.g. +# "bazel-out/k8-fastbuild/bin/" or "bazel-out/k8-opt-exec-ST-/bin/". Headers +# behind strip_include_prefix are compiled from such a _virtual_includes/ tree +# and the coverage mapping records that path; the scope allowlist carries the +# configuration-agnostic short_path, so both sides are normalized to it. +_BAZEL_OUT_CONFIG_RE = re.compile(r"^bazel-out/[^/]+/bin/") + + +def strip_config_prefix(path: str) -> str: + """Drop a leading ``bazel-out//bin/`` from a workspace-relative path.""" + return _BAZEL_OUT_CONFIG_RE.sub("", path, count=1) + + +def redundant_baseline_variants(test_covered: dict[str, str], baseline_covered: dict[str, str]) -> set[str]: + """Raw baseline paths whose normalized file is already covered by a test binary. + + Both dicts map raw covmap paths to normalized names. A plain source file + has the same raw path in both, so nothing is returned for it; a generated + header differs only in the configuration prefix, and its baseline variant + would otherwise appear as a second, spurious 0 % entry. + """ + covered_names = set(test_covered.values()) + return {raw for raw, name in baseline_covered.items() if name in covered_names and raw not in test_covered} + + +def canonical_path(path: str, path_map: dict[str, str] | None = None) -> str: + """The name a file is reported under. + + Drops the configuration prefix of a generated header and maps a + ``_virtual_includes/`` path to the header it was generated from (the + scope's path map). Plain source paths are returned unchanged. + """ + stripped = strip_config_prefix(path) + if path_map: + return path_map.get(stripped, stripped) + return stripped + + +_FOREIGN_VIRTUAL_RE = re.compile(r"^(.*/)?_virtual_includes/[^/]+/(?P.+)$") + + +def resolve_foreign_virtual_includes( + names: set[str], allowlist: set[str] +) -> tuple[dict[str, str], dict[str, list[str]]]: + """Map ``_virtual_includes/`` paths of targets outside the scope to the allowlisted file. + + The scope's path map only covers virtual-include trees of targets the + aspect visited. A test-only twin of a library (same ``hdrs`` behind + ``strip_include_prefix``, other copts; baselibs' ``futurecpp_internal``) + generates its own tree from the very same files, and test binaries record + that tree. Such a name is resolved by its tail: the allowlisted file that + ends with ```` at a path-component boundary. If no file matches the + full tail (an ``include_prefix`` added components), shorter tails are + tried. Returns ``{virtual name: canonical}`` and ``{virtual name: candidates}`` + for tails that match several allowlisted files (left unresolved). + """ + resolved: dict[str, str] = {} + ambiguous: dict[str, list[str]] = {} + for name in sorted(names): + match = _FOREIGN_VIRTUAL_RE.match(name) + if not match or name in allowlist: + continue + parts = match.group("tail").split("/") + for start in range(len(parts)): + suffix = "/".join(parts[start:]) + candidates = sorted(a for a in allowlist if a == suffix or a.endswith("/" + suffix)) + if len(candidates) == 1: + resolved[name] = candidates[0] + break + if len(candidates) > 1: + ambiguous[name] = candidates + break + return resolved, ambiguous + + +def duplicate_test_variants(test_covered: dict[str, str]) -> dict[str, list[str]]: + """Raw test-binary paths to drop because another raw path of the same file is kept. + + A file compiled under two names (its declared path and a virtual-includes + path) would otherwise produce two entries for one canonical name. The + variant equal to the canonical name is kept, else the first in sort order. + Returns {canonical name: dropped raw paths}. + """ + by_name: dict[str, list[str]] = {} + for raw, name in sorted(test_covered.items()): + by_name.setdefault(name, []).append(raw) + dropped: dict[str, list[str]] = {} + for name, raws in by_name.items(): + if len(raws) > 1: + keep = name if name in raws else raws[0] + dropped[name] = [raw for raw in raws if raw != keep] + return dropped + + +@dataclass +class FileSelection: + """Which compiled files stay in the report and under which name.""" + + staged: dict[str, str] = field(default_factory=dict) + """raw covmap path -> canonical name of every file that stays in the report.""" + excluded: set[str] = field(default_factory=set) + """raw covmap paths suppressed through --ignore-filename-regex.""" + baseline_only: set[str] = field(default_factory=set) + """canonical names that only the baseline archives contain (0 % entries).""" + duplicates: dict[str, list[str]] = field(default_factory=dict) + """canonical name -> raw variants dropped in favour of another variant.""" + unmapped: set[str] = field(default_factory=set) + """allowlisted files without coverage data anywhere, and no benign explanation: the findings.""" + declaration_only: set[str] = field(default_factory=set) + """unmapped headers whose same-named source file has coverage data (declarations only).""" + empty_units: set[str] = field(default_factory=set) + """unmapped sources that were compiled into a baseline archive: no code of their own.""" + + +_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx", ".inl", ".ipp", ".tpp") + + +def _is_header(path: str) -> bool: + return path.endswith(_HEADER_SUFFIXES) + + +def source_stem(path: str) -> str: + """Path without its last extension: ``src/foo.h`` and ``src/foo.cpp`` share ``src/foo``.""" + return os.path.splitext(path)[0] + + +def select_files( + test_covered: dict[str, str], + baseline_covered: dict[str, str], + allowlist: set[str] | None, + compiled_stems: set[str] | None = None, +) -> FileSelection: + """Apply the scope allowlist to the raw files of test binaries and baseline archives. + + ``allowlist`` is a set of canonical names; ``None`` keeps every file. + ``compiled_stems`` are ``/`` stems of the baseline archives' + object members (see :func:`expand_baseline_archives`); an allowlisted + source with such a stem but no coverage data was compiled and holds no + code of its own. + """ + everything = {**baseline_covered, **test_covered} + + def in_scope(name: str) -> bool: + return allowlist is None or name in allowlist + + excluded = {raw for raw, name in everything.items() if not in_scope(name)} + excluded |= redundant_baseline_variants(test_covered, baseline_covered) + duplicates = duplicate_test_variants(test_covered) + for raws in duplicates.values(): + excluded.update(raws) + staged = {raw: name for raw, name in everything.items() if raw not in excluded} + baseline_only = {name for name in set(baseline_covered.values()) - set(test_covered.values()) if in_scope(name)} + # In scope, but compiled into nothing: a header no translation unit + # includes, or template code that is never instantiated. llvm-cov cannot + # report such a file, not even at 0 %, so the reporter must. + unmapped: set[str] = set() + declaration_only: set[str] = set() + empty_units: set[str] = set() + if allowlist is not None: + with_data = set(test_covered.values()) | set(baseline_covered.values()) + unmapped = allowlist - with_data + # A header whose same-named source file has data (foo.h next to a + # compiled foo.cpp) holds declarations only; that is expected and is + # kept apart from headers nothing compiles. + stems_with_data = {source_stem(name) for name in with_data} + declaration_only = {name for name in unmapped if _is_header(name) and source_stem(name) in stems_with_data} + unmapped -= declaration_only + # A source whose object sits in a baseline archive but that has no + # coverage data of its own is a placeholder translation unit of a + # header-only library: compiled, nothing to cover in that file. + if compiled_stems: + empty_units = {name for name in unmapped if not _is_header(name) and source_stem(name) in compiled_stems} + unmapped -= empty_units + return FileSelection( + staged=staged, + excluded=excluded, + baseline_only=baseline_only, + duplicates=duplicates, + unmapped=unmapped, + declaration_only=declaration_only, + empty_units=empty_units, + ) + + +_TEST_SUBDIRECTORIES = ("test", "tests") + + +def instrumentation_filter_suspects(no_data: set[str], with_data: set[str]) -> list[str]: + """In-scope files without test data whose directory is tested from a ``test``/``tests`` subdirectory. + + Bazel guesses ``--instrumentation_filter`` from the packages of the test + targets and strips only a trailing ``/tests``. A library tested from a + ``test`` subpackage is then outside the filter: compiled without counters + (both backends) or, on the gcov backend, its counters are dropped by + Bazel's collector. Such files show 0 % or no data although a test ran + them. The heuristic: no file of the directory has test data, while a + ``test/`` or ``tests/`` subdirectory of it has. + """ + dirs_with_data = {name.rsplit("/", 1)[0] if "/" in name else "" for name in with_data} + suspects = [] + for name in no_data: + directory = name.rsplit("/", 1)[0] if "/" in name else "" + if directory in dirs_with_data: + continue + tested_from_subdir = any( + d == f"{directory}/{sub}" or d.startswith(f"{directory}/{sub}/") + for d in dirs_with_data + for sub in _TEST_SUBDIRECTORIES + ) + if tested_from_subdir: + suspects.append(name) + return sorted(suspects) + + +def warn_instrumentation_filter(selection: FileSelection, with_data: set[str]) -> list[str]: + """Print the ``--instrumentation_filter`` hint for the suspects; returns them.""" + suspects = instrumentation_filter_suspects(selection.baseline_only | selection.unmapped, with_data) + if suspects: + print( + f"WARNING: {len(suspects)} in-scope files have no test data although their directory is tested " + f"from a test/ or tests/ subdirectory (e.g., {suspects[:5]}). Bazel's default " + "--instrumentation_filter covers only the packages of the test targets; set " + "--instrumentation_filter=^//[/:] in the coverage config (user manual, step 4).", + file=sys.stderr, + ) + return suspects + + +UNMAPPED_NO_DATA = "no-data" +UNMAPPED_DECLARATION_ONLY = "declaration-only" +UNMAPPED_EMPTY_UNIT = "compiled-without-code" + + +def format_unmapped_files(selection: FileSelection) -> str: + """``\t`` lines for every in-scope file without coverage data.""" + rows = ( + [(UNMAPPED_NO_DATA, name) for name in selection.unmapped] + + [(UNMAPPED_DECLARATION_ONLY, name) for name in selection.declaration_only] + + [(UNMAPPED_EMPTY_UNIT, name) for name in selection.empty_units] + ) + return "".join(f"{category}\t{name}\n" for category, name in sorted(rows)) diff --git a/score_coverage/coverage_sources.py b/score_coverage/coverage_sources.py new file mode 100644 index 0000000..4ccde66 --- /dev/null +++ b/score_coverage/coverage_sources.py @@ -0,0 +1,64 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Resolve source files from Bazel runfiles and stage them for report renderers.""" + +import os +from pathlib import Path +from typing import Protocol + + +class RunfilesLike(Protocol): + """The part of ``python.runfiles.Runfiles`` this module uses; tests provide fakes.""" + + def Rlocation(self, path: str) -> str | None: # noqa: N802 # pylint: disable=invalid-name + """Resolve a runfiles path to an absolute path, or None.""" + + +def resolve_source(runfiles: RunfilesLike, canonical: str, workspace_root: str) -> str | None: + """Absolute path of an in-scope source: from Bazel runfiles, else the workspace.""" + if canonical.startswith("external/"): + candidates = [runfiles.Rlocation(canonical[len("external/") :])] + else: + candidates = [runfiles.Rlocation(os.path.join("_main", canonical)), os.path.join(workspace_root, canonical)] + for candidate in candidates: + if candidate and os.path.isfile(candidate): + return candidate + return None + + +def stage_sources( + source_root: Path, + staged: dict[str, str], + runfiles: RunfilesLike, + workspace_root: str, +) -> list[str]: + """Recreate recorded source paths under ``source_root`` as links. + + ``staged`` maps each path recorded in coverage data to its canonical source + path. Renderers use the recorded path to read source text, so the links + reproduce that layout beneath ``source_root``. Return canonical paths that + could not be found in runfiles or the workspace. + """ + missing = [] + for raw, canonical in sorted(staged.items()): + if raw.startswith("/"): + continue # an absolute covmap path is read as it is + real = resolve_source(runfiles, canonical, workspace_root) + if real is None: + missing.append(canonical) + continue + link = source_root / raw + link.parent.mkdir(parents=True, exist_ok=True) + if not link.is_symlink() and not link.exists(): + link.symlink_to(os.path.realpath(real)) + return sorted(set(missing)) diff --git a/score_coverage/gcov_reporter.py b/score_coverage/gcov_reporter.py index 6719789..ccfb008 100644 --- a/score_coverage/gcov_reporter.py +++ b/score_coverage/gcov_reporter.py @@ -40,20 +40,21 @@ from python.runfiles import Runfiles -from score_coverage.reporter import ( +from score_coverage.coverage_selection import ( FileSelection, - RunfilesLike, canonical_path, - create_zip, format_unmapped_files, + resolve_foreign_virtual_includes, + select_files, + warn_instrumentation_filter, +) +from score_coverage.coverage_sources import RunfilesLike, stage_sources +from score_coverage.reporter import ( + create_zip, load_coverage_allowlist, load_path_map, read_reports_file, - resolve_foreign_virtual_includes, resolve_tool, - select_files, - stage_sources, - warn_instrumentation_filter, write_empty_output, ) diff --git a/score_coverage/reporter.py b/score_coverage/reporter.py index 219023d..1fd9139 100644 --- a/score_coverage/reporter.py +++ b/score_coverage/reporter.py @@ -30,18 +30,20 @@ import subprocess import sys import zipfile -from dataclasses import dataclass, field from pathlib import Path -from typing import Protocol from python.runfiles import Runfiles - -class RunfilesLike(Protocol): - """The part of ``python.runfiles.Runfiles`` this module uses; tests provide fakes.""" - - def Rlocation(self, path: str) -> str | None: # noqa: N802 # pylint: disable=invalid-name - """Resolve a runfiles path to an absolute path, or None.""" +from score_coverage.coverage_selection import ( + FileSelection, + canonical_path, + format_unmapped_files, + resolve_foreign_virtual_includes, + select_files, + source_stem, + warn_instrumentation_filter, +) +from score_coverage.coverage_sources import RunfilesLike, stage_sources def main(argv: list[str] | None = None) -> None: @@ -193,81 +195,6 @@ def show_html(objects: list[str]) -> None: print(f"INFO: Coverage reporter completed. Output: {args.output_file}", file=sys.stderr) -# Configuration-specific root of a generated file's exec path, e.g. -# "bazel-out/k8-fastbuild/bin/" or "bazel-out/k8-opt-exec-ST-/bin/". Headers -# behind strip_include_prefix are compiled from such a _virtual_includes/ tree -# and the coverage mapping records that path; the scope allowlist carries the -# configuration-agnostic short_path, so both sides are normalized to it. -_BAZEL_OUT_CONFIG_RE = re.compile(r"^bazel-out/[^/]+/bin/") - - -def strip_config_prefix(path: str) -> str: - """Drop a leading ``bazel-out//bin/`` from a workspace-relative path.""" - return _BAZEL_OUT_CONFIG_RE.sub("", path, count=1) - - -def redundant_baseline_variants(test_covered: dict[str, str], baseline_covered: dict[str, str]) -> set[str]: - """Raw baseline paths whose normalized file is already covered by a test binary. - - Both dicts map raw covmap paths to normalized names. A plain source file - has the same raw path in both, so nothing is returned for it; a generated - header differs only in the configuration prefix, and its baseline variant - would otherwise appear as a second, spurious 0 % entry. - """ - covered_names = set(test_covered.values()) - return {raw for raw, name in baseline_covered.items() if name in covered_names and raw not in test_covered} - - -def canonical_path(path: str, path_map: dict[str, str] | None = None) -> str: - """The name a file is reported under. - - Drops the configuration prefix of a generated header and maps a - ``_virtual_includes/`` path to the header it was generated from (the - scope's path map). Plain source paths are returned unchanged. - """ - stripped = strip_config_prefix(path) - if path_map: - return path_map.get(stripped, stripped) - return stripped - - -_FOREIGN_VIRTUAL_RE = re.compile(r"^(.*/)?_virtual_includes/[^/]+/(?P.+)$") - - -def resolve_foreign_virtual_includes( - names: set[str], allowlist: set[str] -) -> tuple[dict[str, str], dict[str, list[str]]]: - """Map ``_virtual_includes/`` paths of targets outside the scope to the allowlisted file. - - The scope's path map only covers virtual-include trees of targets the - aspect visited. A test-only twin of a library (same ``hdrs`` behind - ``strip_include_prefix``, other copts; baselibs' ``futurecpp_internal``) - generates its own tree from the very same files, and test binaries record - that tree. Such a name is resolved by its tail: the allowlisted file that - ends with ```` at a path-component boundary. If no file matches the - full tail (an ``include_prefix`` added components), shorter tails are - tried. Returns ``{virtual name: canonical}`` and ``{virtual name: candidates}`` - for tails that match several allowlisted files (left unresolved). - """ - resolved: dict[str, str] = {} - ambiguous: dict[str, list[str]] = {} - for name in sorted(names): - match = _FOREIGN_VIRTUAL_RE.match(name) - if not match or name in allowlist: - continue - parts = match.group("tail").split("/") - for start in range(len(parts)): - suffix = "/".join(parts[start:]) - candidates = sorted(a for a in allowlist if a == suffix or a.endswith("/" + suffix)) - if len(candidates) == 1: - resolved[name] = candidates[0] - break - if len(candidates) > 1: - ambiguous[name] = candidates - break - return resolved, ambiguous - - def exclusion_regex(raw: str, roots: list[str]) -> str: """``--ignore-filename-regex`` that matches exactly one compiled file. @@ -284,214 +211,6 @@ def exclusion_regex(raw: str, roots: list[str]) -> str: return "^(" + "|".join(re.escape(prefix) for prefix in prefixes) + ")?" + re.escape(raw) + "$" -def duplicate_test_variants(test_covered: dict[str, str]) -> dict[str, list[str]]: - """Raw test-binary paths to drop because another raw path of the same file is kept. - - A file compiled under two names (its declared path and a virtual-includes - path) would otherwise produce two entries for one canonical name. The - variant equal to the canonical name is kept, else the first in sort order. - Returns {canonical name: dropped raw paths}. - """ - by_name: dict[str, list[str]] = {} - for raw, name in sorted(test_covered.items()): - by_name.setdefault(name, []).append(raw) - dropped: dict[str, list[str]] = {} - for name, raws in by_name.items(): - if len(raws) > 1: - keep = name if name in raws else raws[0] - dropped[name] = [raw for raw in raws if raw != keep] - return dropped - - -@dataclass -class FileSelection: - """Which compiled files stay in the report and under which name.""" - - staged: dict[str, str] = field(default_factory=dict) - """raw covmap path -> canonical name of every file that stays in the report.""" - excluded: set[str] = field(default_factory=set) - """raw covmap paths suppressed through --ignore-filename-regex.""" - baseline_only: set[str] = field(default_factory=set) - """canonical names that only the baseline archives contain (0 % entries).""" - duplicates: dict[str, list[str]] = field(default_factory=dict) - """canonical name -> raw variants dropped in favour of another variant.""" - unmapped: set[str] = field(default_factory=set) - """allowlisted files without coverage data anywhere, and no benign explanation: the findings.""" - declaration_only: set[str] = field(default_factory=set) - """unmapped headers whose same-named source file has coverage data (declarations only).""" - empty_units: set[str] = field(default_factory=set) - """unmapped sources that were compiled into a baseline archive: no code of their own.""" - - -_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx", ".inl", ".ipp", ".tpp") - - -def _is_header(path: str) -> bool: - return path.endswith(_HEADER_SUFFIXES) - - -def _stem(path: str) -> str: - """Path without its last extension: ``src/foo.h`` and ``src/foo.cpp`` share ``src/foo``.""" - return os.path.splitext(path)[0] - - -def select_files( - test_covered: dict[str, str], - baseline_covered: dict[str, str], - allowlist: set[str] | None, - compiled_stems: set[str] | None = None, -) -> FileSelection: - """Apply the scope allowlist to the raw files of test binaries and baseline archives. - - ``allowlist`` is a set of canonical names; ``None`` keeps every file. - ``compiled_stems`` are ``/`` stems of the baseline archives' - object members (see :func:`expand_baseline_archives`); an allowlisted - source with such a stem but no coverage data was compiled and holds no - code of its own. - """ - everything = {**baseline_covered, **test_covered} - - def in_scope(name: str) -> bool: - return allowlist is None or name in allowlist - - excluded = {raw for raw, name in everything.items() if not in_scope(name)} - excluded |= redundant_baseline_variants(test_covered, baseline_covered) - duplicates = duplicate_test_variants(test_covered) - for raws in duplicates.values(): - excluded.update(raws) - staged = {raw: name for raw, name in everything.items() if raw not in excluded} - baseline_only = {name for name in set(baseline_covered.values()) - set(test_covered.values()) if in_scope(name)} - # In scope, but compiled into nothing: a header no translation unit - # includes, or template code that is never instantiated. llvm-cov cannot - # report such a file, not even at 0 %, so the reporter must. - unmapped: set[str] = set() - declaration_only: set[str] = set() - empty_units: set[str] = set() - if allowlist is not None: - with_data = set(test_covered.values()) | set(baseline_covered.values()) - unmapped = allowlist - with_data - # A header whose same-named source file has data (foo.h next to a - # compiled foo.cpp) holds declarations only; that is expected and is - # kept apart from headers nothing compiles. - stems_with_data = {_stem(name) for name in with_data} - declaration_only = {name for name in unmapped if _is_header(name) and _stem(name) in stems_with_data} - unmapped -= declaration_only - # A source whose object sits in a baseline archive but that has no - # coverage data of its own is a placeholder translation unit of a - # header-only library: compiled, nothing to cover in that file. - if compiled_stems: - empty_units = {name for name in unmapped if not _is_header(name) and _stem(name) in compiled_stems} - unmapped -= empty_units - return FileSelection( - staged=staged, - excluded=excluded, - baseline_only=baseline_only, - duplicates=duplicates, - unmapped=unmapped, - declaration_only=declaration_only, - empty_units=empty_units, - ) - - -_TEST_SUBDIRECTORIES = ("test", "tests") - - -def instrumentation_filter_suspects(no_data: set[str], with_data: set[str]) -> list[str]: - """In-scope files without test data whose directory is tested from a ``test``/``tests`` subdirectory. - - Bazel guesses ``--instrumentation_filter`` from the packages of the test - targets and strips only a trailing ``/tests``. A library tested from a - ``test`` subpackage is then outside the filter: compiled without counters - (both backends) or, on the gcov backend, its counters are dropped by - Bazel's collector. Such files show 0 % or no data although a test ran - them. The heuristic: no file of the directory has test data, while a - ``test/`` or ``tests/`` subdirectory of it has. - """ - dirs_with_data = {name.rsplit("/", 1)[0] if "/" in name else "" for name in with_data} - suspects = [] - for name in no_data: - directory = name.rsplit("/", 1)[0] if "/" in name else "" - if directory in dirs_with_data: - continue - tested_from_subdir = any( - d == f"{directory}/{sub}" or d.startswith(f"{directory}/{sub}/") - for d in dirs_with_data - for sub in _TEST_SUBDIRECTORIES - ) - if tested_from_subdir: - suspects.append(name) - return sorted(suspects) - - -def warn_instrumentation_filter(selection: FileSelection, with_data: set[str]) -> list[str]: - """Print the ``--instrumentation_filter`` hint for the suspects; returns them.""" - suspects = instrumentation_filter_suspects(selection.baseline_only | selection.unmapped, with_data) - if suspects: - print( - f"WARNING: {len(suspects)} in-scope files have no test data although their directory is tested " - f"from a test/ or tests/ subdirectory (e.g., {suspects[:5]}). Bazel's default " - "--instrumentation_filter covers only the packages of the test targets; set " - "--instrumentation_filter=^//[/:] in the coverage config (user manual, step 4).", - file=sys.stderr, - ) - return suspects - - -UNMAPPED_NO_DATA = "no-data" -UNMAPPED_DECLARATION_ONLY = "declaration-only" -UNMAPPED_EMPTY_UNIT = "compiled-without-code" - - -def format_unmapped_files(selection: FileSelection) -> str: - """``\t`` lines for every in-scope file without coverage data.""" - rows = ( - [(UNMAPPED_NO_DATA, name) for name in selection.unmapped] - + [(UNMAPPED_DECLARATION_ONLY, name) for name in selection.declaration_only] - + [(UNMAPPED_EMPTY_UNIT, name) for name in selection.empty_units] - ) - return "".join(f"{category}\t{name}\n" for category, name in sorted(rows)) - - -def resolve_source(runfiles: RunfilesLike, canonical: str, workspace_root: str) -> str | None: - """Absolute path of an in-scope source: from the reporter's runfiles, else the workspace.""" - if canonical.startswith("external/"): - candidates = [runfiles.Rlocation(canonical[len("external/") :])] - else: - candidates = [runfiles.Rlocation(os.path.join("_main", canonical)), os.path.join(workspace_root, canonical)] - for candidate in candidates: - if candidate and os.path.isfile(candidate): - return candidate - return None - - -def stage_sources( - source_root: Path, - staged: dict[str, str], - runfiles: RunfilesLike, - workspace_root: str, -) -> list[str]: - """Create ``source_root/`` links to the real sources. - - llvm-cov then reads every in-scope file through one - ``--path-equivalence=/proc/self/cwd/,`` (C++) and - ``--compilation-dir=`` (Rust). Returns the canonical names - whose source could not be located. - """ - missing = [] - for raw, canonical in sorted(staged.items()): - if raw.startswith("/"): - continue # an absolute covmap path is read as it is - real = resolve_source(runfiles, canonical, workspace_root) - if real is None: - missing.append(canonical) - continue - link = source_root / raw - link.parent.mkdir(parents=True, exist_ok=True) - if not link.is_symlink() and not link.exists(): - link.symlink_to(os.path.realpath(real)) - return sorted(set(missing)) - - _ASSET_LINK_RE = re.compile(r"((?:href|src)=')((?:\.\./)*)(style\.css|control\.js)'") @@ -1109,7 +828,7 @@ def expand_baseline_archives(manifest: dict[str, str], workdir: Path) -> tuple[l usable = [] for name, offset, size in members: if name.endswith(".o") and not name.endswith(".rcgu.o"): - compiled_stems.add(os.path.join(os.path.dirname(manifest[path]), _stem(name))) + compiled_stems.add(os.path.join(os.path.dirname(manifest[path]), source_stem(name))) f.seek(offset) if object_has_covmap(f.read(size)): usable.append((name, offset, size)) diff --git a/score_coverage/tests/BUILD b/score_coverage/tests/BUILD index f2ca9b7..f11fa1b 100644 --- a/score_coverage/tests/BUILD +++ b/score_coverage/tests/BUILD @@ -39,6 +39,8 @@ score_py_pytest( srcs = ["reporter_test.py"], deps = [ ":traceability", + "//score_coverage:coverage_selection_lib", + "//score_coverage:coverage_sources_lib", "//score_coverage:reporter_lib", ], ) diff --git a/score_coverage/tests/reporter_test.py b/score_coverage/tests/reporter_test.py index 6c9f68c..54f90ee 100644 --- a/score_coverage/tests/reporter_test.py +++ b/score_coverage/tests/reporter_test.py @@ -31,7 +31,7 @@ from pathlib import Path from unittest import mock -from score_coverage import reporter +from score_coverage import coverage_selection, coverage_sources, reporter from score_coverage.reporter import ( _filter_lcov, _make_html_paths_relative, @@ -833,13 +833,13 @@ class CanonicalPathTest(unittest.TestCase): MAP = {"src/_virtual_includes/v/api.h": "src/v/include/api.h"} def test_plain_paths_are_unchanged(self): - self.assertEqual(reporter.canonical_path("src/a.cpp", self.MAP), "src/a.cpp") - self.assertEqual(reporter.canonical_path("external/ext+/x.h", None), "external/ext+/x.h") + self.assertEqual(coverage_selection.canonical_path("src/a.cpp", self.MAP), "src/a.cpp") + self.assertEqual(coverage_selection.canonical_path("external/ext+/x.h", None), "external/ext+/x.h") def test_virtual_path_maps_to_declared_header_under_any_config(self): for cfg in ("k8-fastbuild", "k8-opt-exec-ST-1234"): self.assertEqual( - reporter.canonical_path(f"bazel-out/{cfg}/bin/src/_virtual_includes/v/api.h", self.MAP), + coverage_selection.canonical_path(f"bazel-out/{cfg}/bin/src/_virtual_includes/v/api.h", self.MAP), "src/v/include/api.h", ) @@ -854,28 +854,30 @@ def test_instrumentation_filter_suspects_flags_libraries_tested_from_a_test_subd "score/net/socket.cpp", # nothing tested below it: ordinary untested file } self.assertEqual( - reporter.instrumentation_filter_suspects(no_data, with_data), + coverage_selection.instrumentation_filter_suspects(no_data, with_data), ["score/json/model.cpp", "score/os/acl_impl.cpp"], ) - self.assertEqual(reporter.instrumentation_filter_suspects(set(), with_data), []) + self.assertEqual(coverage_selection.instrumentation_filter_suspects(set(), with_data), []) def test_warn_instrumentation_filter_names_the_flag(self): - selection = reporter.FileSelection(baseline_only={"score/os/acl_impl.cpp"}, unmapped={"score/os/glob.cpp"}) + selection = coverage_selection.FileSelection( + baseline_only={"score/os/acl_impl.cpp"}, unmapped={"score/os/glob.cpp"} + ) err = io.StringIO() with redirect_stderr(err): - suspects = reporter.warn_instrumentation_filter(selection, {"score/os/test/acl_test.cpp"}) + suspects = coverage_selection.warn_instrumentation_filter(selection, {"score/os/test/acl_test.cpp"}) self.assertEqual(suspects, ["score/os/acl_impl.cpp", "score/os/glob.cpp"]) self.assertIn("--instrumentation_filter=^//[/:]", err.getvalue()) self.assertIn("2 in-scope files", err.getvalue()) # Nothing to say when the directory itself has data. err = io.StringIO() with redirect_stderr(err): - self.assertEqual(reporter.warn_instrumentation_filter(selection, {"score/os/errno.cpp"}), []) + self.assertEqual(coverage_selection.warn_instrumentation_filter(selection, {"score/os/errno.cpp"}), []) self.assertEqual(err.getvalue(), "") def test_unmapped_virtual_path_keeps_its_config_free_form(self): self.assertEqual( - reporter.canonical_path("bazel-out/k8-fastbuild/bin/src/_virtual_includes/u/y.h", self.MAP), + coverage_selection.canonical_path("bazel-out/k8-fastbuild/bin/src/_virtual_includes/u/y.h", self.MAP), "src/_virtual_includes/u/y.h", ) @@ -893,7 +895,7 @@ class ForeignVirtualIncludesTest(unittest.TestCase): } def test_unique_tail_resolves(self): - resolved, ambiguous = reporter.resolve_foreign_virtual_includes( + resolved, ambiguous = coverage_selection.resolve_foreign_virtual_includes( {"lib/_virtual_includes/lib_internal/score/private/invoke.hpp", "src/plain.cpp"}, self.ALLOW ) self.assertEqual( @@ -903,7 +905,7 @@ def test_unique_tail_resolves(self): self.assertEqual(ambiguous, {}) def test_ambiguous_tail_is_reported_not_guessed(self): - resolved, ambiguous = reporter.resolve_foreign_virtual_includes( + resolved, ambiguous = coverage_selection.resolve_foreign_virtual_includes( {"lib/_virtual_includes/lib_internal/score/apply.hpp"}, self.ALLOW ) self.assertEqual(resolved, {}) @@ -919,7 +921,7 @@ def test_ambiguous_tail_is_reported_not_guessed(self): def test_include_prefix_components_are_skipped(self): # include_prefix = "pfx" adds a component the declared path does not have - resolved, _ = reporter.resolve_foreign_virtual_includes( + resolved, _ = coverage_selection.resolve_foreign_virtual_includes( {"src/_virtual_includes/twin/pfx/vendored/api.h"}, self.ALLOW ) self.assertEqual( @@ -927,7 +929,7 @@ def test_include_prefix_components_are_skipped(self): ) def test_no_match_and_non_virtual_names_are_left_alone(self): - resolved, ambiguous = reporter.resolve_foreign_virtual_includes( + resolved, ambiguous = coverage_selection.resolve_foreign_virtual_includes( {"x/_virtual_includes/t/unknown.h", "src/plain.cpp", "lib/include/score/apply.hpp"}, self.ALLOW ) self.assertEqual((resolved, ambiguous), ({}, {})) @@ -974,7 +976,7 @@ def test_out_of_scope_and_redundant_variants_are_excluded(self): "bazel-out/k8-opt-exec-ST-1/bin/src/_virtual_includes/v/api.h": "src/v/api.h", "external/openssl+/y.h": "external/openssl+/y.h", } - sel = reporter.select_files(test, baseline, {"src/a.cpp", "src/v/api.h", "src/untested.cpp"}) + sel = coverage_selection.select_files(test, baseline, {"src/a.cpp", "src/v/api.h", "src/untested.cpp"}) self.assertEqual( sel.excluded, { @@ -1014,12 +1016,12 @@ def test_allowlisted_file_without_any_coverage_data_is_reported(self): # empty.cpp was compiled (its object is an archive member) but has no # data of its own: no code. never.h, tmpl.h and a source nobody built # (orphan.cpp: no object anywhere) remain findings. - sel = reporter.select_files(test, baseline, allowlist, compiled_stems={"src/a", "src/b", "src/empty"}) + sel = coverage_selection.select_files(test, baseline, allowlist, compiled_stems={"src/a", "src/b", "src/empty"}) self.assertEqual(sel.unmapped, {"src/never.h", "src/tmpl.h", "src/orphan.cpp"}) self.assertEqual(sel.declaration_only, {"src/a.h", "src/b.hpp"}) self.assertEqual(sel.empty_units, {"src/empty.cpp"}) self.assertEqual( - reporter.format_unmapped_files(sel), + coverage_selection.format_unmapped_files(sel), "compiled-without-code\tsrc/empty.cpp\ndeclaration-only\tsrc/a.h\ndeclaration-only\tsrc/b.hpp\n" "no-data\tsrc/never.h\nno-data\tsrc/orphan.cpp\nno-data\tsrc/tmpl.h\n", ) @@ -1034,21 +1036,21 @@ def test_duplicate_test_variants_keep_the_declared_path(self): "bazel-out/k8-fastbuild-ST-2/bin/src/_virtual_includes/w/w.h": "src/w/w.h", } self.assertEqual( - reporter.duplicate_test_variants(test), + coverage_selection.duplicate_test_variants(test), { "src/v/api.h": ["bazel-out/k8-fastbuild/bin/src/_virtual_includes/v/api.h"], # no declared-path variant: the first in sort order is kept ('-' < '/') "src/w/w.h": ["bazel-out/k8-fastbuild/bin/src/_virtual_includes/w/w.h"], }, ) - sel = reporter.select_files(test, {}, None) + sel = coverage_selection.select_files(test, {}, None) self.assertEqual( set(sel.staged), {"src/v/api.h", "bazel-out/k8-fastbuild-ST-2/bin/src/_virtual_includes/w/w.h"} ) self.assertEqual(len(sel.excluded), 2) def test_no_allowlist_keeps_everything(self): - sel = reporter.select_files({"a": "a"}, {"b": "b"}, None) + sel = coverage_selection.select_files({"a": "a"}, {"b": "b"}, None) self.assertEqual(sel.staged, {"a": "a", "b": "b"}) self.assertEqual(sel.excluded, set()) self.assertEqual(sel.baseline_only, {"b"}) @@ -1083,17 +1085,17 @@ def tearDown(self): def test_resolution_order_runfiles_then_workspace(self): self.assertEqual( - reporter.resolve_source(self.runfiles, "src/a.cpp", str(self.ws)), + coverage_sources.resolve_source(self.runfiles, "src/a.cpp", str(self.ws)), str(self.runfiles_dir / "_main" / "src" / "a.cpp"), ) self.assertEqual( - reporter.resolve_source(self.runfiles, "external/ext+/inc/x.h", str(self.ws)), + coverage_sources.resolve_source(self.runfiles, "external/ext+/inc/x.h", str(self.ws)), str(self.runfiles_dir / "ext+" / "inc" / "x.h"), ) self.assertEqual( - reporter.resolve_source(self.runfiles, "rust/lib.rs", str(self.ws)), str(self.ws / "rust/lib.rs") + coverage_sources.resolve_source(self.runfiles, "rust/lib.rs", str(self.ws)), str(self.ws / "rust/lib.rs") ) - self.assertIsNone(reporter.resolve_source(self.runfiles, "src/missing.cpp", str(self.ws))) + self.assertIsNone(coverage_sources.resolve_source(self.runfiles, "src/missing.cpp", str(self.ws))) def test_links_follow_the_raw_layout_and_missing_files_are_reported(self): stage = self.root / "sources" @@ -1104,7 +1106,7 @@ def test_links_follow_the_raw_layout_and_missing_files_are_reported(self): "src/missing.cpp": "src/missing.cpp", "/usr/include/abs.h": "/usr/include/abs.h", } - missing = reporter.stage_sources(stage, staged, self.runfiles, str(self.ws)) + missing = coverage_sources.stage_sources(stage, staged, self.runfiles, str(self.ws)) self.assertEqual(missing, ["src/missing.cpp"]) self.assertEqual((stage / "src" / "a.cpp").read_text(encoding="utf-8"), "a") virtual = stage / "bazel-out/k8-fastbuild/bin/src/_virtual_includes/v/x.h" @@ -1113,7 +1115,9 @@ def test_links_follow_the_raw_layout_and_missing_files_are_reported(self): self.assertEqual((stage / "rust" / "lib.rs").read_text(encoding="utf-8"), "r") self.assertFalse((stage / "usr").exists()) # idempotent - self.assertEqual(reporter.stage_sources(stage, staged, self.runfiles, str(self.ws)), ["src/missing.cpp"]) + self.assertEqual( + coverage_sources.stage_sources(stage, staged, self.runfiles, str(self.ws)), ["src/missing.cpp"] + ) @verifies("tool_req__coverage_report_relative_paths", "tool_req__coverage_report_outputs") @@ -1243,21 +1247,23 @@ class ConfigPrefixTest(unittest.TestCase): def test_strip_config_prefix(self): self.assertEqual( - reporter.strip_config_prefix("bazel-out/k8-fastbuild/bin/src/_virtual_includes/v/x.h"), + coverage_selection.strip_config_prefix("bazel-out/k8-fastbuild/bin/src/_virtual_includes/v/x.h"), "src/_virtual_includes/v/x.h", ) self.assertEqual( - reporter.strip_config_prefix("bazel-out/k8-opt-exec-ST-db392155ee03/bin/src/_virtual_includes/v/x.h"), + coverage_selection.strip_config_prefix( + "bazel-out/k8-opt-exec-ST-db392155ee03/bin/src/_virtual_includes/v/x.h" + ), "src/_virtual_includes/v/x.h", ) - self.assertEqual(reporter.strip_config_prefix("src/a.cpp"), "src/a.cpp") + self.assertEqual(coverage_selection.strip_config_prefix("src/a.cpp"), "src/a.cpp") self.assertEqual( - reporter.strip_config_prefix("external/flatbuffers+/include/flatbuffers/base.h"), + coverage_selection.strip_config_prefix("external/flatbuffers+/include/flatbuffers/base.h"), "external/flatbuffers+/include/flatbuffers/base.h", ) # only a leading prefix is stripped, once unchanged = "x/bazel-out/k8-fastbuild/bin/y.h" - self.assertEqual(reporter.strip_config_prefix(unchanged), unchanged) + self.assertEqual(coverage_selection.strip_config_prefix(unchanged), unchanged) def test_lcov_and_html_paths_drop_the_config_prefix(self): lcov = "SF:/ws/bazel-out/k8-fastbuild/bin/src/_virtual_includes/v/x.h\nDA:1,1\nend_of_record\n" @@ -1291,7 +1297,7 @@ def test_generated_header_variant_from_baseline_is_redundant(self): "src/uncovered.cpp": "src/uncovered.cpp", } self.assertEqual( - reporter.redundant_baseline_variants(test_covered, baseline), + coverage_selection.redundant_baseline_variants(test_covered, baseline), {"bazel-out/k8-opt-exec-ST-1/bin/src/_virtual_includes/v/x.h"}, ) @@ -1302,11 +1308,11 @@ def test_plain_sources_and_untested_files_are_kept(self): "bazel-out/k8-opt-exec-ST-1/bin/src/_virtual_includes/u/y.h": "src/_virtual_includes/u/y.h", } # a.cpp: identical raw path -> not redundant; y.h: not covered by any test -> kept as baseline - self.assertEqual(reporter.redundant_baseline_variants(test_covered, baseline), set()) + self.assertEqual(coverage_selection.redundant_baseline_variants(test_covered, baseline), set()) def test_empty_inputs(self): - self.assertEqual(reporter.redundant_baseline_variants({}, {}), set()) - self.assertEqual(reporter.redundant_baseline_variants({"a": "a"}, {}), set()) + self.assertEqual(coverage_selection.redundant_baseline_variants({}, {}), set()) + self.assertEqual(coverage_selection.redundant_baseline_variants({"a": "a"}, {}), set()) if __name__ == "__main__":