diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 113803b97..e7b795761 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -36,6 +36,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_sphinx_needs_templates", "@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 f3399e507..94effa918 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -24,6 +24,7 @@ "sphinxcontrib.plantuml", "score_plantuml", "sphinx_needs", + "score_sphinx_needs_templates", "score_cross_module_compatibility", "score_metamodel", "sphinx_design", diff --git a/src/extensions/score_sphinx_needs_templates/BUILD b/src/extensions/score_sphinx_needs_templates/BUILD new file mode 100644 index 000000000..8a981d037 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/BUILD @@ -0,0 +1,40 @@ +# ******************************************************************************* +# 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 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") +load("//:score_pytest.bzl", "score_pytest") + +filegroup( + name = "all_sources", + srcs = ["__init__.py", "lplus.py"], + visibility = ["//visibility:public"], +) + +py_library( + name = "score_sphinx_needs_templates", + srcs = [":all_sources"], + imports = ["."], + data = ["@score_docs_as_code//src/needs_templates:files"], + visibility = ["//visibility:public"], + deps = all_requirements + [ + "@score_docs_as_code//src/helper_lib", + ], +) + +score_pytest( + name = "report_pipeline_tests", + size = "small", + srcs = glob(["tests/*.py"]), + deps = [":score_sphinx_needs_templates"], + pytest_config = "//:pyproject.toml", +) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md new file mode 100644 index 000000000..e540c80fb --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -0,0 +1,193 @@ + + +# `score_sphinx_needs_templates` + +This extension contains the runtime support for the repository's Sphinx-Needs +`.need` templates. It is loaded by `score_sphinx_bundle` immediately after +`sphinx_needs`. + +## Features + +The extension provides: + +* the shared `src/needs_templates` directory as the Sphinx-Needs template + directory; +* the L+ report placeholder and materializer for graph-driven verification + reports. + +## L+ reports + +An ordinary `mod_ver_report` Need remains the source of the report metadata. +The companion directive records only the report ID and template identity: + +```rst +.. score_lplus_report:: + :id: mod_vrep__example + :template: mod_ver_report_tiny +``` + +During parallel reading this creates only a pickleable placeholder and a +persisted declaration. At `env-updated`, after worker environments have been +merged, L+ obtains the resolved read-only `NeedsView`, renders the selected +Jinja template against it, parses that controlled RST fragment, and replaces +the placeholder with its ordinary section, Need-reference, and Sphinx-Needs +nodes. A small Sphinx-9 ToC adapter rebuilds the local page navigation from +those real sections. Graph fingerprints and environment dependencies drive +incremental invalidation. + +## L+ implementation plan + +L+ materializes a graph-driven report in an existing document after parallel +Need collection has completed, then refreshes that document's local ToC. It is +the proposed one-Sphinx-build replacement for the current G reread workaround. +The decision record and constraints are tracked in [Issue #764](https://github.com/eclipse-score/docs-as-code/issues/764). + +## Goal and boundaries + +The Baselibs report must derive its outline from the complete, resolved Need +graph while retaining the existing `docs()` topology and parallel document +reading. It must not call `builder.read_doc()`, `env.clear_doc()`, or re-enter +Sphinx's reader lifecycle. + +L+ is a compatibility-adapter experiment, not a claim that late-generated +content automatically has every property of ordinary source RST. In +particular, it must prove the required local ToC, anchors, Need references, and +non-HTML output before G is removed. A cross-document `:ref:` to a +late-generated heading is an explicit acceptance test, not an assumption. + +Out of scope: + +* a second Sphinx/Bazel manifest pipeline (A/B); +* an HTML-only navigation menu; +* expanding G's reread/`env.tocs` patch with more report features; and +* a generic graph compiler or an upstream API proposal beyond recording the + gaps found by the prototype. + +## Target design + +```text +normal parallel read + | + v +report placeholder + persisted dependency declaration + | + v +worker-environment merge + | + v +env-updated: resolved NeedsView -> materialize existing report Doctree + | + v +compatibility adapter: refresh this document's local ToC + | + v +normal write/post-transform phase +``` + +The read-phase directive records only stable report identity and configuration. +It does not traverse the graph or render dynamic headings. At `env-updated`, +the materializer obtains the resolved, read-only `NeedsView`, renders the +selected controlled Jinja template against that model, parses its RST fragment, +and replaces the placeholder with Docutils nodes for targets, sections, titles, +and report content. It does not re-enter the source reader. + +The materializer owns an environment-persisted reverse dependency map. It maps +traversed Need IDs, relevant link/config fingerprints, and external inputs to +the report document. It must be deterministic, merge safely across workers, +and mark affected report documents for writing on incremental builds. + +The only Sphinx-private surface is the local-ToC refresh. Put it in one small +adapter with an explicit supported-Sphinx-version matrix. The materializer must +never append rubric-derived entries to `env.tocs` directly. + +## Delivery steps + +1. Establish a fixture and baseline + + Create an isolated documentation fixture with two linked components in + non-alphabetical link order, an external feature, a backlink-dependent + element, and a `needextend`. Capture G's intended HTML, anchors, and + navigation as the behavioural baseline; do not copy its lifecycle code. + +2. Add the read-phase placeholder + + Introduce a dedicated report directive/node that creates no dynamic + sections. Persist report ID, document name, template/config fingerprint, + and declared external inputs in environment-owned data. Implement purge and + worker-environment merge handlers for that data only. + +3. Build the resolved-model materializer + + On `env-updated`, obtain `get_needs_view(app)` through a narrow model + adapter. Traverse the report graph in declared order, fail with report/link + context for an absent target, calculate reverse dependencies, render the + selected controlled Jinja template, and replace the placeholder exactly + once with its parsed Docutils/Sphinx-Needs nodes. Do not call + `builder.read_doc()` or invoke a general source parser. + +4. Implement the ToC compatibility adapter + + Regenerate the report document's section ToC from the materialized section + hierarchy and persist the Sphinx-version-specific collector state. Handle + target collisions and nested headings. The adapter must be idempotent on + clean and incremental builds and expose no general-purpose mutation API. + +5. Verify source and builder semantics + + Test a `:ref:` from another document to a generated component heading, a + Need reference within generated content, nested local navigation, and one + non-HTML builder. If an external `:ref:` needs unsupported late + label/domain registration, record the missing contract and fail the L+ gate + instead of adding unbounded private patches. + +6. Exercise invalidation and concurrency + + Run clean and incremental builds with `-j 1` and parallel reading. Change a + transitive Need, an unrelated Need, the report template/configuration, and + an external Need input. Verify deterministic report/navigation output and + that only the appropriate report output is refreshed. + +7. Decide and migrate + + Promote L+ only when every acceptance criterion below passes on the pinned + Sphinx/Sphinx-Needs versions. Then remove G's reread, module-global model + state, rubric-ID generation, and direct `env.tocs` patch for this report. + If the gate fails, keep G unchanged as a narrow bridge and document the + missing upstream contract; do not reintroduce A/B for this report. + +## Acceptance gate + +L+ may replace G only when all of the following hold: + +* graph traversal discovers ordered components and a second Need-linked report + dimension without new directive options or dimension-specific drift checks; +* local and external Needs, backlinks, and accepted `needextend` changes are + present in the rendered report; +* the local ToC is nested and points to stable, collision-free anchors; +* a cross-document `:ref:` to a generated heading and generated Need + references resolve correctly; +* HTML and one non-HTML builder complete with equivalent section structure; +* clean and incremental serial/parallel builds are deterministic; +* a transitive input refreshes the report, while an unrelated input leaves its + output unchanged; and +* all non-public Sphinx interaction is contained in the version-pinned ToC + adapter and covered by compatibility tests. + +## Decision checkpoints + +After steps 3 and 4, review the adapter size and private API surface. After +step 5, decide whether L+ meets the normal-page requirement rather than merely +producing a convincing PyData sidebar. A failure in either checkpoint rejects +L+ as the target architecture. The fallback is product work: retain G only as +a constrained bridge and pursue a supported upstream report-and-ToC API. diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py new file mode 100644 index 000000000..58f3ef569 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -0,0 +1,47 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Sphinx-Needs templates and the L+ report materializer. + +Ordinary Need templates continue to be handled by Sphinx-Needs during the +read phase. L+ report declarations are collected as placeholders and their +graph-driven sections are inserted after parallel environments are merged. +""" + +from pathlib import Path + + +def _needs_template_folder() -> Path: + template_folder = Path(__file__).parents[2] / "needs_templates" + if not template_folder.is_dir(): + raise FileNotFoundError( + f"Sphinx-Needs template folder does not exist: {template_folder}" + ) + return template_folder + + +def setup(app): # type: ignore[no-untyped-def] + from src.helper_lib import config_setdefault + + app.setup_extension("sphinx_needs") + config_setdefault( + app.config, "needs_template_folder", str(_needs_template_folder()) + ) + from .lplus import setup_lplus + + setup_lplus(app) + return { + "version": "3.0.0", + "env_version": 300, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_sphinx_needs_templates/lplus.py b/src/extensions/score_sphinx_needs_templates/lplus.py new file mode 100644 index 000000000..5f8fd59f1 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/lplus.py @@ -0,0 +1,570 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""L+ report materialization. + +L+ records a report placeholder during the normal parallel read. Once worker +environments have been merged, it renders the selected Jinja template against +the complete NeedsView and materializes the resulting RST fragment in the +cached doctree. The only Sphinx-private operation is the version-pinned local +ToC refresh. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any, cast + +from docutils import nodes +from docutils.parsers.rst import Parser, directives +from docutils.utils import new_document +from jinja2 import Environment, FileSystemLoader, StrictUndefined +from sphinx import version_info as sphinx_version +from sphinx.application import Sphinx +from sphinx.errors import SphinxError +from sphinx.util.docutils import SphinxDirective +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.need_item import NeedItem, NeedLink +from sphinx_needs.nodes import Need + +_DECLARATIONS = "_score_lplus_reports" +_FINGERPRINTS = "_score_lplus_report_fingerprints" +_REVERSE_DEPS = "_score_lplus_reverse_dependencies" +_EXTENSION_VERSION = "lplus-2" +_SUPPORTED_SPHINX_MAJOR = 9 + + +class LPlusReportPlaceholder(nodes.General, nodes.Element): + """A node which exists only until the merged Need model is available.""" + + +def _env_map(env: Any, name: str) -> dict[str, Any]: + value = getattr(env, name, None) + if not isinstance(value, dict): + value = {} + setattr(env, name, value) + return value + + +def _split_inputs(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in re.split(r"[,\n]", value) if item.strip()] + + +def _declaration( + *, report_id: str, docname: str, template: str, external_inputs: Iterable[str] +) -> dict[str, Any]: + return { + "report_id": report_id, + "docname": docname, + "template": template, + "external_inputs": sorted(set(external_inputs)), + "version": _EXTENSION_VERSION, + } + + +def _record_declaration(env: Any, declaration: dict[str, Any]) -> None: + declarations = _env_map(env, _DECLARATIONS) + report_id = declaration["report_id"] + previous = declarations.get(report_id) + if previous is not None and previous != declaration: + raise SphinxError( + f"L+ report {report_id!r} is declared more than once with different configuration" + ) + declarations[report_id] = declaration + + +class LPlusReportDirective(SphinxDirective): + """Declare a report template which owns an L+ placeholder.""" + + has_content = False + required_arguments = 0 + optional_arguments = 0 + option_spec = { + "id": directives.unchanged_required, + "template": directives.unchanged, + "external-inputs": directives.unchanged, + } + + def run(self) -> list[nodes.Node]: + report_id = str(self.options.get("id", "")).strip() + if not report_id: + raise SphinxError("score_lplus_report requires an :id: option") + template = str(self.options.get("template", "mod_ver_report_tiny")).strip() + declaration = _declaration( + report_id=report_id, + docname=self.env.docname, + template=template, + external_inputs=_split_inputs(self.options.get("external-inputs")), + ) + _record_declaration(self.env, declaration) + return [ + LPlusReportPlaceholder( + "", + report_id=report_id, + template=template, + source_docname=self.env.docname, + ) + ] + + +def _need_id(value: NeedLink | str) -> str: + if isinstance(value, str): + try: + return str(NeedLink.parse_address(value).id) + except (AttributeError, TypeError, ValueError): + return value.split("[", 1)[0] + return str(value.id) + + +def _need_links(need: NeedItem, field: str) -> list[NeedLink]: + try: + return list(need.get_links(field, as_str=False)) + except KeyError: + return [] + + +def _link_targets(view: Mapping[str, NeedItem], need: NeedItem) -> Iterable[NeedItem]: + """Yield existing outgoing targets in the Need's declared order.""" + for _field, links in need.iter_links_items(as_str=False): + for link in links: + target = view.get(_need_id(link)) + if target is not None: + yield target + + +def _incoming_dependents( + view: Mapping[str, NeedItem], root_ids: set[str] +) -> Iterable[NeedItem]: + for candidate in view.values(): + if any( + _need_id(link) in root_ids + for _field, links in candidate.iter_links_items(as_str=False) + for link in links + ): + yield candidate + + +def _report_graph( + view: Mapping[str, NeedItem], report: NeedItem +) -> tuple[list[NeedItem], list[NeedItem]]: + """Return the ordered component outline and the report dependency closure.""" + module: NeedItem | None = None + for link in _need_links(report, "belongs_to"): + candidate = view.get(_need_id(link)) + if candidate is not None: + module = candidate + break + if module is None: + raise SphinxError( + f"L+ report {report['id']}: active belongs_to target was not found" + ) + + components: list[NeedItem] = [] + for link in _need_links(module, "includes"): + component = view.get(_need_id(link)) + if component is None: + raise SphinxError( + f"L+ report {report['id']}: module {module['id']} links to missing " + f"component {_need_id(link)} through includes" + ) + components.append(component) + + roots: list[NeedItem] = [report, module, *components] + for field in ("satisfied_by", "contains", "covers", "evidence", "realizes"): + roots.extend( + target + for link in _need_links(report, field) + if (target := view.get(_need_id(link))) is not None + ) + + closure: dict[str, NeedItem] = {} + pending = list(roots) + while pending: + current = pending.pop(0) + current_id = str(current["id"]) + if current_id in closure: + continue + closure[current_id] = current + pending.extend(_link_targets(view, current)) + + for candidate in _incoming_dependents(view, set(closure)): + closure[str(candidate["id"])] = candidate + + return components, list(closure.values()) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, list | tuple | set | frozenset): + return [_jsonable(item) for item in value] + return ( + value + if value is None or isinstance(value, str | int | float | bool) + else str(value) + ) + + +def _fingerprint( + declaration: Mapping[str, Any], needs: Iterable[NeedItem], template_fingerprint: str +) -> str: + payload = { + "declaration": dict(declaration), + "template": template_fingerprint, + "needs": sorted( + (_jsonable(dict(need.items())) for need in needs), + key=lambda item: str(item.get("id", "")), + ), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _template_path(app: Sphinx, template: str) -> Path: + name = template if template.endswith(".need") else f"{template}.need" + path = Path(str(app.config.needs_template_folder)) / name + if not path.is_file(): + raise SphinxError(f"L+ report template {name!r} does not exist at {path}") + return path + + +def _template_fingerprint(app: Sphinx, template: str) -> str: + try: + content = _template_path(app, template).read_bytes() + except SphinxError: + content = b"" + return hashlib.sha256(content).hexdigest() + + +def _template_context(report: NeedItem, view: Mapping[str, NeedItem]) -> dict[str, Any]: + context = {str(key): value for key, value in report.items()} + for field in ( + "belongs_to", + "satisfied_by", + "contains", + "covers", + "evidence", + "realizes", + ): + if field in context: + context[field] = [_need_id(link) for link in _need_links(report, field)] + + def linked(need_id: str, field: str) -> list[NeedItem]: + source_id = _need_id(need_id) + source = view.get(source_id) + if source is None: + raise SphinxError( + f"L+ report {report['id']}: linked_needs source {source_id!r} was not found" + ) + result: list[NeedItem] = [] + for link in _need_links(source, field): + target_id = _need_id(link) + target = view.get(target_id) + if target is None: + raise SphinxError( + f"L+ report {report['id']}: {source_id}.{field} links to " + f"missing Need {target_id!r}" + ) + result.append(target) + return result + + context.update( + report=report, + linked_needs=linked, + report_need=report, + ) + return context + + +def _render_template( + app: Sphinx, + declaration: Mapping[str, Any], + report: NeedItem, + view: Mapping[str, NeedItem], +) -> tuple[str, Path]: + template = str(declaration["template"]) + template_path = _template_path(app, template) + environment = Environment( + loader=FileSystemLoader(str(template_path.parent)), + undefined=StrictUndefined, + keep_trailing_newline=True, + autoescape=False, + ) + rendered = environment.get_template(template_path.name).render( + **_template_context(report, view) + ) + return rendered, template_path + + +def _parse_template( + app: Sphinx, + source_document: nodes.document, + docname: str, + rendered: str, + template_path: Path, +) -> list[nodes.Node]: + """Parse rendered RST as a fragment with the active Sphinx environment.""" + settings = copy.copy(source_document.settings) + settings.env = app.env + settings.docname = docname + settings.source = str(template_path) + + generated_document = new_document(str(template_path), settings=settings) + Parser().parse(rendered, generated_document) + from sphinx_needs.directives.needpie import Needpie + from sphinx_needs.directives.needtable import Needtable + + # The normal Needtable directive gets a target from the surrounding + # source-reader machinery. A fragment parsed after env-updated has no + # reader-owned target, while sphinx-needs' write transform requires one. + # Keep this compatibility detail local to the fragment parser. + prefix = f"needtable-{re.sub(r'[^a-zA-Z0-9]+', '-', docname).strip('-')}" + for serial, table in enumerate(generated_document.findall(Needtable), start=1): + if not table.get("ids"): + table["ids"] = [f"{prefix}-lplus-{serial}"] + for serial, pie in enumerate(generated_document.findall(Needpie), start=1): + if not pie.get("ids"): + pie["ids"] = [f"{prefix}-lplus-pie-{serial}"] + return list(generated_document.children) + + +class _EmptyLinkedNeeds: + """Pickleable placeholder for the incomplete read-phase model.""" + + def __call__(self, *_args: Any, **_kwargs: Any) -> list[NeedItem]: + return [] + + +_empty_linked_needs = _EmptyLinkedNeeds() + + +def _materialize_report( + app: Sphinx, + declaration: dict[str, Any], + view: Mapping[str, NeedItem], +) -> str: + docname = str(declaration["docname"]) + report_id = str(declaration["report_id"]) + report = view.get(report_id) + if report is None: + raise SphinxError( + f"L+ report {report_id!r} is not present in the resolved Need model" + ) + + try: + doctree = app.env._write_doc_doctree_cache[docname] + except KeyError: + doctree = app.env.get_doctree(docname) + + placeholders = [ + node + for node in doctree.findall(LPlusReportPlaceholder) + if node.get("report_id") == report_id + ] + if not placeholders: + raise SphinxError( + f"L+ report {report_id!r} has no placeholder in document {docname!r}" + ) + if len(placeholders) != 1: + raise SphinxError( + f"L+ report {report_id!r} has {len(placeholders)} placeholders" + ) + + rendered, template_path = _render_template(app, declaration, report, view) + replacement = _parse_template(app, doctree, docname, rendered, template_path) + placeholders[0].parent.replace(placeholders[0], replacement) + _register_standard_labels(app, doctree, docname) + _TocCompatibilityAdapter.refresh(app, doctree, docname) + app.env._write_doc_doctree_cache[docname] = doctree + + reverse = _env_map(app.env, _REVERSE_DEPS) + for report_docs in reverse.values(): + if isinstance(report_docs, set): + report_docs.discard(docname) + for need in _report_graph(view, report)[1]: + source_docname = need.get("docname") + reverse.setdefault(str(need["id"]), set()).add(docname) + if ( + isinstance(source_docname, str) + and source_docname + and source_docname != docname + ): + app.env.dependencies.setdefault(docname, set()).add( + app.env.doc2path(source_docname) + ) + for external_input in declaration.get("external_inputs", []): + reverse.setdefault(f"external:{external_input}", set()).add(docname) + app.env.dependencies.setdefault(docname, set()).add( + app.env.srcdir / str(external_input) + ) + app.env.dependencies.setdefault(docname, set()).add(cast(Any, template_path)) + reverse.setdefault(f"template:{template_path}", set()).add(docname) + reverse.setdefault(f"config:{report_id}", set()).add(docname) + return _fingerprint( + declaration, + _report_graph(view, report)[1], + _template_fingerprint(app, str(declaration["template"])), + ) + + +class _TocCompatibilityAdapter: + """Refresh one local ToC using the collector version we support.""" + + @staticmethod + def refresh(app: Sphinx, document: nodes.document, docname: str) -> None: + if sphinx_version[0] != _SUPPORTED_SPHINX_MAJOR: + raise SphinxError( + f"L+ local ToC adapter supports only Sphinx 9.x; got {sphinx_version}" + ) + from sphinx.environment.collectors.toctree import TocTreeCollector + + previous_docname = app.env.current_document.docname + app.env.current_document.docname = docname + try: + TocTreeCollector().process_doc(app, document) + finally: + app.env.current_document.docname = previous_docname + + +def _register_standard_labels( + app: Sphinx, document: nodes.document, docname: str +) -> None: + standard = app.env.domains.standard_domain + standard.clear_doc(docname) + standard.process_doc(app.env, docname, document) + + +def _ensure_declaration_for_need(app: Sphinx, doctree: nodes.document) -> None: + """Make the existing mod_ver_report syntax use the L+ placeholder.""" + declarations = _env_map(app.env, _DECLARATIONS) + needs = SphinxNeedsData(app.env).get_needs_mutable() + for need_node in list(doctree.findall(Need)): + report_id = str(need_node.get("refid", "")) + need = needs.get(report_id) + if need is None or need.get("type") != "mod_ver_report": + continue + + declaration = declarations.get(report_id) + if declaration is None: + declaration = _declaration( + report_id=report_id, + docname=app.env.docname, + template=str(need.get("template") or "mod_ver_report_tiny"), + external_inputs=(), + ) + _record_declaration(app.env, declaration) + + # The standard Need remains in the document for metadata/layout, but + # its read-phase template body must not be rendered a second time. + need_node.children.clear() + + if not any( + isinstance(node, LPlusReportPlaceholder) + and node.get("report_id") == report_id + for node in doctree.findall(LPlusReportPlaceholder) + ): + need_node.parent.insert( + need_node.parent.index(need_node) + 1, + LPlusReportPlaceholder( + "", + report_id=report_id, + template=declaration["template"], + ), + ) + + +def _on_doctree_read(app: Sphinx, doctree: nodes.document) -> None: + _ensure_declaration_for_need(app, doctree) + + +def _on_purge_doc(app: Sphinx, env: Any, docname: str) -> None: + declarations = _env_map(env, _DECLARATIONS) + removed = [ + report_id + for report_id, item in declarations.items() + if item.get("docname") == docname + ] + for report_id in removed: + declarations.pop(report_id, None) + _env_map(env, _FINGERPRINTS).pop(report_id, None) + for key, docnames in list(_env_map(env, _REVERSE_DEPS).items()): + if isinstance(docnames, set): + docnames.discard(docname) + if not docnames: + _env_map(env, _REVERSE_DEPS).pop(key, None) + + +def _on_merge_info(app: Sphinx, env: Any, docnames: Iterable[str], other: Any) -> None: + other_declarations = getattr(other, _DECLARATIONS, {}) + if isinstance(other_declarations, dict): + allowed = set(docnames) + for _report_id, declaration in other_declarations.items(): + if declaration.get("docname") in allowed: + _record_declaration(env, dict(declaration)) + + reverse = _env_map(env, _REVERSE_DEPS) + other_reverse = getattr(other, _REVERSE_DEPS, {}) + if isinstance(other_reverse, dict): + allowed = set(docnames) + for need_id, report_docs in other_reverse.items(): + selected = ( + set(report_docs) & allowed if isinstance(report_docs, set) else set() + ) + if selected: + reverse.setdefault(need_id, set()).update(selected) + + +def _on_env_updated(app: Sphinx, env: Any) -> list[str]: + declarations = _env_map(env, _DECLARATIONS) + if not declarations: + return [] + + view = SphinxNeedsData(env).get_needs_view() + fingerprints = _env_map(env, _FINGERPRINTS) + changed: list[str] = [] + for report_id in sorted(declarations): + declaration = declarations[report_id] + report = view.get(report_id) + if report is None: + raise SphinxError( + f"L+ report {report_id!r} is not present in the resolved Need model" + ) + _components, graph = _report_graph(view, report) + fingerprint = _fingerprint( + declaration, + graph, + _template_fingerprint(app, str(declaration["template"])), + ) + if fingerprints.get(report_id) == fingerprint: + continue + docname = str(declaration["docname"]) + result = _materialize_report(app, declaration, view) + fingerprints[report_id] = result + changed.append(docname) + return sorted(set(changed)) + + +def setup_lplus(app: Sphinx) -> None: + app.add_node(LPlusReportPlaceholder) + app.add_directive("score_lplus_report", LPlusReportDirective) + app.config.needs_render_context.setdefault("linked_needs", _empty_linked_needs) + app.connect("doctree-read", _on_doctree_read, priority=600) + app.connect("env-purge-doc", _on_purge_doc, priority=-100) + app.connect("env-merge-info", _on_merge_info, priority=600) + app.connect("env-updated", _on_env_updated, priority=700) diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_report_pipeline.py b/src/extensions/score_sphinx_needs_templates/tests/test_report_pipeline.py new file mode 100644 index 000000000..ee6dc47d4 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/tests/test_report_pipeline.py @@ -0,0 +1,66 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Contract tests for the graph traversal used by L+.""" + +from score_sphinx_needs_templates.lplus import _report_graph + + +class _Link: + def __init__(self, target: str) -> None: + self.id = target + + +class _Need: + def __init__(self, need_id: str, title: str, links: dict[str, list[str]]) -> None: + self.values = {"id": need_id, "title": title, "type": "test"} + self.links = { + field: [_Link(target) for target in targets] + for field, targets in links.items() + } + + def __getitem__(self, key: str) -> object: + return self.values[key] + + def get(self, key: str, default: object = None) -> object: + return self.values.get(key, default) + + def get_links(self, field: str, *, as_str: bool = True) -> list[_Link]: + del as_str + return self.links.get(field, []) + + def iter_links_items(self, *, as_str: bool = True): + del as_str + return ((field, links) for field, links in self.links.items()) + + +def test_lplus_graph_preserves_include_order_and_excludes_unrelated_needs() -> None: + report = _Need("report", "Report", {"belongs_to": ["module"]}) + module = _Need("module", "Module", {"includes": ["second", "first"]}) + second = _Need("second", "Second", {"belongs_to": ["feature"]}) + first = _Need("first", "First", {"belongs_to": ["feature"]}) + feature = _Need("feature", "Feature", {}) + unrelated = _Need("unrelated", "Unrelated", {}) + view = { + need["id"]: need for need in [report, module, second, first, feature, unrelated] + } + + components, graph = _report_graph(view, report) # type: ignore[arg-type] + + assert [need["id"] for need in components] == ["second", "first"] + assert {need["id"] for need in graph} == { + "report", + "module", + "second", + "first", + "feature", + } diff --git a/src/needs_templates/BUILD b/src/needs_templates/BUILD index b6f8c2567..35d73df47 100644 --- a/src/needs_templates/BUILD +++ b/src/needs_templates/BUILD @@ -14,6 +14,11 @@ # All Sphinx-Needs templates are deliberately kept flat in this package. # Consumers derive the common parent directory from the plural Bazel path # expansion, so do not add templates below subdirectories here. +exports_files( + ["mod_ver_report_tiny.need"], + visibility = ["//visibility:public"], +) + filegroup( name = "files", srcs = glob(["*.need"]), diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/mod_ver_report_tiny.need new file mode 100644 index 000000000..eec510c8b --- /dev/null +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -0,0 +1,374 @@ +{# + score: render-after-needs-collection + + The report follows the content and order of the standard module verification + report used by the baselibs PR report: + + * feature summary and feature-level verification statistics; + * component overview; + * component-level verification, traceability, coverage, and work products. + + A need template receives the current need's fields only. The + ``linked_needs`` render helper follows the module's outgoing links so that + the component sections stay driven by the Need model. +#} +{% set module_id = belongs_to|first|default("") %} +{% set module_short = module_id|replace("mod__", "") %} +{% set module_slug = module_short|replace("_", "")|lower %} +{% set feature_id = module_id|replace("mod__", "feat__") %} + +.. _{{ id }}: + +.. raw:: html + + + +This document provides a verification report of the Baselibs feature and its +associated components. + +Feature ++++++++ + +.. needtable:: + :filter: id == "{{ feature_id }}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +Feature Requirements Statistics +------------------------------- + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid" + type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "feat_req" and "{{ feature_id }}" in satisfied_by and len(fully_verifies_back) > 0 + type == "feat_req" and "{{ feature_id }}" in satisfied_by and len(partially_verifies_back) > 0 and len(fully_verifies_back) == 0 + type == "feat_req" and "{{ feature_id }}" in satisfied_by and len(fully_verifies_back) == 0 and len(partially_verifies_back) == 0 + +.. dropdown:: Show requirements table + :animate: fade-in + + .. needtable:: + :filter: type == "feat_req" and "{{ feature_id }}" in satisfied_by + :style: table + :columns: id;title;safety;status;fully_verifies_back;partially_verifies_back + :colwidths: 13,22,8,10,23,24 + :sort: id + +Feature Architecture Statistics +-------------------------------- + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "valid" + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and "inspected" in tags + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and "inspected" not in tags + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +Feature Inspection Statistics +-------------------------------- + +Presence of the feature-level inspection work products. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status + * - :need:`wp__requirements_inspect` + - Requirements Inspection + - .. needtable:: + :filter: type == "document" and "{{ module_slug }}" in id.replace("_", "").lower() and "wp__requirements_inspect" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ module_slug }}" in id.replace("_", "").lower() and "wp__requirements_inspect" in realizes + :columns: status + :style: table + * - :need:`wp__sw_arch_verification` + - Architecture Inspection + - .. needtable:: + :filter: type == "document" and "{{ module_slug }}" in id.replace("_", "").lower() and "wp__sw_arch_verification" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ module_slug }}" in id.replace("_", "").lower() and "wp__sw_arch_verification" in realizes + :columns: status + :style: table + +{% set report_components = linked_needs(module_id, "includes") %} + +Components ++++++++++ + +Component Overview +------------------ + +.. needtable:: + :filter: type == "comp" and "{{ module_id }}" in includes_back + :columns: id as "Component";safety;security;status + :style: table + :sort: id + +{% macro render_component(component_id, component_title, component_slug) %} +.. raw:: html + + + +{{ component_title }} +{{ "-" * (component_title|length) }} + +.. raw:: html + +
+ +Component Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid" + type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: {{ component_title }} Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "comp_req" and "{{ component_id }}" in satisfied_by and len(fully_verifies_back) > 0 + type == "comp_req" and "{{ component_id }}" in satisfied_by and len(partially_verifies_back) > 0 and len(fully_verifies_back) == 0 + type == "comp_req" and "{{ component_id }}" in satisfied_by and len(fully_verifies_back) == 0 and len(partially_verifies_back) == 0 + +Component Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "valid" + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" in tags + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" not in tags + +Requirements Traceability +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following table lists all requirements of this component together with +their verification status and the tests that (fully or partially) verify them: + +.. dropdown:: Show requirements table + :animate: fade-in + + .. needtable:: + :filter: type == "comp_req" and "{{ component_id }}" in satisfied_by + :style: table + :columns: id;title;safety;status;fully_verifies_back;partially_verifies_back + :colwidths: 13,22,8,10,23,24 + :sort: id + +Test Coverage +~~~~~~~~~~~~~ + +Per-source-file line and branch coverage aggregated from the LCOV report +produced by ``bazel coverage``. + +.. 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. + +Architectural Elements +~~~~~~~~~~~~~~~~~~~~~~ + +The following table lists the architectural elements of this component +together with their inspection status. Elements that have been formally +inspected carry the ``inspected`` tag; elements without that tag have not yet +been inspected. + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +Verification & Safety Analysis Documents +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Presence of the standard verification and safety analysis work products for +this component. A dash (``—``) means the corresponding document is missing. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status + * - :need:`wp__requirements_inspect` + - Requirements Inspection + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__requirements_inspect" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__requirements_inspect" in realizes + :columns: status + :style: table + * - :need:`wp__sw_arch_verification` + - Architecture Inspection + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_arch_verification" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_arch_verification" in realizes + :columns: status + :style: table + * - :need:`wp__sw_implementation_inspection` + - Implementation Inspection + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_implementation_inspection" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_implementation_inspection" in realizes + :columns: status + :style: table + * - :need:`wp__sw_component_dfa` + - DFA + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_component_dfa" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_component_dfa" in realizes + :columns: status + :style: table + * - :need:`wp__sw_component_fmea` + - FMEA + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_component_fmea" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ component_slug|replace("_", "")|lower }}" in id.replace("_", "").lower() and "wp__sw_component_fmea" in realizes + :columns: status + :style: table +{% endmacro %} + +{% for component in report_components %} +{{ render_component( + component["id"], + component["title"], + component["id"]|replace("comp__" ~ module_short ~ "_", "") +) }} +{% endfor %}