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",