diff --git a/docs.bzl b/docs.bzl index eafe15481..284e09f04 100644 --- a/docs.bzl +++ b/docs.bzl @@ -196,6 +196,7 @@ def docs( scan_code = [], code_targets = [], test_sources = [], + testlink_source = "xml", known_good = None, metamodel = None, bundles = [], @@ -218,6 +219,12 @@ def docs( expand to their files. test_sources: Optional list of repo-relative directory paths which will be used to filter testcases for documentation generation. When empty (default), all testcases found in `bazel-testlogs` will be used. + testlink_source: Where test links come from. "xml" (default) scans + `bazel-testlogs/**/test.xml`, "lobster" reads the `*.lobster` + activity pools emitted by lobster-gtest, "none" disables test + links. Note that "lobster" carries no failure messages and + reports skipped tests as passed, and that `test_sources` has + no effect in that mode. known_good: Optional label to a "known good" JSON file for source links. metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this file instead of the default metamodel shipped with score_metamodel. @@ -328,6 +335,7 @@ def docs( "SOURCE_DIRECTORY": source_dir, "PACKAGE_DIR": native.package_name(), "TEST_SOURCES": str(test_sources), + "TESTLINK_SOURCE": testlink_source, "DATA": str(data), "EXTERNAL_NEEDS_FILES": str(external_needs), # `bazel run` starts from a runfiles tree, so this logical path is diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d3acbc6ac..97beb2b9f 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -96,6 +96,27 @@ Minimal example (root ``BUILD``) Explicit source files or filegroups to scan. Use ``code_targets`` for implementation targets; it follows their dependencies automatically. +- ``testlink_source`` (string, optional, default ``"xml"``) + Selects where the ``score_source_code_linker`` takes test links from. + + ``"xml"`` + Scan ``bazel-testlogs/**/test.xml``. Requires the tests to have been run + via ``bazel test``; a ``cc_test`` that is only consumed as a dependency of + ``unit()``/``component()``/``dependable_element()`` does not produce such a + file. + + ``"lobster"`` + Read the ``*.lobster`` activity pools that ``lobster-gtest`` emits below + ``bazel-bin``. These are produced by every build of the corresponding + traceability target, so no separate test run is required. Two limitations + apply: ``lobster-gtest`` discards the ```` message, so failed + tests carry no result text, and it reports skipped tests as passed. + ``test_sources`` has no effect in this mode. + + ``"none"`` + Do not create testcase needs at all. Source code links + (``req-Id:`` annotations) are unaffected and keep working. + - ``external_needs`` (list of bazel labels) External ``:needs_json_file`` targets from other modules/repositories for referencing their needs. diff --git a/src/BUILD b/src/BUILD index aa9885ef8..e8e9ab5ae 100644 --- a/src/BUILD +++ b/src/BUILD @@ -52,6 +52,7 @@ filegroup( "//src/extensions/score_metamodel:all_sources", "//src/extensions/score_mounts:all_sources", "//src/extensions/score_source_code_linker:all_sources", + "//src/extensions/score_lobster_report:all_sources", "//src/extensions/score_sphinx_bundle:all_sources", "//src/extensions/score_sync_toml:all_sources", "//src/extensions/score_metrics:all_sources", diff --git a/src/extensions/score_lobster_report/BUILD b/src/extensions/score_lobster_report/BUILD new file mode 100644 index 000000000..6d79b59ca --- /dev/null +++ b/src/extensions/score_lobster_report/BUILD @@ -0,0 +1,31 @@ +# ******************************************************************************* +# 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("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") + +filegroup( + name = "all_sources", + srcs = glob(["*.py"]), + visibility = ["//visibility:public"], +) + +py_library( + name = "score_lobster_report", + srcs = [":all_sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = all_requirements + [ + "@score_docs_as_code//src/helper_lib", + "@score_docs_as_code//src/extensions/score_source_code_linker", + ], +) diff --git a/src/extensions/score_lobster_report/__init__.py b/src/extensions/score_lobster_report/__init__.py new file mode 100644 index 000000000..753572f7d --- /dev/null +++ b/src/extensions/score_lobster_report/__init__.py @@ -0,0 +1,46 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Renders a pre-built Lobster traceability report (JSON, as produced by the +rules_score ``component()``/``dependable_element()`` Bazel macros) as a table +directly inside a Sphinx page, via the ``.. lobster-traceability-report::`` +directive. + +Unlike ``score_source_code_linker`` (which turns ``bazel-testlogs/**/test.xml`` +into sphinx-needs at build time), this extension reads a fixed, already-built +JSON file straight off disk when the directive runs. That keeps the Bazel +target that actually produces the JSON (e.g. a ``component()``/ +``dependable_element()`` instance, which is ``testonly`` because it depends on +``cc_test`` targets) out of the (non-testonly) ``docs()`` Bazel dependency +graph entirely - the docs build never needs a Bazel-level dependency on it, +only the file to exist on disk, exactly like the bazel-testlogs scan does for +test results. +""" + +from sphinx.application import Sphinx + +from src.extensions.score_lobster_report.directive import ( + LobsterTraceabilityReportDirective, +) + + +def setup(app: Sphinx) -> dict[str, object]: + app.add_directive( + "lobster-traceability-report", LobsterTraceabilityReportDirective + ) + + return { + "version": "1.0.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_lobster_report/directive.py b/src/extensions/score_lobster_report/directive.py new file mode 100644 index 000000000..c7ef8cca5 --- /dev/null +++ b/src/extensions/score_lobster_report/directive.py @@ -0,0 +1,800 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Directive that renders a Lobster traceability JSON report as a table.""" + +import hashlib +import json +import math +from html import escape +from pathlib import Path +from typing import Any + +from docutils import nodes +from sphinx.util.docutils import SphinxDirective + +from src.extensions.score_source_code_linker.xml_parser import short_hash +from src.helper_lib import find_ws_root + +# Colours for the coverage pie charts: "covered" green, "not covered" red. +# Chosen to stay legible on both the light and the dark theme variant. +_COVERED_COLOR = "#2e8b57" +_UNCOVERED_COLOR = "#c0392b" + +# Generically-rendered pools that additionally get an explanatory summary +# sentence and a coverage pie chart, mapped to (sentence, chart title). +# Coverage is "item has at least one ``ref_up``", i.e. it traces up to the +# pool above it. +_PIE_INFO = { + "Architecture": ( + "{covered} / {total} architecture component(s) are covered by at " + "least one component requirement.", + "Components covered by at least one component requirement", + ), +} + + +def _pie_chart(title: str, segments: list[tuple[str, int, str]]) -> list[nodes.Node]: + """A small inline-SVG pie chart with a legend, rendered as raw HTML. + + ``segments`` is a list of ``(label, value, colour)``. Everything is + emitted as a self-contained ```` so no plotting library, image + file or build-time asset generation is needed; non-HTML writers simply + drop the raw node. + """ + total = sum(value for _label, value, _colour in segments) + if total <= 0: + return [] + + size = 160 + radius = size / 2 + center = size / 2 + + paths: list[str] = [] + angle = -math.pi / 2 # start at 12 o'clock instead of 3 o'clock + for label, value, colour in segments: + if value <= 0: + continue + sweep = 2 * math.pi * value / total + if value == total: + # A full-circle arc would start and end on the same point and + # therefore collapse to nothing; draw a plain circle instead. + paths.append( + f'{escape(label)}: {value} ' + f"(100%)" + ) + continue + end = angle + sweep + x0, y0 = center + radius * math.cos(angle), center + radius * math.sin(angle) + x1, y1 = center + radius * math.cos(end), center + radius * math.sin(end) + large_arc = 1 if sweep > math.pi else 0 + paths.append( + f'{escape(label)}: {value} ' + f"({100 * value / total:.1f}%)" + ) + angle = end + + legend = "".join( + f'
  • ' + f'' + f"{escape(label)}: {value} ({100 * value / total:.1f}%)
  • " + for label, value, colour in segments + ) + + html = ( + '
    ' + f'' + f"{escape(title)}{''.join(paths)}" + f'' + "
    " + ) + return [nodes.raw("", html, format="html")] + + +def _coverage_pie(title: str, covered: int, total: int) -> list[nodes.Node]: + return _pie_chart( + title, + [ + ("covered", covered, _COVERED_COLOR), + ("not covered", total - covered, _UNCOVERED_COLOR), + ], + ) + + +def _row(cells: list[nodes.Node | str]) -> nodes.row: + row = nodes.row() + for cell in cells: + entry = nodes.entry() + if isinstance(cell, nodes.Node): + entry += cell + else: + entry += nodes.paragraph(text=str(cell)) + row += entry + return row + + +def _table(header: list[str], rows: list[list[nodes.Node | str]]) -> nodes.table: + table = nodes.table() + tgroup = nodes.tgroup(cols=len(header)) + table += tgroup + for _ in header: + tgroup += nodes.colspec(colwidth=1) + thead = nodes.thead() + tgroup += thead + thead += _row(list(header)) + tbody = nodes.tbody() + tgroup += tbody + for row in rows: + tbody += _row(row) + return table + + +def _foldable(summary: str, body: nodes.Node) -> list[nodes.Node]: + """Wrap ``body`` (typically a table) in a native HTML ``
    `` + element so long tables can be collapsed away. + + Implemented with raw HTML rather than e.g. sphinx-design's ``dropdown`` + because ``
    ``/```` needs no extra CSS or JavaScript and + keeps the wrapped node a plain docutils table -- non-HTML writers just + drop the two raw nodes and still render the table. + + The element starts collapsed, so the page opens as a compact overview of + section headings, summaries and pie charts. Because the report + cross-links its own rows via ``#lobster-item-...`` anchors, and not every + browser expands a collapsed ``
    `` when the fragment target lives + inside it, ``_ANCHOR_SCRIPT`` re-opens the relevant ones on navigation. + """ + return [ + nodes.raw( + "", + '
    ' + '{escape(summary)}', + format="html", + ), + body, + nodes.raw("", "
    ", format="html"), + ] + + +# Opens every collapsed
    on the way to the element the current URL +# fragment points at, then scrolls it into view -- without this, following an +# in-report "#lobster-item-..." link would jump to a row hidden inside a +# folded table. Emitted once per directive instance. +_ANCHOR_SCRIPT = """""" + + +class LobsterTraceabilityReportDirective(SphinxDirective): + """Render a Lobster traceability report as one section per pool + ("level") found in the JSON: Feature Requirements, Component + Requirements, Unit Test, Architecture, Public API, Failure Modes, + Control Measures, Root Causes, etc. The requirement chain is rendered + first and top-down as two cross-linked tables (using ``:need:`` + references): Feature Requirements against the Component Requirements + refining them, then Component Requirements against the Unit Tests + verifying them. Every other pool follows, rendered generically + straight from the JSON (name/text/references/status), with no + special-casing per pool name. + + Usage:: + + .. lobster-traceability-report:: + bazel-bin/score/bitmanipulation/dependable_element_bitmanipulation_index_report.json + + The path is resolved relative to the workspace root, the same way + ``score_source_code_linker`` locates ``bazel-testlogs``. The referenced + report is a plain build artifact and must already exist, e.g. via:: + + bazel build //score/bitmanipulation:dependable_element_bitmanipulation + + All information rendered here comes exclusively from that JSON file + (never from any separately-generated Lobster HTML report). + """ + + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + has_content = False + + def run(self) -> list[nodes.Node]: + rel_path = self.arguments[0].strip() + ws_root = find_ws_root() + if ws_root is None: + return [self._warning( + "Could not determine the workspace root; skipping the " + "Lobster traceability report." + )] + + report_path = ws_root / rel_path + if not report_path.exists(): + return [self._warning( + f"Lobster report not found at '{rel_path}' (resolved to " + f"'{report_path}'). Build it first, e.g. `bazel build " + "//score/bitmanipulation:dependable_element_bitmanipulation`, " + "then re-run the docs build." + )] + + try: + report = json.loads(report_path.read_text()) + except (json.JSONDecodeError, OSError) as exc: + return [self._warning( + f"Failed to read Lobster report '{rel_path}': {exc}" + )] + + return self._build_nodes(report) + + def _warning(self, text: str) -> nodes.warning: + return nodes.warning("", nodes.paragraph(text=text)) + + def _need_ref(self, need_id: str, display: str | None = None) -> list[nodes.Node]: + """Parse a ``:need:```` role, so it resolves (at doctree- + resolved time) into a real link to that need's directive/element, + exactly like a hand-written ``:need:`` reference elsewhere in the + docs. + + ``display``, if given, is rendered as explicit link text (RST's + ``:need:`text ``` syntax) instead of the default, which + would otherwise show ``need_id`` itself. Useful when ``need_id`` is + a generated, machine-friendly id (e.g. ``testcase__Foo__Bar_a1b2c"``) + whose repeated double underscores read as stacked lines once the + browser's own link-underline decoration overlaps them - passing the + original, human-authored name instead avoids that. + """ + role_text = f"{display} <{need_id}>" if display else need_id + text_nodes, _messages = self.state.inline_text( + f":need:`{role_text}`", self.lineno + ) + return list(text_nodes) + + @staticmethod + def _strip_version(tag: str) -> str: + # Some pools reference an item's tag with its "@" suffix + # (e.g. "req Foo.bar@1"), others reference the same item unversioned + # ("req Foo.bar"). Normalize away the suffix so both spellings hit + # the same index entry. + base, sep, version = tag.rpartition("@") + return base if sep and version.isdigit() else tag + + @staticmethod + def _strip_kind_prefix(tag: str) -> str: + # A tag is " " (e.g. "req Foo.bar", "gtest + # Foo:bar", "arch //foo:bar", "fta Foo.bar"); drop the kind for a + # more readable fallback label. + _kind, sep, rest = tag.partition(" ") + return rest if sep else tag + + @staticmethod + def _anchor_id(tag: str) -> str: + # A stable, docutils-safe anchor id for cross-linking items *within* + # this report, independent of any "real" Sphinx-needs id -- mirrors + # the "lobster-item-" anchors dependable_element's own + # traceability_report already generates. + return "lobster-item-" + hashlib.sha1(tag.encode("utf-8")).hexdigest() + + def _build_tag_index( + self, levels_list: list[dict[str, Any]] + ) -> dict[str, tuple[str, str]]: + """Map every item's tag (and its version-stripped spelling) to the + anchor id it will be given and its display name, across *all* pools + -- so any item's ``refs``/``ref_up``/``ref_down`` entry can be + turned into a clickable link to wherever that item is rendered on + this same page, regardless of which pool defines it or which pool + references it.""" + index: dict[str, tuple[str, str]] = {} + for level in levels_list: + for item in level.get("items", []): + tag = item.get("tag") or item.get("name") or "" + if not tag: + continue + display = item.get("name") or tag + entry = (self._anchor_id(tag), display) + index[tag] = entry + index.setdefault(self._strip_version(tag), entry) + return index + + def _anchor_target(self, tag: str) -> nodes.target: + """An invisible anchor at an item's own row/cell, so other items' + references can link to it.""" + anchor_id, _display = self._tag_index.get(tag, (self._anchor_id(tag), "")) + target = nodes.target(ids=[anchor_id]) + self.state.document.note_explicit_target(target, target) + return target + + def _ref_link(self, ref_tag: str) -> nodes.Node: + """Turn one raw ``refs``/``ref_up``/``ref_down`` tag into a clickable + internal link to that item's anchor, falling back to plain text if + the referenced item isn't part of this report.""" + entry = self._tag_index.get(ref_tag) or self._tag_index.get( + self._strip_version(ref_tag) + ) + if entry is None: + return nodes.Text(self._strip_kind_prefix(ref_tag)) + anchor_id, display = entry + return nodes.reference("", "", nodes.Text(display), refid=anchor_id, internal=True) + + # Requirement-kind ids that are actually registered as real Sphinx-needs + # elsewhere in the docs (unlike e.g. the FailureMode/ControlMeasure/ + # Interface TRLC records, which only exist for this Lobster report and + # have no matching need). + _REQ_ID_PREFIXES = ( + "feat_req__", + "comp_req__", + "aou_req__", + "tool_req__", + "stkh_req__", + ) + + @classmethod + def _generic_req_need_id(cls, item: dict[str, Any]) -> str | None: + """If a generically-rendered pool's item is itself a genuine + requirement (feature, component, AoU, tool or stakeholder + requirement), return its need id so it can be linked with + ``:need:``, same as Component Requirements already are. Returns + ``None`` for items with no matching real need (e.g. Failure Modes, + Control Measures, Root Causes, Architecture, Public API).""" + tag = item.get("tag") or "" + if not tag.startswith("req "): + return None + name = item.get("name", "") + need_id = name.split(".", 1)[-1] if "." in name else name + return need_id if need_id.startswith(cls._REQ_ID_PREFIXES) else None + + @staticmethod + def _req_need_id(req: dict[str, Any]) -> str: + # A requirement's TRLC-qualified "name" is ".", but the + # ".. comp_req::"/".. feat_req::" directive registers the need under + # just "". + name = req.get("name", "") + return name.split(".", 1)[-1] if "." in name else name + + @staticmethod + def _unit_test_need_id(test: dict[str, Any]) -> str: + # score_source_code_linker builds gtest need ids from the JUnit + # "classname"/"name" pair joined with "__" (e.g. + # "HalfByte__CanBeConstructedFromUInt8"), whereas the Lobster report + # renders the same test as "HalfByte:CanBeConstructedFromUInt8". + # Reconstruct the same id: testcase_____. + suite, sep, case = test.get("name", "").partition(":") + xml_style_name = f"{suite}__{case}" if sep else suite + file = test.get("location", {}).get("file", "") or "" + return f"testcase__{xml_style_name}_{short_hash(file + xml_style_name)}" + + def _section(self, title: str) -> nodes.section: + """A docutils section with a title, wired up like a normal RST + heading (implicit target registered so it gets an id / shows up in + the local table of contents), so every pool actually renders as its + own section rather than a flat, unheaded dump.""" + section = nodes.section(ids=[nodes.make_id(title)]) + section += nodes.title(text=title) + self.state.document.note_implicit_target(section, section) + return section + + @staticmethod + def _req_key(name: str) -> str: + # A requirement's own "tag" carries a "@" suffix (e.g. + # "req Foo.bar@1"), but items below it reference it unversioned in + # their "ref_up" (e.g. "req Foo.bar"). Match on the unversioned + # requirement name so links are found reliably across versions. + return f"req {name}" + + def _feat_req_comp_req_section( + self, feat_req_level: dict[str, Any], comp_req_level: dict[str, Any] + ) -> nodes.section: + feat_reqs = feat_req_level.get("items", []) + comp_reqs = comp_req_level.get("items", []) + + # Group component requirements by the (unversioned) feature + # requirement name they trace up to. + comp_reqs_by_feat: dict[str, list[dict[str, Any]]] = {} + for comp_req in comp_reqs: + for ref in comp_req.get("ref_up") or [""]: + comp_reqs_by_feat.setdefault(ref.split("@")[0], []).append(comp_req) + + rows: list[list[nodes.Node | str]] = [] + for feat_req in sorted(feat_reqs, key=lambda r: r.get("name", "")): + linked = sorted( + comp_reqs_by_feat.get(self._req_key(feat_req.get("name", "")), []), + key=lambda r: r.get("name", ""), + ) + + feat_cell = nodes.paragraph() + feat_cell += self._anchor_target( + feat_req.get("tag") or feat_req.get("name") or "" + ) + feat_cell += self._need_ref(self._req_need_id(feat_req)) + feat_cell += nodes.Text(": " + feat_req.get("text", "")) + + if linked: + comp_req_list = nodes.bullet_list() + for comp_req in linked: + item = nodes.list_item() + para = nodes.paragraph() + para += self._need_ref(self._req_need_id(comp_req)) + para += nodes.Text(": " + comp_req.get("text", "")) + item += para + comp_req_list += item + comp_cell: nodes.Node = comp_req_list + else: + comp_cell = nodes.paragraph(text="(no linked component requirements)") + + rows.append([feat_cell, comp_cell, feat_req.get("tracing_status", "")]) + + total_feat_reqs = len(feat_reqs) + covered_feat_reqs = sum( + 1 + for r in feat_reqs + if comp_reqs_by_feat.get(self._req_key(r.get("name", ""))) + ) + summary = nodes.paragraph() + summary += nodes.Text( + f"{covered_feat_reqs} / {total_feat_reqs} feature requirements are " + "refined by at least one component requirement." + ) + + section = self._section("Feature Requirements \u2194 Component Requirements") + section += summary + section.extend( + _coverage_pie( + "Feature requirements refined by component requirements", + covered_feat_reqs, + total_feat_reqs, + ) + ) + section.extend( + _foldable( + f"Show {len(rows)} feature requirement(s)", + _table( + ["Feature Requirement", "Linked Component Requirements", "Status"], + rows, + ), + ) + ) + return section + + def _comp_req_unit_test_section( + self, comp_req_level: dict[str, Any], unit_test_level: dict[str, Any] + ) -> nodes.section: + comp_reqs = comp_req_level.get("items", []) + unit_tests = unit_test_level.get("items", []) + + # Group unit tests by the (unversioned) requirement name they trace + # up to. + tests_by_req: dict[str, list[dict[str, Any]]] = {} + for test in unit_tests: + for ref in test.get("ref_up") or [""]: + tests_by_req.setdefault(ref.split("@")[0], []).append(test) + + rows: list[list[nodes.Node | str]] = [] + for req in sorted(comp_reqs, key=lambda r: r.get("name", "")): + tests = sorted( + tests_by_req.get(self._req_key(req.get("name", "")), []), + key=lambda t: t.get("name", ""), + ) + + req_cell = nodes.paragraph() + req_cell += self._anchor_target(req.get("tag") or req.get("name") or "") + req_cell += self._need_ref(self._req_need_id(req)) + req_cell += nodes.Text(": " + req.get("text", "")) + + if tests: + test_list = nodes.bullet_list() + for test in tests: + item = nodes.list_item() + para = nodes.paragraph() + para += self._anchor_target( + test.get("tag") or test.get("name") or "" + ) + para += self._need_ref( + self._unit_test_need_id(test), display=test.get("name") + ) + para += nodes.Text(f" [{test.get('status', '')}]") + item += para + test_list += item + test_cell: nodes.Node = test_list + else: + test_cell = nodes.paragraph(text="(no linked tests)") + + rows.append([req_cell, test_cell, req.get("tracing_status", "")]) + + total_tests = len(unit_tests) + linked_tests = sum(1 for t in unit_tests if t.get("ref_up")) + total_comp_reqs = len(comp_reqs) + covered_comp_reqs = sum( + 1 for r in comp_reqs if tests_by_req.get(self._req_key(r.get("name", ""))) + ) + summary = nodes.paragraph() + summary += nodes.Text( + f"{covered_comp_reqs} / {total_comp_reqs} component requirements are " + f"verified by at least one unit test; {linked_tests} / {total_tests} " + "unit tests are linked to a component requirement." + ) + + section = self._section("Component Requirements \u2194 Unit Tests") + section += summary + section.extend( + _coverage_pie( + "Component requirements verified by unit tests", + covered_comp_reqs, + total_comp_reqs, + ) + ) + section.extend( + _foldable( + f"Show {len(rows)} component requirement(s)", + _table(["Requirement", "Linked Unit Tests", "Status"], rows), + ) + ) + return section + + @staticmethod + def _item_references(item: dict[str, Any]) -> list[str]: + # Different pools use different reference field names in the JSON + # ("refs" for e.g. FailureMode->Architecture, "ref_up"/"ref_down" + # for pools chained via lobster's own up/down tracing). Collect + # whichever is present. + refs: list[str] = [] + for field in ("refs", "ref_up", "ref_down"): + refs.extend(item.get(field) or []) + return refs + + def _generic_level_section( + self, level: dict[str, Any], pie_info: tuple[str, str] | None = None + ) -> nodes.section: + """Render a pool generically, straight from its JSON items, with no + pool-specific knowledge -- this is what makes Architecture and any + further pool (e.g. Forwarded AoUs) show up automatically, without + needing a hand-written case for each one. + + ``pie_info``, if given, is a (summary sentence, chart title) pair + that replaces the bare item count with a spelled-out coverage + statement plus a covered/not-covered pie chart. An item counts as + covered when it traces up to at least one item of the pool above it + (``ref_up``). + """ + name = level.get("name", "") + items = level.get("items", []) + coverage = level.get("coverage") + + section = self._section(name) + covered = sum(1 for item in items if item.get("ref_up")) + + summary = nodes.paragraph() + if pie_info: + summary += nodes.Text( + pie_info[0].format(covered=covered, total=len(items)) + ) + else: + summary_text = f"{len(items)} item(s)" + if coverage is not None: + summary_text += f", {coverage:.1f}% coverage" + summary += nodes.Text(summary_text) + section += summary + + if not items: + section += nodes.paragraph(text="(no items)") + return section + + if pie_info: + section.extend(_coverage_pie(pie_info[1], covered, len(items))) + + rows: list[list[nodes.Node | str]] = [] + for item in sorted(items, key=lambda i: i.get("name") or i.get("tag") or ""): + name_cell = nodes.paragraph() + name_cell += self._anchor_target(item.get("tag") or item.get("name") or "") + need_id = self._generic_req_need_id(item) + if need_id: + name_cell += self._need_ref(need_id) + else: + name_cell += nodes.Text(item.get("name") or item.get("tag", "")) + text_cell = nodes.paragraph(text=item.get("text") or "") + refs = self._item_references(item) + refs_cell = nodes.paragraph() + if refs: + for index, ref in enumerate(refs): + if index: + refs_cell += nodes.Text(", ") + refs_cell += self._ref_link(ref) + else: + refs_cell += nodes.Text("-") + status_cell = item.get("tracing_status") or item.get("status") or "" + rows.append([name_cell, text_cell, refs_cell, status_cell]) + + section.extend( + _foldable( + f"Show {len(rows)} item(s)", + _table(["Item", "Text", "References", "Status"], rows), + ) + ) + return section + + # The four pools produced by the FMEA/FTA chain. They are rendered as a + # single joined table instead of four separate ones, because on their own + # each is just a list of names whose meaning only emerges from the chain: + # a fault tree's basic event (root cause) is mitigated by a control + # measure and leads, via the tree's top event, to a failure mode of a + # public API interface. + _SAFETY_LEVEL_NAMES = ( + "Public API", + "Failure Modes", + "Control Measures", + "Root Causes", + ) + + def _safety_analysis_section( + self, levels_by_name: dict[str, dict[str, Any]] + ) -> nodes.section: + by_tag: dict[str, dict[str, Any]] = {} + for level_name in self._SAFETY_LEVEL_NAMES: + for item in levels_by_name[level_name].get("items", []): + tag = item.get("tag") or "" + by_tag[tag] = item + by_tag.setdefault(self._strip_version(tag), item) + + root_causes = levels_by_name["Root Causes"].get("items", []) + + # A fault tree lives in one .puml file and contains exactly one top + # event (the failure mode it analyses) plus the basic events (the + # root causes) leading to it, so the file is what links a root cause + # to its failure mode. + top_event_by_file: dict[str, dict[str, Any]] = {} + for item in root_causes: + if item.get("kind") == "TopEvent": + top_event_by_file[item.get("location", {}).get("file", "")] = item + + spelled_out: set[str] = set() + + def cell(tag: str | None) -> nodes.paragraph: + """Render one referenced item: spelled out in full (name plus + descriptive text) the first time it appears, and as the bare + name on every repeat -- so e.g. the one public API interface + shared by all rows is described once, not four times.""" + para = nodes.paragraph() + item = by_tag.get(tag or "") or by_tag.get(self._strip_version(tag or "")) + if item is None: + para += nodes.Text("-") + return para + item_tag = item.get("tag") or "" + display = (item.get("name") or item_tag).rsplit(".", 1)[-1] + if item_tag in spelled_out: + para += nodes.Text(display) + return para + spelled_out.add(item_tag) + para += self._anchor_target(item_tag) + para += nodes.strong(text=display) + if item.get("text"): + para += nodes.Text(": " + item["text"]) + return para + + rows: list[list[nodes.Node | str]] = [] + for root_cause in sorted(root_causes, key=lambda i: i.get("name") or ""): + if root_cause.get("kind") == "TopEvent": + # Top events are the failure modes themselves and show up in + # the "Failure Mode" column of their tree's rows. + continue + top_event = top_event_by_file.get( + root_cause.get("location", {}).get("file", "") + ) + failure_mode_tag = (top_event or {}).get("ref_up") or [None] + failure_mode = by_tag.get( + self._strip_version(failure_mode_tag[0] or "") + ) + public_api_tag = (failure_mode or {}).get("ref_up") or [None] + control_measure_tag = root_cause.get("ref_up") or [None] + + rows.append( + [ + cell(root_cause.get("tag")), + cell(failure_mode_tag[0]), + cell(public_api_tag[0]), + cell(control_measure_tag[0]), + ] + ) + + summary = nodes.paragraph() + summary += nodes.Text( + f"{len(rows)} root cause(s) across {len(top_event_by_file)} fault " + "tree(s), each shown with the failure mode it leads to, the public " + "API interface that failure mode belongs to, and the control " + "measure mitigating it." + ) + + section = self._section("Safety Analysis") + section += summary + section.extend( + _foldable( + f"Show {len(rows)} root cause(s)", + _table( + ["Root Cause", "Failure Mode", "Public API", "Control Measure"], + rows, + ), + ) + ) + return section + + def _build_nodes(self, report: dict[str, Any]) -> list[nodes.Node]: + levels_list = report.get("levels", []) + levels_by_name = {lvl.get("name"): lvl for lvl in levels_list} + self._tag_index = self._build_tag_index(levels_list) + rendered_names: set[str] = set() + result: list[nodes.Node] = [nodes.raw("", _ANCHOR_SCRIPT, format="html")] + + # Render the requirement chain top-down first -- feature + # requirements refined into component requirements, then component + # requirements verified by unit tests -- so the report reads along + # the direction of decomposition before the remaining pools follow + # in whatever order the JSON lists them. + feat_req_level = levels_by_name.get("Feature Requirements") + comp_req_level = levels_by_name.get("Component Requirements") + unit_test_level = levels_by_name.get("Unit Test") + if feat_req_level is not None and comp_req_level is not None: + result.append( + self._feat_req_comp_req_section(feat_req_level, comp_req_level) + ) + rendered_names.add("Feature Requirements") + if comp_req_level is not None and unit_test_level is not None: + result.append( + self._comp_req_unit_test_section(comp_req_level, unit_test_level) + ) + rendered_names.add("Component Requirements") + rendered_names.add("Unit Test") + + # The FMEA/FTA pools only make sense as one joined table, so keep + # them out of the generic loop and append that table at the end. + safety_section: nodes.section | None = None + if all(name in levels_by_name for name in self._SAFETY_LEVEL_NAMES): + safety_section = self._safety_analysis_section(levels_by_name) + rendered_names.update(self._SAFETY_LEVEL_NAMES) + + for level in levels_list: + name = level.get("name") + if name in rendered_names: + continue + rendered_names.add(name) + result.append( + self._generic_level_section(level, _PIE_INFO.get(name or "")) + ) + + if safety_section is not None: + result.append(safety_section) + + return result diff --git a/src/extensions/score_source_code_linker/BUILD b/src/extensions/score_source_code_linker/BUILD index 98a2c68ea..2fcf07580 100644 --- a/src/extensions/score_source_code_linker/BUILD +++ b/src/extensions/score_source_code_linker/BUILD @@ -54,6 +54,7 @@ py_library( "needlinks.py", "testlink.py", "xml_parser.py", + "lobster_parser.py", "helpers.py", "repo_source_links.py", ], diff --git a/src/extensions/score_source_code_linker/__init__.py b/src/extensions/score_source_code_linker/__init__.py index ef1acfd90..40827aa0c 100644 --- a/src/extensions/score_source_code_linker/__init__.py +++ b/src/extensions/score_source_code_linker/__init__.py @@ -35,6 +35,7 @@ generate_source_code_links_json, ) from src.extensions.score_source_code_linker.helpers import get_github_link +from src.extensions.score_source_code_linker.lobster_parser import run_lobster_parser from src.extensions.score_source_code_linker.need_source_links import ( group_by_need, load_source_code_links_combined_json, @@ -206,6 +207,29 @@ def setup_test_code_linker(app: Sphinx, env: BuildEnvironment): ws_root = find_ws_root() if not ws_root: return + + source = str(getattr(app.config, "score_testlink_source", "xml")) + if source == "none": + LOGGER.debug( + "Test links are disabled via score_testlink_source='none'.", + type="score_source_code_linker", + ) + return + if source == "lobster": + LOGGER.debug( + "INFO: Generating test links from '.lobster' pools.", + type="score_source_code_linker", + ) + run_lobster_parser(app, env) + return + if source != "xml": + LOGGER.warning( + f"Unknown score_testlink_source '{source}'. " + "Expected 'xml', 'lobster' or 'none'. Skipping test links.", + type="score_source_code_linker", + ) + return + LOGGER.debug( "INFO: Generating score_xml_parser JSON file.", type="score_source_code_linker", @@ -361,6 +385,18 @@ def setup(app: Sphinx) -> dict[str, str | bool]: "these directories. Empty means no filtering (scan the whole workspace)." ), ) + app.add_config_value( + "score_testlink_source", + default="xml", + rebuild="env", + types=str, + description=( + "Where test links come from: 'xml' scans bazel-testlogs/**/test.xml, " + "'lobster' reads the *.lobster activity pools produced by lobster-gtest, " + "'none' disables test links entirely. Note that 'lobster' cannot provide " + "failure messages and reports skipped tests as passed." + ), + ) setup_once(app) return { diff --git a/src/extensions/score_source_code_linker/lobster_parser.py b/src/extensions/score_source_code_linker/lobster_parser.py new file mode 100644 index 000000000..c79a6613a --- /dev/null +++ b/src/extensions/score_source_code_linker/lobster_parser.py @@ -0,0 +1,290 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Alternative producer for test links, reading ``*.lobster`` activity traces +instead of ``bazel-testlogs/**/test.xml``. + +``lobster-gtest`` already parses the GoogleTest XML itself, so the ``.lobster`` +pools are a downstream artifact of the very same data: every ``RecordProperty`` +other than ``lobster-tracing`` is rendered into the item's ``text`` field as +``:Key: value`` lines, and the test result is condensed into ``status``. + +This module produces exactly the same two caches as ``xml_parser.run_xml_parser`` +so that everything downstream (``group_by_need``, the repo grouping and +``inject_links_into_needs``) works unchanged. Which of the two producers runs is +decided by the ``score_testlink_source`` configuration value. + +Known fidelity losses compared to the XML producer: + * ``result_text`` is always empty - lobster-gtest drops the ```` + message and only keeps a boolean outcome. + * ``skipped`` cannot be distinguished from ``passed`` - lobster-gtest only + inspects the ```` element, so a ``GTEST_SKIP()`` test is reported + as ``ok``. +""" + +# req-Id: tool_req__docs_test_link_testcase + +import itertools +import json +import os +import re +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx_needs import logging + +from src.extensions.score_source_code_linker.helpers import ( + parse_info_from_known_good, + parse_repo_name_from_path, +) +from src.extensions.score_source_code_linker.needlinks import ( + DefaultMetaData, + MetaData, +) +from src.extensions.score_source_code_linker.testlink import ( + DataOfTestCase, + store_data_of_test_case_json, + store_test_xml_parsed_json, +) +from src.extensions.score_source_code_linker.xml_parser import construct_and_add_need +from src.helper_lib import find_ws_root + +logger = logging.get_logger(__name__) + +LOBSTER_ACTIVITY_SCHEMA = "lobster-act-trace" + +# lobster-gtest renders the RecordProperty keys through ``str.capitalize()``, +# which is lossy ("TestType" -> "Testtype"). Map them back to the field names +# of DataOfTestCase. +_TEXT_KEY_MAP = { + "partiallyverifies": "PartiallyVerifies", + "fullyverifies": "FullyVerifies", + "testtype": "TestType", + "derivationtechnique": "DerivationTechnique", +} + +# ``_resolve_test_status`` in lobster-gtest only knows these three outcomes. +_STATUS_MAP = { + "ok": "passed", + "fail": "failed", + "not run": "disabled", +} + +_TEXT_LINE_RE = re.compile(r"^:([A-Za-z]+):\s*(.*)$") + + +def parse_text_block(text: str | None) -> dict[str, str]: + """Turn a lobster ``text`` blob into DataOfTestCase field names. + + ``":Testtype: requirements-based"`` becomes + ``{"TestType": "requirements-based"}``. Unknown keys (e.g. ``:Description:``) + are dropped, mirroring ``xml_parser.parse_properties``. + """ + properties: dict[str, str] = {} + for line in (text or "").splitlines(): + match = _TEXT_LINE_RE.match(line.strip()) + if match is None: + continue + field = _TEXT_KEY_MAP.get(match.group(1).lower()) + if field is not None: + properties[field] = match.group(2).strip() + return properties + + +def normalize_test_name(name: str) -> str: + """Align the lobster naming with the one used by the XML producer. + + lobster-gtest builds ``f"{suite_name}:{test_name}"`` while + ``read_test_xml_file`` joins the same two parts with ``"__"``. Normalizing + here keeps the generated ``testcase___`` IDs identical for both + producers. + """ + return name.replace(":", "__") + + +def get_metadata_from_lobster_path(raw_filepath: Path) -> MetaData: + """Derive repo metadata from the location of the ``.lobster`` file. + + Mirrors ``xml_parser.get_metadata_from_test_path``, but keys off ``bazel-bin`` + instead of ``bazel-testlogs``. External repositories show up as + ``bazel-bin/external/+/...``, everything else is the local repository. + """ + md = DefaultMetaData() + path_str = str(raw_filepath) + if "bazel-bin/" in path_str: + clean_filepath = Path(path_str.split("bazel-bin/")[-1]) + elif "bazel-out/" in path_str: + # bazel-bin is a symlink into bazel-out//bin; strip the two + # leading path segments so that a possible "external/+" prefix + # becomes visible to parse_repo_name_from_path. + remainder = Path(path_str.split("bazel-out/")[-1]).parts[2:] + clean_filepath = Path(*remainder) if remainder else raw_filepath + else: + return md + + md["repo_name"] = parse_repo_name_from_path(clean_filepath) + known_good_json = os.environ.get("KNOWN_GOOD_JSON") + if md["repo_name"] != "local_repo" and known_good_json: + md["hash"], md["url"] = parse_info_from_known_good( + Path(known_good_json), md["repo_name"] + ) + return md + + +def item_to_test_case(item: dict[str, object], md: MetaData) -> DataOfTestCase: + """Convert a single lobster activity item into a DataOfTestCase.""" + location = item.get("location") + location = location if isinstance(location, dict) else {} + line = location.get("line") + status = item.get("status") + + properties: dict[str, object] = { + "name": normalize_test_name(str(item.get("name", ""))), + "file": location.get("file"), + "line": str(line) if line is not None else None, + "result": _STATUS_MAP.get(str(status), "disabled"), + # lobster-gtest discards the message, so there is never a + # result text. It must not be None though, is_valid() rejects that. + "result_text": "", + } + text = item.get("text") + properties.update(parse_text_block(text if isinstance(text, str) else None)) + properties.update(md) + return DataOfTestCase.from_dict(properties) + + +def find_lobster_files(search_paths: list[Path]) -> list[Path]: + """Collect all ``*.lobster`` files below the given directories. + + ``bazel-bin`` is a symlink into ``bazel-out``, so the search path itself is + resolved first. Symlinks *below* it are deliberately not followed: runfiles + trees link back into the workspace (and into ``bazel-bin`` itself), which + would make the walk cyclic. ``.runfiles`` directories only ever contain + copies of outputs that are also present in their regular location, so they + are pruned as well. + """ + found: list[Path] = [] + seen: set[Path] = set() + for search_path in search_paths: + if not search_path.exists(): + continue + for root, dirs, files in os.walk(search_path.resolve()): + dirs[:] = [d for d in dirs if not d.endswith(".runfiles")] + for filename in files: + if not filename.endswith(".lobster"): + continue + path = Path(root) / filename + if path in seen: + continue + seen.add(path) + found.append(path) + return found + + +def read_lobster_file(file: Path) -> tuple[list[DataOfTestCase], list[str]]: + """Read one ``.lobster`` file and return its test cases. + + Returns a tuple of the parsed test cases and the names of those that are + missing mandatory properties (and will therefore not be linked). + """ + try: + document = json.loads(file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning( + f"Could not read lobster file '{file}': {exc}", + type="score_source_code_linker", + ) + return [], [] + + if not isinstance(document, dict): + return [], [] + if document.get("schema") != LOBSTER_ACTIVITY_SCHEMA: + # Requirement and implementation pools carry no test information. + return [], [] + + data = document.get("data") + if not isinstance(data, list): + return [], [] + + md = get_metadata_from_lobster_path(file) + test_cases: list[DataOfTestCase] = [] + invalid: list[str] = [] + for item in data: + if not isinstance(item, dict) or item.get("kind") != "test": + continue + test_case = item_to_test_case(item, md) + if not test_case.is_valid(): + invalid.append(str(test_case.name)) + test_cases.append(test_case) + return test_cases, invalid + + +def run_lobster_parser(app: Sphinx, _: BuildEnvironment) -> None: + """Build testcase needs from ``*.lobster`` pools. + + Drop-in replacement for ``xml_parser.run_xml_parser``: it writes the very + same caches, so the combining and injection steps stay untouched. + """ + ws_root = find_ws_root() + if ws_root is None: + return + + lobster_files = find_lobster_files([ws_root / "bazel-bin"]) + logger.info( + f"Found {len(lobster_files)} lobster files in total. Parsing them now", + type="score_source_code_linker", + ) + if not lobster_files: + logger.info( + "Did not find any '.lobster' files. If test data should be parsed, " + "please build the corresponding unit()/component()/" + "dependable_element() targets before building the documentation.", + type="score_source_code_linker", + ) + + test_case_needs: list[DataOfTestCase] = [] + invalid_names: list[str] = [] + for file in lobster_files: + test_cases, invalid = read_lobster_file(file) + invalid_names.extend(invalid) + test_case_needs.extend(test_cases) + for test_case in test_cases: + construct_and_add_need(app, test_case) + + if invalid_names: + logger.info( + f"Tests missing some properties: {', '.join(invalid_names)}", + type="score_source_code_linker", + ) + + # Always write the caches, even when empty. Otherwise a stale cache from a + # previous run (possibly produced by the XML parser) would silently be + # picked up by build_and_save_combined_file. + logger.info( + f"Saving {len(test_case_needs)} test case needs to the cache " + "`score_testcaseneeds_cache.json` in _build/.", + type="score_source_code_linker", + ) + store_data_of_test_case_json( + app.outdir / "score_testcaseneeds_cache.json", test_case_needs + ) + output = list( + itertools.chain.from_iterable(tcn.get_test_links() for tcn in test_case_needs) + ) + logger.info( + f"Saving {len(output)} parsed testcases to the cache " + "`score_xml_parser_cache.json` in _build/.", + type="score_source_code_linker", + ) + store_test_xml_parsed_json(app.outdir / "score_xml_parser_cache.json", output) diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 954c172e3..995671338 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -32,6 +32,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_lobster_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 11adca09e..973e7c7ef 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -32,6 +32,7 @@ "sphinx_mounts", "score_mounts", "score_source_code_linker", + "score_lobster_report", "score_draw_uml_funcs", "score_layout", "sphinx_collections", diff --git a/src/incremental.py b/src/incremental.py index 8004271af..14ce486ba 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -160,6 +160,7 @@ def _mounted_watch_dirs( # works for both the py_binary and the needs_json target. f"--define=external_needs_source={_merged_external_needs()}", f"--define=testcase_source_dirs={os.environ.get('TEST_SOURCES', '[]')}", + f"--define=score_testlink_source={os.environ.get('TESTLINK_SOURCE', 'xml')}", # Path to the Bazel-emitted mounts manifest (empty when no mounts are # configured); consumed by the score_mounts extension. f"--define=mounts_manifest={os.environ.get('MOUNTS_MANIFEST', '')}",