diff --git a/src/extensions/score_coverage_report/BUILD b/src/extensions/score_coverage_report/BUILD new file mode 100644 index 000000000..9cea8be69 --- /dev/null +++ b/src/extensions/score_coverage_report/BUILD @@ -0,0 +1,56 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("//:score_pytest.bzl", "score_pytest") + +filegroup( + name = "sources", + srcs = glob(["*.py"]), +) + +filegroup( + name = "tests", + srcs = glob(["tests/*.py"]), +) + +filegroup( + name = "all_sources", + srcs = [ + ":sources", + ":tests", + ], + visibility = ["//visibility:public"], +) + +py_library( + name = "score_coverage_report", + srcs = [":sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = ["@score_docs_as_code//src/helper_lib"], +) + +score_pytest( + name = "score_coverage_report_test", + size = "small", + srcs = glob(["tests/*.py"]), + args = [ + "-s", + "-vv", + ], + imports = ["."], + deps = [ + ":score_coverage_report", + ], +) diff --git a/src/extensions/score_coverage_report/README.md b/src/extensions/score_coverage_report/README.md new file mode 100644 index 000000000..12b032150 --- /dev/null +++ b/src/extensions/score_coverage_report/README.md @@ -0,0 +1,161 @@ + + +# `score_coverage_report` + +This Sphinx extension feeds per-component LCOV coverage data into the +`module_verification_report` Sphinx-Needs post-template. It reads a single +LCOV tracefile (produced by whatever coverage tool the consuming repository +already uses, e.g. `bazel coverage`), attributes each source file to the +`comp` Need that owns it, and exposes the result to Jinja templates through a +`component_coverage(component_id)` render-context callable. + +## Why this exists + +`module_verification_report` renders a "Test Coverage" section per component, +which was previously a static placeholder. Coverage data only exists once +tests have *run*, so a docs build cannot declare the tracefile as a Bazel +input without pulling the whole test suite into its dependency graph. This +extension therefore resolves the tracefile lazily, at Sphinx build time, and +degrades gracefully (empty tables, not a build failure) whenever the file is +missing. + +Which files belong to which component is likewise not something the +directory layout or the Bazel graph can answer reliably -- components nest, +and several may share one documentation bundle. Component boundaries are an +architectural decision, so they are declared next to the `includes` list that +already defines a module's scope, via the optional `source_roots` option on +`mod` Needs (see `score_metamodel`'s `metamodel.yaml`). + +## Architecture overview + +```mermaid +flowchart TD + subgraph Input + A[LCOV tracefile
bazel coverage --combined_report=lcov] + B["mod Need `source_roots` option
(score_metamodel)"] + end + + subgraph score_coverage_report + R["_resolve_lcov_path
env var / conf.py / workspace defaults"] + L["_read_lcov
plain file or ZIP archive"] + P["lcov_parser.parse_lcov
DA:/BRDA: -> FileCoverage per SF: path"] + S["_component_source_roots
collects source_roots from all mod Needs"] + C["lcov_parser.assign_files_to_components
longest-prefix-match attribution"] + F["_component_coverage_callable
component_coverage(component_id)"] + end + + T["module_verification_report.need
Jinja post_template"] + + A --> R --> L --> P --> C + B --> S --> C + C --> F --> T +``` + +### Lifecycle and module-global state + +The extension hooks into two Sphinx-Needs/Sphinx lifecycle events and keeps +its intermediate results in process-local module globals rather than instance +state, mirroring the pattern used by `score_sphinx_needs_templates`'s +`_LinkedNeeds`: the render-context callable registered in +`needs_render_context` must stay a plain, picklable top-level instance, so it +cannot carry per-build state itself. + +* `builder-inited` → `_capture_coverage_config`: resolves and parses the + LCOV tracefile once per build, storing the per-file result in + `_coverage_by_file` and the `BuildEnvironment` in `_build_environment`. +* `env-updated` → `_invalidate_component_coverage` (priority `100`, i.e. + before `score_sphinx_needs_templates`'s handler on the same event): drops + the cached `_coverage_by_component` attribution. + +`module_verification_report` carries the `render-after-needs-collection` +marker, so it is rendered twice: once while documents are read (when a +parallel worker may not yet see every `mod` Need) and once more after Need +environments are merged. Invalidating the attribution cache on `env-updated` +ensures the second rendering pass attributes coverage using the complete set +of Needs, while still reusing the (expensive, I/O-bound) parsed tracefile +from `_coverage_by_file`. + +### Locating the tracefile + +`_resolve_lcov_path` picks the LCOV file with the following precedence, +mirroring how `score_source_code_linker` wires `SCORE_SOURCELINKS`: + +1. `SCORE_COVERAGE_LCOV` environment variable -- intended for a Bazel rule to + inject a `$(location ...)`-expanded path. +2. `score_coverage_lcov_path` in `conf.py` -- a plain path, convenient for + local/direct Sphinx builds. +3. Nothing configured: a tracefile found under the workspace-relative + `coverage-report/` directory (`DEFAULT_COVERAGE_DIR`), else + `bazel-out/_coverage/_coverage_report.dat` (`DEFAULT_LCOV_PATH`), which is + where `bazel coverage --combined_report=lcov` already leaves its output. + Both defaults require `BUILD_WORKSPACE_DIRECTORY` (i.e. `bazel run`); under + a sandboxed `bazel build` neither resolves, and the extension logs and + skips coverage instead of failing the build. + +A relative configured path is resolved against the Bazel workspace root +(`find_ws_root()`, from `src/helper_lib`) when one is known, and used as-is +otherwise. + +### Reading the tracefile + +`_read_lcov` sniffs the file content rather than trusting its extension: a +report generator (e.g. `score_coverage`) may bundle the HTML, text and LCOV +renderings into a single ZIP archive under the configured/default name. If +the file is a ZIP, the extension reads `lcov_report/lcov.dat` if present, else +the first archive member with a `.dat`/`.info`/`.lcov` suffix, entirely in +memory (nothing is unpacked to disk). + +### Parsing (`lcov_parser.py`) + +`parse_lcov` turns LCOV tracefile text into `FileCoverage` records keyed by +the `SF:` path, independent of the tool that produced it (`llvm-cov`, `gcov`, +`grcov`, ...). Per-file totals are recomputed from `DA:`/`BRDA:` record lines +rather than trusted from the optional `LF`/`LH`/`BRF`/`BRH` summary lines, +since producers are inconsistent about emitting those. + +### Attributing files to components + +`_component_source_roots` walks every `mod` Need in the Sphinx-Needs graph and +parses its `source_roots` option body (one `: ` pair per +line) via `_parse_source_roots`, which skips malformed lines with a warning +rather than failing the build. + +`assign_files_to_components` then attributes each `FileCoverage` to at most +one component: roots are matched as path prefixes (`_matches_prefix`, which +tolerates both repo-relative and absolute/sandboxed `SF:` paths), and the +*longest* matching root wins so that a nested child component claims its own +files instead of an ancestor also counting them. A component with no matching +root, or no Need declaring it, simply reports no coverage -- callers can tell +"no data available" apart from "0% covered". + +### Consumption from templates + +`_ComponentCoverage.__call__` (registered as `component_coverage` in +`needs_render_context`) lazily computes and caches the +component-to-`ComponentCoverage` mapping on first use per build/invalidation +cycle. `module_verification_report.need` calls +`component_coverage(component_id)` to render the per-component line/branch +coverage pie charts and the per-file coverage table. + +## Public surface + +* `setup(app)` -- the Sphinx extension entry point. Registers the + `score_coverage_lcov_path` config value, the `component_coverage` + render-context callable, and the two lifecycle hooks described above. + +Everything else (`_resolve_lcov_path`, `_read_lcov`, `_load_coverage`, +`_parse_source_roots`, `_component_source_roots`, `_ComponentCoverage`, +`lcov_parser.parse_lcov`, `lcov_parser.assign_files_to_components`, ...) is an +internal implementation detail, covered directly by the unit tests in +`tests/`. diff --git a/src/extensions/score_coverage_report/__init__.py b/src/extensions/score_coverage_report/__init__.py new file mode 100644 index 000000000..534118e2a --- /dev/null +++ b/src/extensions/score_coverage_report/__init__.py @@ -0,0 +1,309 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Feed per-component LCOV coverage into ``module_verification_report`` Needs. + +This extension reads a single LCOV tracefile (produced by whatever coverage +tool the consuming repository already uses, e.g. ``bazel coverage``) and +exposes a ``component_coverage(component_id)`` helper to Sphinx-Needs +post-templates (see ``needs_render_context`` in the Sphinx-Needs docs). The +``module_verification_report`` post-template calls it to render the +per-component "Test Coverage" table that is otherwise a static placeholder. + +Wiring, in order of precedence: + +1. The ``SCORE_COVERAGE_LCOV`` environment variable (meant for a Bazel rule + to inject a ``$(location ...)``-expanded path, mirroring how + ``SCORE_SOURCELINKS`` is wired for ``score_source_code_linker``). +2. The ``score_coverage_lcov_path`` value in ``conf.py`` (a plain path, + convenient for local/direct Sphinx builds). +3. Nothing at all: a tracefile under :data:`DEFAULT_COVERAGE_DIR` in the + workspace, else :data:`DEFAULT_LCOV_PATH`, where ``bazel coverage + --combined_report=lcov`` already leaves its output. A plain ``bazel + coverage`` followed by a docs build therefore needs no configuration + whatsoever. + +The two defaults mirror how ``score_source_code_linker`` locates test results +(``tests-report``, else ``bazel-testlogs``): coverage, like test outcomes, only +exists once tests have *run*, so a docs build cannot declare it as a Bazel +input without pulling the whole test suite into its dependency graph. Both +defaults need ``BUILD_WORKSPACE_DIRECTORY``, i.e. ``bazel run``; under a +sandboxed ``bazel build`` neither resolves, and the tables fall back to a note +rather than failing the build. + +A relative configured path is resolved against the Bazel workspace root when +one is known; it is used as-is otherwise. The file may be either a bare +tracefile or a coverage report archive (see :func:`_read_lcov`), so no manual +unpacking step is required. + +Which files belong to which component is not configured here: every ``mod`` +Need maps its included components to their implementation roots through the +optional ``source_roots`` option defined in the metamodel, one +``: `` pair per line. Component boundaries are an architectural +decision that neither the directory layout nor the Bazel graph reproduces +reliably -- components nest, and several of them may share a single +documentation bundle -- so the architecture states them next to the +``includes`` list that already defines the module's scope. A component that no +module maps reports no coverage. +""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.logging import get_logger + +from src.extensions.score_coverage_report.lcov_parser import ( + ComponentCoverage, + FileCoverage, + assign_files_to_components, + parse_lcov, +) +from src.helper_lib import Environment, find_ws_root + +LOGGER = get_logger(__name__) + +#: Optional ``mod`` option mapping each included component to its +#: implementation root, declared in ``score_metamodel``'s ``metamodel.yaml``. +#: The body holds one ``: `` pair per line. +SOURCE_ROOTS_OPTION = "source_roots" + +#: Workspace directory searched for a tracefile before falling back to +#: :data:`DEFAULT_LCOV_PATH`. This mirrors ``score_source_code_linker``'s +#: ``tests-report`` convention: a real directory inside the workspace survives +#: where the ``bazel-*`` convenience symlinks do not, so CI can drop a coverage +#: artifact here and have it picked up without further configuration. +DEFAULT_COVERAGE_DIR = "coverage-report" + +#: Workspace-relative location where ``bazel coverage --combined_report=lcov`` +#: writes the combined report. Used when neither the environment variable nor +#: ``conf.py`` names a file, so that the common case needs no configuration. +DEFAULT_LCOV_PATH = "bazel-out/_coverage/_coverage_report.dat" + +#: Archive member holding the tracefile in a ``score_coverage`` report bundle. +ARCHIVE_LCOV_MEMBER = "lcov_report/lcov.dat" + +#: Suffixes accepted when an archive does not use :data:`ARCHIVE_LCOV_MEMBER`. +_LCOV_SUFFIXES = (".dat", ".info", ".lcov") + +# Process-local state, mirroring the pattern used by +# ``score_sphinx_needs_templates``'s ``_LinkedNeeds``: the render-context +# callable must stay a plain, pickleable top-level instance, so the data it +# reads is kept in module globals instead of instance state. +_build_environment: BuildEnvironment | None = None +_coverage_by_file: dict[str, FileCoverage] = {} +_coverage_by_component: dict[str, ComponentCoverage] | None = None + + +def _find_in_coverage_dir(ws_root: Path) -> Path | None: + """Return the first tracefile inside :data:`DEFAULT_COVERAGE_DIR`, if any. + + Recursive, because a coverage artifact is usually an unpacked report tree + rather than a single loose file. Sorting keeps the choice deterministic + when several candidates exist. + """ + coverage_dir = ws_root / DEFAULT_COVERAGE_DIR + if not coverage_dir.is_dir(): + return None + candidates = sorted( + path + for path in coverage_dir.rglob("*") + if path.is_file() and path.suffix in _LCOV_SUFFIXES + ) + return candidates[0] if candidates else None + + +def _resolve_lcov_path(app: Sphinx) -> Path: + env = Environment() + raw_path = env.get("SCORE_COVERAGE_LCOV", "") or ( + getattr(app.config, "score_coverage_lcov_path", "") or "" + ) + ws_root = find_ws_root() + if raw_path: + lcov_path = Path(raw_path) + if not lcov_path.is_absolute() and ws_root is not None: + lcov_path = ws_root / lcov_path + return lcov_path + if ws_root is None: + return Path(DEFAULT_LCOV_PATH) + return _find_in_coverage_dir(ws_root) or ws_root / DEFAULT_LCOV_PATH + + +def _read_lcov(lcov_path: Path) -> str | None: + """Return the tracefile text, unpacking a report archive when needed. + + ``bazel coverage --combined_report=lcov`` names its output ``.dat``, but a + repository may plug in a report generator that writes an archive under that + name instead -- ``score_coverage``'s reporter bundles the HTML, text and + LCOV renderings into a ZIP. Sniff the content rather than the extension and + accept both, so the docs build reads whatever ``bazel coverage`` produced. + + Members are read into memory only; nothing is written to disk, so a crafted + archive cannot escape via ``..`` path entries. + """ + if not zipfile.is_zipfile(lcov_path): + return lcov_path.read_text(encoding="utf-8") + with zipfile.ZipFile(lcov_path) as archive: + names = archive.namelist() + member = ARCHIVE_LCOV_MEMBER if ARCHIVE_LCOV_MEMBER in names else None + if member is None: + member = next( + (name for name in sorted(names) if name.endswith(_LCOV_SUFFIXES)), + None, + ) + if member is None: + LOGGER.warning( + "score_coverage_report: no LCOV tracefile inside archive " + f"{lcov_path}, skipping coverage tables", + type="score_coverage_report", + ) + return None + return archive.read(member).decode("utf-8") + + +def _load_coverage(app: Sphinx) -> dict[str, FileCoverage]: + lcov_path = _resolve_lcov_path(app) + if not lcov_path.is_file(): + LOGGER.info( + f"score_coverage_report: LCOV file not found, skipping: {lcov_path}", + type="score_coverage_report", + ) + return {} + content = _read_lcov(lcov_path) + if content is None: + return {} + return parse_lcov(content) + + +def _parse_source_roots(raw: str) -> dict[str, str]: + """Parse a ``source_roots`` option body into ``{component_id: root}``. + + The body holds one ``: `` pair per line. ``score_metamodel`` + validates that shape, but only as a warning, so malformed lines are skipped + here rather than trusted. + """ + roots: dict[str, str] = {} + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + component_id, separator, root = line.partition(":") + if not separator: + LOGGER.warning( + f"score_coverage_report: ignoring malformed source_roots entry " + f"(expected ': '): {line!r}", + type="score_coverage_report", + ) + continue + component_id, root = component_id.strip(), root.strip() + if component_id and root: + roots[component_id] = root + return roots + + +def _component_source_roots(env: BuildEnvironment) -> dict[str, str]: + """Collect the implementation roots declared by every ``mod`` Need.""" + needs = SphinxNeedsData(env).get_needs_mutable() + roots: dict[str, str] = {} + for need in needs.values(): + if need.get("type") != "mod": + continue + raw = str(need.get(SOURCE_ROOTS_OPTION) or "").strip() + if not raw: + continue + for component_id, root in _parse_source_roots(raw).items(): + previous = roots.get(component_id) + if previous is not None and previous != root: + LOGGER.warning( + f"score_coverage_report: {component_id} is given conflicting " + f"source roots ({previous!r} and {root!r}); using {root!r}", + type="score_coverage_report", + ) + roots[component_id] = root + return roots + + +class _ComponentCoverage: + """Render-context callable: ``component_coverage(component_id)``.""" + + def __call__(self, component_id: str) -> ComponentCoverage | None: + global _coverage_by_component + if _build_environment is None or not _coverage_by_file: + return None + if _coverage_by_component is None: + _coverage_by_component = assign_files_to_components( + _coverage_by_file, _component_source_roots(_build_environment) + ) + return _coverage_by_component.get(component_id) + + +_component_coverage_callable = _ComponentCoverage() + + +def _capture_coverage_config(app: Sphinx) -> None: + """Load the LCOV tracefile and keep the build environment for lookups. + + Connected to ``builder-inited``, the first lifecycle event at which both + ``app.config`` and ``app.env`` are available (mirrors + ``score_sphinx_needs_templates._capture_template_environment``). + """ + global _build_environment, _coverage_by_file + _build_environment = app.env + _coverage_by_file = _load_coverage(app) + + +def _invalidate_component_coverage(app: Sphinx, env: BuildEnvironment) -> None: + """Drop the attribution cache before marked pages are rendered again. + + ``module_verification_report`` carries the ``render-after-needs-collection`` + marker, so it is rendered once while documents are read -- when a parallel + worker need not see every ``mod`` Need yet -- and once more after the Need + environments have been merged. Clearing the cache here makes the second pass + attribute coverage using the complete set of Needs. The low priority number + keeps this ahead of ``score_sphinx_needs_templates``, which re-reads the + marked pages on the same event. + """ + global _coverage_by_component + _coverage_by_component = None + + +def setup(app: Sphinx) -> dict[str, object]: + app.setup_extension("sphinx_needs") + + app.add_config_value( + "score_coverage_lcov_path", + default="", + rebuild="env", + types=str, + description=( + "Repo-relative (or absolute) path to an LCOV tracefile, or to an " + "archive containing one, used to populate per-component " + "test-coverage tables in module_verification_report. Overridden by " + "the SCORE_COVERAGE_LCOV environment variable when set; defaults " + f"to {DEFAULT_LCOV_PATH} when neither is given." + ), + ) + app.config.needs_render_context.setdefault( + "component_coverage", _component_coverage_callable + ) + app.connect("builder-inited", _capture_coverage_config) + app.connect("env-updated", _invalidate_component_coverage, priority=100) + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_coverage_report/lcov_parser.py b/src/extensions/score_coverage_report/lcov_parser.py new file mode 100644 index 000000000..0cd765caf --- /dev/null +++ b/src/extensions/score_coverage_report/lcov_parser.py @@ -0,0 +1,253 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Parse LCOV ``.info`` text and aggregate it per component. + +The parser is intentionally independent of any specific coverage producer +(``llvm-cov export --format=lcov``, ``gcov``/``lcov``, ``grcov``, ...): LCOV's +tracefile format (https://ltp.sourceforge.net/coverage/lcov/geninfo.1.php) is a +long-standing, widely emitted plain-text interchange format, so this module +only depends on that format, not on any particular toolchain. + +Per-file totals are recomputed from the ``DA``/``BRDA`` records rather than +trusted from the ``LF``/``LH``/``BRF``/``BRH`` summary records some producers +emit, because those summary records are optional and inconsistently present. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class FileCoverage: + """Line and branch totals for one source file, as recorded in an LCOV + ``SF:``/``end_of_record`` block.""" + + path: str + lines_found: int = 0 + lines_hit: int = 0 + branches_found: int = 0 + branches_hit: int = 0 + + @property + def line_percent(self) -> float | None: + if not self.lines_found: + return None + return round(100 * self.lines_hit / self.lines_found, 1) + + @property + def branch_percent(self) -> float | None: + if not self.branches_found: + return None + return round(100 * self.branches_hit / self.branches_found, 1) + + +@dataclass(frozen=True) +class ComponentCoverage: + """Coverage totals for every LCOV file record attributed to one component.""" + + component_id: str + files: tuple[FileCoverage, ...] = field(default_factory=tuple) + + @property + def lines_found(self) -> int: + return sum(f.lines_found for f in self.files) + + @property + def lines_hit(self) -> int: + return sum(f.lines_hit for f in self.files) + + @property + def branches_found(self) -> int: + return sum(f.branches_found for f in self.files) + + @property + def branches_hit(self) -> int: + return sum(f.branches_hit for f in self.files) + + @property + def line_percent(self) -> float | None: + if not self.lines_found: + return None + return round(100 * self.lines_hit / self.lines_found, 1) + + @property + def branch_percent(self) -> float | None: + if not self.branches_found: + return None + return round(100 * self.branches_hit / self.branches_found, 1) + + +def _parse_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + +@dataclass +class _LcovRecordState: + current_path: str | None = None + lines_found: int = 0 + lines_hit: int = 0 + branches_found: int = 0 + branches_hit: int = 0 + + +def _reset_lcov_record(state: _LcovRecordState) -> None: + state.current_path = None + state.lines_found = 0 + state.lines_hit = 0 + state.branches_found = 0 + state.branches_hit = 0 + + +def _flush_lcov_record( + files: dict[str, FileCoverage], state: _LcovRecordState +) -> None: + if state.current_path is not None: + files[state.current_path] = FileCoverage( + path=state.current_path, + lines_found=state.lines_found, + lines_hit=state.lines_hit, + branches_found=state.branches_found, + branches_hit=state.branches_hit, + ) + _reset_lcov_record(state) + + +def _count_lcov_line(state: _LcovRecordState, line: str) -> None: + parts = line[len("DA:") :].split(",") + if len(parts) >= 2: + hits = _parse_int(parts[1]) + if hits is not None: + state.lines_found += 1 + if hits > 0: + state.lines_hit += 1 + + +def _count_lcov_branch(state: _LcovRecordState, line: str) -> None: + parts = line[len("BRDA:") :].split(",") + if len(parts) >= 4: + taken = parts[3] + state.branches_found += 1 + # ``-`` means the branch was never reached at all (as opposed to + # reached-but-not-taken, which is recorded as "0"). + hits = _parse_int(taken) if taken != "-" else 0 + if hits: + state.branches_hit += 1 + + +def _consume_lcov_line(files: dict[str, FileCoverage], state: _LcovRecordState, line: str) -> None: + if line.startswith("SF:"): + # A new ``SF:`` without a preceding ``end_of_record`` would indicate + # a malformed tracefile; flush defensively so no data is dropped. + _flush_lcov_record(files, state) + state.current_path = line[len("SF:") :].strip() + elif line.startswith("DA:"): + _count_lcov_line(state, line) + elif line.startswith("BRDA:"): + _count_lcov_branch(state, line) + elif line == "end_of_record": + _flush_lcov_record(files, state) + + +def parse_lcov(text: str) -> dict[str, FileCoverage]: + """Parse LCOV tracefile text into per-file coverage, keyed by ``SF:`` path. + + Unknown or malformed record lines are ignored rather than raising, since a + tracefile may contain record types (``FN``, ``FNDA``, ``BRA``, ``VER``, ...) + this module does not need and different producers are not perfectly + consistent about optional summary lines. + """ + + files: dict[str, FileCoverage] = {} + state = _LcovRecordState() + + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + _consume_lcov_line(files, state, line) + # Tolerate a missing trailing ``end_of_record`` on the last block. + _flush_lcov_record(files, state) + return files + + +def _normalized(path: str) -> str: + return path.replace("\\", "/").lstrip("./") + + +def _matches_prefix(path: str, prefix: str) -> bool: + """Whether ``path`` is attributable to a component owning ``prefix``. + + LCOV ``SF:`` paths are sometimes repo-relative (``src/parser/reader.cpp``) + and sometimes absolute host/execroot paths that merely *contain* the + repo-relative directory as a suffix (e.g. under a Bazel sandbox root). + Both forms are matched so callers do not need to normalize the tracefile + themselves. + """ + + normalized_path = _normalized(path) + normalized_prefix = prefix.strip("/") + if not normalized_prefix: + return False + return ( + normalized_path == normalized_prefix + or normalized_path.startswith(f"{normalized_prefix}/") + or f"/{normalized_prefix}/" in normalized_path + ) + + +def assign_files_to_components( + files_by_path: Mapping[str, FileCoverage], + source_roots: Mapping[str, str], +) -> dict[str, ComponentCoverage]: + """Attribute every LCOV file record to at most one component. + + Components nest: a ``comp`` Need may ``consists_of`` further ``comp`` Needs, + and their source roots nest accordingly. A file below ``src/parser/detail`` + therefore matches both the child's root and its parent's ``src/parser``. The + most specific declared root wins, so every line is counted exactly once + across the report instead of being added to an ancestor as well. + + Components without a matching record are absent from the result, which lets + callers distinguish "no coverage data available" from "0% covered". + """ + + # Longest root first, so the most specific component claims a file. The + # component ID breaks ties to keep the attribution deterministic when two + # components declare equally specific roots. + ordered_roots = sorted( + ( + (component_id, root.strip("/")) + for component_id, root in source_roots.items() + if root.strip("/") + ), + key=lambda item: (-len(item[1]), item[0]), + ) + + claimed: dict[str, list[FileCoverage]] = {} + for _, coverage in sorted(files_by_path.items()): + for component_id, root in ordered_roots: + if _matches_prefix(coverage.path, root): + claimed.setdefault(component_id, []).append(coverage) + break + + return { + component_id: ComponentCoverage( + component_id=component_id, files=tuple(covered_files) + ) + for component_id, covered_files in claimed.items() + } diff --git a/src/extensions/score_coverage_report/tests/test_coverage_report.py b/src/extensions/score_coverage_report/tests/test_coverage_report.py new file mode 100644 index 000000000..6826c32e1 --- /dev/null +++ b/src/extensions/score_coverage_report/tests/test_coverage_report.py @@ -0,0 +1,371 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Unit tests for score_coverage_report's Sphinx wiring. + +These tests exercise the extension module directly against a fake ``app`` +(a plain namespace with just the attributes the module reads) rather than a +full Sphinx application, keeping them fast and independent of a Sphinx-Needs +build environment. + +``setup`` is the module's only public name; everything these white-box tests +reach for is deliberately private, so the private-usage rule is disabled for +the whole file instead of being silenced on each of the ~20 call sites. +Access stays qualified (``coverage_report._x``) because several of these names +are rebound by ``monkeypatch``, which only works on the module attribute. +""" + +# pyright: reportPrivateUsage=false + +import zipfile +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +import pytest +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment + +import src.extensions.score_coverage_report as coverage_report + + +def _fake_app(**config_values: object) -> Sphinx: + """A stand-in exposing only ``app.config``, which is all the module reads. + + Cast to ``Sphinx`` so call sites keep the production signature: building a + real application just to read two config values would make these tests + depend on a full Sphinx-Needs environment. + """ + return cast(Sphinx, SimpleNamespace(config=SimpleNamespace(**config_values))) + + +def test_resolve_lcov_path_prefers_env_var_over_conf_py( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SCORE_COVERAGE_LCOV", "/env/coverage.lcov") + app = _fake_app(score_coverage_lcov_path="conf_py_coverage.lcov") + + result = coverage_report._resolve_lcov_path(app) + + assert result == Path("/env/coverage.lcov") + + +def test_resolve_lcov_path_falls_back_to_conf_py( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path="conf_py_coverage.lcov") + + result = coverage_report._resolve_lcov_path(app) + + assert result == Path("conf_py_coverage.lcov") + + +def test_resolve_lcov_path_resolves_relative_path_against_workspace_root( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: tmp_path) + app = _fake_app(score_coverage_lcov_path="_build/coverage.lcov") + + result = coverage_report._resolve_lcov_path(app) + + assert result == tmp_path / "_build" / "coverage.lcov" + + +def test_resolve_lcov_path_defaults_to_bazel_combined_report( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: tmp_path) + app = _fake_app(score_coverage_lcov_path="") + + result = coverage_report._resolve_lcov_path(app) + + assert result == tmp_path / coverage_report.DEFAULT_LCOV_PATH + + +def test_resolve_lcov_path_prefers_coverage_report_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A workspace artifact directory wins over the bazel-out symlink.""" + coverage_dir = tmp_path / coverage_report.DEFAULT_COVERAGE_DIR / "lcov_report" + coverage_dir.mkdir(parents=True) + tracefile = coverage_dir / "lcov.dat" + tracefile.write_text("SF:src/logging/sink.cpp\nDA:1,1\nend_of_record\n") + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: tmp_path) + app = _fake_app(score_coverage_lcov_path="") + + assert coverage_report._resolve_lcov_path(app) == tracefile + + +def test_resolve_lcov_path_ignores_coverage_report_dir_without_tracefile( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + coverage_dir = tmp_path / coverage_report.DEFAULT_COVERAGE_DIR / "html_report" + coverage_dir.mkdir(parents=True) + (coverage_dir / "index.html").write_text("") + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: tmp_path) + app = _fake_app(score_coverage_lcov_path="") + + result = coverage_report._resolve_lcov_path(app) + + assert result == tmp_path / coverage_report.DEFAULT_LCOV_PATH + + +def test_resolve_lcov_path_falls_back_to_default_without_workspace_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path="") + + result = coverage_report._resolve_lcov_path(app) + + assert result == Path(coverage_report.DEFAULT_LCOV_PATH) + + +def test_load_coverage_returns_empty_dict_for_missing_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path=str(tmp_path / "does_not_exist.lcov")) + + assert coverage_report._load_coverage(app) == {} + + +def test_load_coverage_reads_tracefile_from_report_archive( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``bazel coverage`` report may be a ZIP despite its ``.dat`` name.""" + archive = tmp_path / "_coverage_report.dat" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("html_report/index.html", "") + zf.writestr( + coverage_report.ARCHIVE_LCOV_MEMBER, + "SF:src/logging/sink.cpp\nDA:1,1\nDA:2,0\nend_of_record\n", + ) + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path=str(archive)) + + files = coverage_report._load_coverage(app) + + assert files["src/logging/sink.cpp"].lines_found == 2 + assert files["src/logging/sink.cpp"].lines_hit == 1 + + +def test_load_coverage_returns_empty_dict_for_archive_without_tracefile( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + archive = tmp_path / "_coverage_report.dat" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("html_report/index.html", "") + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path=str(archive)) + + assert coverage_report._load_coverage(app) == {} + + +def test_load_coverage_parses_existing_lcov_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + lcov_file = tmp_path / "coverage.lcov" + lcov_file.write_text("SF:src/parser/reader.cpp\nDA:1,1\nend_of_record\n") + monkeypatch.delenv("SCORE_COVERAGE_LCOV", raising=False) + monkeypatch.setattr(coverage_report, "find_ws_root", lambda: None) + app = _fake_app(score_coverage_lcov_path=str(lcov_file)) + + files = coverage_report._load_coverage(app) + + assert "src/parser/reader.cpp" in files + assert files["src/parser/reader.cpp"].lines_found == 1 + + +def test_component_coverage_callable_returns_none_without_captured_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(coverage_report, "_build_environment", None) + monkeypatch.setattr(coverage_report, "_coverage_by_file", {}) + monkeypatch.setattr(coverage_report, "_coverage_by_component", None) + + assert coverage_report._component_coverage_callable("comp__example_parser") is None + + +def test_component_coverage_callable_returns_none_without_declared_source_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.extensions.score_coverage_report.lcov_parser import FileCoverage + + monkeypatch.setattr(coverage_report, "_build_environment", object()) + monkeypatch.setattr( + coverage_report, + "_coverage_by_file", + { + "src/parser/reader.cpp": FileCoverage( + path="src/parser/reader.cpp", lines_found=10, lines_hit=8 + ) + }, + ) + monkeypatch.setattr(coverage_report, "_coverage_by_component", None) + monkeypatch.setattr(coverage_report, "_component_source_roots", lambda env: {}) + + assert coverage_report._component_coverage_callable("comp__example_parser") is None + + +def test_component_coverage_callable_aggregates_declared_source_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.extensions.score_coverage_report.lcov_parser import FileCoverage + + monkeypatch.setattr(coverage_report, "_build_environment", object()) + monkeypatch.setattr( + coverage_report, + "_coverage_by_file", + { + "src/parser/reader.cpp": FileCoverage( + path="src/parser/reader.cpp", lines_found=10, lines_hit=8 + ) + }, + ) + monkeypatch.setattr(coverage_report, "_coverage_by_component", None) + monkeypatch.setattr( + coverage_report, + "_component_source_roots", + lambda env: {"comp__example_parser": "src/parser"}, + ) + + result = coverage_report._component_coverage_callable("comp__example_parser") + + assert result is not None + assert result.component_id == "comp__example_parser" + assert result.lines_found == 10 + assert result.lines_hit == 8 + + +def test_invalidate_component_coverage_forces_recomputation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(coverage_report, "_coverage_by_component", {"stale": object()}) + + coverage_report._invalidate_component_coverage( + _fake_app(), cast(BuildEnvironment, object()) + ) + + assert coverage_report._coverage_by_component is None + + +def test_parse_source_roots_reads_one_pair_per_line() -> None: + raw = "comp__example_parser: src/parser\ncomp__example_logging: src/logging" + + assert coverage_report._parse_source_roots(raw) == { + "comp__example_parser": "src/parser", + "comp__example_logging": "src/logging", + } + + +def test_parse_source_roots_tolerates_indentation_and_blank_lines() -> None: + raw = "\n comp__a: src/a\n\n comp__b : src/nested/deep \n" + + assert coverage_report._parse_source_roots(raw) == { + "comp__a": "src/a", + "comp__b": "src/nested/deep", + } + + +def test_parse_source_roots_skips_malformed_lines() -> None: + raw = "comp__a: src/a\nsrc/no_component_id\ncomp__b:\n: src/no_id" + + assert coverage_report._parse_source_roots(raw) == {"comp__a": "src/a"} + + +def _env_with_needs( + monkeypatch: pytest.MonkeyPatch, needs: dict[str, dict[str, str]] +) -> BuildEnvironment: + monkeypatch.setattr( + coverage_report, + "SphinxNeedsData", + lambda env: SimpleNamespace(get_needs_mutable=lambda: needs), + ) + return cast(BuildEnvironment, object()) + + +def test_component_source_roots_collects_from_mod_needs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + env = _env_with_needs( + monkeypatch, + { + "mod__example": { + "id": "mod__example", + "type": "mod", + "source_roots": ( + "comp__example_parser: src/parser\n" + "comp__example_logging: src/logging" + ), + }, + "comp__example_parser": {"id": "comp__example_parser", "type": "comp"}, + }, + ) + + assert coverage_report._component_source_roots(env) == { + "comp__example_parser": "src/parser", + "comp__example_logging": "src/logging", + } + + +def test_component_source_roots_ignores_non_mod_needs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + env = _env_with_needs( + monkeypatch, + { + "comp__example_parser": { + "id": "comp__example_parser", + "type": "comp", + "source_roots": "comp__example_parser: src/parser", + }, + "mod__empty": {"id": "mod__empty", "type": "mod"}, + }, + ) + + assert coverage_report._component_source_roots(env) == {} + + +def test_component_source_roots_merges_multiple_modules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + env = _env_with_needs( + monkeypatch, + { + "mod__a": { + "id": "mod__a", + "type": "mod", + "source_roots": "comp__example_parser: src/parser", + }, + "mod__b": { + "id": "mod__b", + "type": "mod", + "source_roots": "comp__example_logging: src/logging", + }, + }, + ) + + assert coverage_report._component_source_roots(env) == { + "comp__example_parser": "src/parser", + "comp__example_logging": "src/logging", + } diff --git a/src/extensions/score_coverage_report/tests/test_lcov_parser.py b/src/extensions/score_coverage_report/tests/test_lcov_parser.py new file mode 100644 index 000000000..ab6011580 --- /dev/null +++ b/src/extensions/score_coverage_report/tests/test_lcov_parser.py @@ -0,0 +1,194 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Unit tests for the LCOV parser and per-component aggregation.""" + +from src.extensions.score_coverage_report.lcov_parser import ( + FileCoverage, + assign_files_to_components, + parse_lcov, +) + +LCOV_TWO_FILES = """\ +TN: +SF:src/parser/reader.cpp +DA:1,1 +DA:2,0 +DA:3,3 +BRDA:2,0,0,1 +BRDA:2,0,1,0 +LF:3 +LH:2 +BRF:2 +BRH:1 +end_of_record +SF:src/logging/sink.cpp +DA:1,5 +DA:2,5 +LF:2 +LH:2 +end_of_record +""" + + +def test_parse_lcov_computes_line_and_branch_totals_from_records(): + files = parse_lcov(LCOV_TWO_FILES) + + assert set(files) == {"src/parser/reader.cpp", "src/logging/sink.cpp"} + + reader = files["src/parser/reader.cpp"] + assert reader.lines_found == 3 + assert reader.lines_hit == 2 + assert reader.branches_found == 2 + assert reader.branches_hit == 1 + assert reader.line_percent == 66.7 + assert reader.branch_percent == 50.0 + + sink = files["src/logging/sink.cpp"] + assert sink.lines_found == 2 + assert sink.lines_hit == 2 + assert sink.branches_found == 0 + assert sink.branches_hit == 0 + assert sink.line_percent == 100.0 + assert sink.branch_percent is None + + +def test_parse_lcov_ignores_unknown_record_types(): + text = """\ +SF:src/parser/reader.cpp +FN:1,foo +FNDA:4,foo +FNF:1 +FNH:1 +DA:1,4 +end_of_record +""" + files = parse_lcov(text) + assert files["src/parser/reader.cpp"].lines_found == 1 + assert files["src/parser/reader.cpp"].lines_hit == 1 + + +def test_parse_lcov_tolerates_missing_trailing_end_of_record(): + text = "SF:src/parser/reader.cpp\nDA:1,1\n" + files = parse_lcov(text) + assert files["src/parser/reader.cpp"].lines_found == 1 + + +def test_parse_lcov_empty_text_returns_no_files(): + assert parse_lcov("") == {} + + +def test_assign_files_to_components_matches_relative_prefix(): + files = parse_lcov(LCOV_TWO_FILES) + + result = assign_files_to_components(files, {"comp__example_parser": "src/parser"}) + + coverage = result["comp__example_parser"] + assert coverage.component_id == "comp__example_parser" + assert [f.path for f in coverage.files] == ["src/parser/reader.cpp"] + assert coverage.lines_found == 3 + assert coverage.lines_hit == 2 + assert coverage.line_percent == 66.7 + + +def test_assign_files_to_components_matches_prefix_embedded_in_absolute_path(): + sandbox_path = ( + "/root/.cache/bazel/exec/sandbox/1/execroot/_main/src/parser/reader.cpp" + ) + files = { + sandbox_path: FileCoverage(path=sandbox_path, lines_found=10, lines_hit=5), + } + + result = assign_files_to_components(files, {"comp__example_parser": "src/parser"}) + + assert result["comp__example_parser"].lines_found == 10 + assert result["comp__example_parser"].lines_hit == 5 + + +def test_assign_files_to_components_does_not_match_sibling_directory(): + # "src/parser" must not match "src/parserx/..." or similar look-alikes. + result = assign_files_to_components( + {"src/parserx/other.cpp": FileCoverage(path="src/parserx/other.cpp")}, + {"comp__example_parser": "src/parser"}, + ) + + assert result == {} + + +def test_assign_files_to_components_omits_components_without_matches(): + files = parse_lcov(LCOV_TWO_FILES) + + result = assign_files_to_components( + files, {"comp__example_missing": "src/does_not_exist"} + ) + + assert "comp__example_missing" not in result + + +def test_assign_files_to_components_sums_multiple_files(): + all_files = dict(parse_lcov(LCOV_TWO_FILES)) + all_files["src/parser/writer.cpp"] = FileCoverage( + path="src/parser/writer.cpp", lines_found=4, lines_hit=4 + ) + + result = assign_files_to_components( + all_files, {"comp__example_parser": "src/parser"} + ) + + coverage = result["comp__example_parser"] + assert sorted(f.path for f in coverage.files) == [ + "src/parser/reader.cpp", + "src/parser/writer.cpp", + ] + assert coverage.lines_found == 7 + assert coverage.lines_hit == 6 + + +def test_assign_files_to_components_gives_nested_file_to_most_specific_component(): + # comp__example_parser "consists_of" comp__example_parser_detail, so their + # source roots nest. The child must claim its own file exclusively, or the + # parent would count the same lines a second time. + files = { + "src/parser/reader.cpp": FileCoverage( + path="src/parser/reader.cpp", lines_found=10, lines_hit=10 + ), + "src/parser/detail/adapter.cpp": FileCoverage( + path="src/parser/detail/adapter.cpp", + lines_found=4, + lines_hit=1, + ), + } + + result = assign_files_to_components( + files, + { + "comp__example_parser": "src/parser", + "comp__example_parser_detail": "src/parser/detail", + }, + ) + + assert [f.path for f in result["comp__example_parser"].files] == [ + "src/parser/reader.cpp" + ] + assert result["comp__example_parser"].lines_found == 10 + assert [f.path for f in result["comp__example_parser_detail"].files] == [ + "src/parser/detail/adapter.cpp" + ] + assert result["comp__example_parser_detail"].lines_found == 4 + + +def test_assign_files_to_components_ignores_components_without_source_root(): + files = parse_lcov(LCOV_TWO_FILES) + + result = assign_files_to_components(files, {"comp__example_no_root": ""}) + + assert result == {} diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index ed60080bc..25ba0b40b 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -538,6 +538,17 @@ needs_types: safety: ^(QM|ASIL_B)$ # req-Id: tool_req__docs_common_attr_status status: ^(valid|invalid)$ + # Maps included components to the repository-relative root directory of + # their implementation, written as one ``: `` pair per line: + # + # :source_roots: + # comp__example_parser: src/parser + # comp__example_logging: src/logging + # + # Coverage data is attributed to the component that declares the most + # specific matching root, so nested components each report their own code. + # Components without an entry report no coverage. + source_roots: ^[ \t]*[A-Za-z0-9_]+[ \t]*:[ \t]*[A-Za-z0-9_][A-Za-z0-9_-]*(/[A-Za-z0-9_][A-Za-z0-9_-]*)*[ \t]*(\n[ \t]*[A-Za-z0-9_]+[ \t]*:[ \t]*[A-Za-z0-9_][A-Za-z0-9_-]*(/[A-Za-z0-9_][A-Za-z0-9_-]*)*[ \t]*)*\n?$ mandatory_links: # req-Id: tool_req__arch_linkage_safety includes: comp diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 3230c6124..afb30445d 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -33,6 +33,7 @@ py_library( "@score_docs_as_code//src/extensions/score_metamodel", "@score_docs_as_code//src/extensions/score_mounts", "@score_docs_as_code//src/extensions/score_source_code_linker", + "@score_docs_as_code//src/extensions/score_coverage_report", "@score_docs_as_code//src/extensions/score_metrics", "@score_docs_as_code//src/extensions/score_sync_toml", "@score_docs_as_code//src/helper_lib", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index 1628647fc..d4c054f87 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -33,6 +33,7 @@ "sphinx_mounts", "score_mounts", "score_source_code_linker", + "score_coverage_report", "score_draw_uml_funcs", "score_layout", "sphinx_collections", diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 64bbbcea8..1692299f9 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -305,17 +305,68 @@ verification status and the tests that (fully or partially) verify them: Test Coverage ^^^^^^^^^^^^^ -Per-source-file line and branch coverage aggregated from the LCOV report -produced by ``bazel coverage``. +{# The coverage data is resolved through the Need graph by the + score_coverage_report extension, so this section stays a pure rendering of + structured values instead of pre-formatted text. #} +{% set coverage = component_coverage(component_id) %} +{% if coverage %} +Per-source-file line and branch coverage of this component, aggregated from the +LCOV tracefile produced by ``bazel coverage``. + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Line Coverage + :labels: covered, not covered + :colors: #37a12d, #ca2828 + :legend: + + {{ coverage.lines_hit }} + {{ coverage.lines_found - coverage.lines_hit }} + + .. grid-item:: + + .. needpie:: {{ component_title }} Branch Coverage + :labels: covered, not covered + :colors: #37a12d, #ca2828 + :legend: + + {{ coverage.branches_hit }} + {{ coverage.branches_found - coverage.branches_hit }} .. dropdown:: Show test coverage table :animate: fade-in - .. note:: - - No coverage data available for this component. Run ``bazel coverage`` - with the corresponding targets and rebuild the docs to populate this - table. + .. list-table:: + :header-rows: 1 + :widths: 52 12 12 12 12 + :class: longtable + + * - Source file + - Lines + - Line % + - Branches + - Branch % +{%- for file in coverage.files %} + * - ``{{ file.path }}`` + - {{ file.lines_hit }} / {{ file.lines_found }} + - {% if file.line_percent is none %}n/a{% else %}{{ file.line_percent }} %{% endif %} + - {{ file.branches_hit }} / {{ file.branches_found }} + - {% if file.branch_percent is none %}n/a{% else %}{{ file.branch_percent }} %{% endif %} +{%- endfor %} + * - **Total** + - {{ coverage.lines_hit }} / {{ coverage.lines_found }} + - {% if coverage.line_percent is none %}n/a{% else %}**{{ coverage.line_percent }} %**{% endif %} + - {{ coverage.branches_hit }} / {{ coverage.branches_found }} + - {% if coverage.branch_percent is none %}n/a{% else %}**{{ coverage.branch_percent }} %**{% endif %} +{% else %} +.. note:: + + No coverage data available for this component. Run ``bazel coverage`` with + the corresponding targets and rebuild the docs to populate this section. +{% endif %} Architectural Elements ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json index 346aeed66..faeca600d 100644 --- a/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json @@ -1126,6 +1126,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json index d5da28295..88711f3d6 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json @@ -1126,6 +1126,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json index eece8f468..65cd62ec8 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json @@ -1126,6 +1126,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json index fb6acfd31..0c1251f64 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json @@ -1126,6 +1126,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json index f214d4128..b867d452c 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json @@ -1143,6 +1143,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json index 1a3ed00e2..287e59e8e 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json @@ -1126,6 +1126,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json index 3bc37bab0..430ca881a 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json @@ -1143,6 +1143,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json index 16ea98a94..02675254c 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json @@ -1143,6 +1143,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json index 43eabf09e..accd2c523 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json @@ -1226,6 +1226,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json index 8bdb206d2..5c265b86f 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json @@ -1142,6 +1142,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json index fb4ecca51..1ace0195e 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json @@ -1170,6 +1170,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json index 09e3ff71a..bf4171daf 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json @@ -1172,6 +1172,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues", diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json index 6a304b688..077812fcf 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json @@ -1157,6 +1157,15 @@ "null" ] }, + "source_roots": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "specific": { "default": null, "description": "Added by service github-issues",