From 0c19129e928782a0ce2e8d79887cf8d379d46cbc Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 26 Aug 2026 21:44:35 +0200 Subject: [PATCH 01/12] initial version --- src/extensions/score_metamodel/metamodel.yaml | 5 + src/needs_templates/mod_ver_report_tiny.need | 368 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 src/needs_templates/mod_ver_report_tiny.need diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 33b088d52..bebb693b2 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -998,6 +998,11 @@ needs_types: - verification_report parts: 3 + auto_mod_ver_report: + title: Module Verification Report (automated) + parts: 2 + mandatory_links: + belongs_to: mod # https://eclipse-score.github.io/process_description/main/permalink.html?id=gd_temp__change_decision_record dec_rec: 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..1cb145c48 --- /dev/null +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -0,0 +1,368 @@ +{# + 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 Baselibs + component list is therefore kept explicit here, while needtable and needpie + filters resolve each component's data from Sphinx-Needs at render time. +#} +{% 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. + +.. rubric:: Feature + +.. needtable:: + :filter: id == "{{ feature_id }}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +.. rubric:: 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 + +.. rubric:: 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 + +.. rubric:: 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 = [ + ("comp__baselibs_json", "Json", "json"), + ("comp__baselibs_memory_shared", "Memory Shared", "memory_shared"), + ("comp__baselibs_result", "Result", "result"), + ("comp__baselibs_bit_manipulation", "Bit Manipulation", "bit_manipulation"), + ("comp__baselibs_containers", "Containers", "containers"), + ("comp__baselibs_filesystem", "Filesystem", "filesystem"), + ("comp__baselibs_concurrency", "Concurrency", "concurrency"), + ("comp__baselibs_safecpp", "Safecpp", "safecpp"), + ("comp__baselibs_static_reflection", "Static Reflection", "static_reflection"), + ("comp__baselibs_hash", "Hash", "hash"), + ("comp__baselibs_flatbuffers", "Flatbuffers", "flatbuffers"), + ("comp__baselibs_abi_compatible_data_types", "Abi Compatible Data Types", "abi_compatible_data_types") +] %} + +.. rubric:: Components + +.. rubric:: 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 + + + +.. rubric:: {{ component_title }} + +.. raw:: html + +
+ +.. rubric:: 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 + +.. rubric:: 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 + +.. rubric:: 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 + +.. rubric:: 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. + +.. rubric:: 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 + +.. rubric:: 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_id, component_title, component_slug in report_components %} +{{ render_component(component_id, component_title, component_slug) }} +{% endfor %} From 7096c71570d68b45b9906153b60388a34550b77c Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 26 Aug 2026 23:11:49 +0200 Subject: [PATCH 02/12] post collection rerender --- src/BUILD | 1 + src/extensions/score_sphinx_bundle/BUILD | 5 +- .../score_sphinx_bundle/__init__.py | 29 +--- .../score_sphinx_needs_templates/BUILD | 30 ++++ .../score_sphinx_needs_templates/README.md | 47 ++++++ .../score_sphinx_needs_templates/__init__.py | 148 ++++++++++++++++++ src/needs_templates/mod_ver_report_tiny.need | 31 ++-- 7 files changed, 240 insertions(+), 51 deletions(-) create mode 100644 src/extensions/score_sphinx_needs_templates/BUILD create mode 100644 src/extensions/score_sphinx_needs_templates/README.md create mode 100644 src/extensions/score_sphinx_needs_templates/__init__.py diff --git a/src/BUILD b/src/BUILD index aa9885ef8..b7b216854 100644 --- a/src/BUILD +++ b/src/BUILD @@ -53,6 +53,7 @@ filegroup( "//src/extensions/score_mounts:all_sources", "//src/extensions/score_source_code_linker:all_sources", "//src/extensions/score_sphinx_bundle:all_sources", + "//src/extensions/score_sphinx_needs_templates:all_sources", "//src/extensions/score_sync_toml:all_sources", "//src/extensions/score_metrics:all_sources", "//src/helper_lib:all_sources", diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 113803b97..3230c6124 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -22,14 +22,11 @@ filegroup( py_library( name = "score_sphinx_bundle", srcs = [":all_sources"], - # Keep the shared Sphinx-Needs templates beside the extension in the - # Bazel runfiles tree. The Python extension discovers their directory from - # its own __file__ instead of receiving a path from docs.bzl. - data = ["@score_docs_as_code//src/needs_templates:files"], visibility = ["//visibility:public"], deps = all_requirements + [ "@score_docs_as_code//src/extensions:score_plantuml", "@score_docs_as_code//src/extensions:broken_link_fix", + "@score_docs_as_code//src/extensions/score_sphinx_needs_templates", "@score_docs_as_code//src/extensions/score_draw_uml_funcs", "@score_docs_as_code//src/extensions/score_cross_module_compatibility", "@score_docs_as_code//src/extensions/score_layout", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index f3399e507..523649be0 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -10,8 +10,6 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -from pathlib import Path - import matplotlib from sphinx.application import Sphinx @@ -24,6 +22,7 @@ "sphinxcontrib.plantuml", "score_plantuml", "sphinx_needs", + "score_sphinx_needs_templates", "score_cross_module_compatibility", "score_metamodel", "sphinx_design", @@ -45,38 +44,12 @@ ] -def _needs_template_folder() -> Path: - """Return the shared Sphinx-Needs template directory. - - The extension and the templates are both part of the main ``src`` tree. - Deriving the path from ``__file__`` works for the workspace, Bazel - runfiles, and the sandbox because the extension's data files preserve that - source-tree layout. - """ - # Keep the runfiles/sandbox prefix intact; only walk from the extension's - # package directory to the sibling ``needs_templates`` directory. - # Basically: src/extensions/score_sphinx_bundle/../../needs_templates. - 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: Sphinx) -> dict[str, object]: matplotlib.rcParamsDefault["savefig.bbox"] = "tight" config_setdefault(app.config, "html_copy_source", False) config_setdefault(app.config, "html_show_sourcelink", False) - # The templates are a data dependency of this extension. Locate the - # shared directory from the extension itself instead of passing a Bazel - # label or a list of generated paths through every docs target. - config_setdefault( - app.config, "needs_template_folder", str(_needs_template_folder()) - ) - # Global settings # Note: the "sub-extensions" also set their own config values diff --git a/src/extensions/score_sphinx_needs_templates/BUILD b/src/extensions/score_sphinx_needs_templates/BUILD new file mode 100644 index 000000000..a57da451c --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/BUILD @@ -0,0 +1,30 @@ +# ******************************************************************************* +# 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") + +filegroup( + name = "all_sources", + srcs = ["__init__.py"], + visibility = ["//visibility:public"], +) + +py_library( + name = "score_sphinx_needs_templates", + srcs = [":all_sources"], + data = ["@score_docs_as_code//src/needs_templates:files"], + visibility = ["//visibility:public"], + deps = all_requirements + [ + "@score_docs_as_code//src/helper_lib", + ], +) 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..e47e43e48 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -0,0 +1,47 @@ + + +# `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 `linked_needs(need_id, link_name)` helper for traversing Need links; +* support for templates whose output depends on other Needs in the model. + +## Using `linked_needs` + +The helper returns the linked `NeedItem` objects in the order declared by the +source Need. This allows a template to derive its sections from the Need graph +instead of embedding Need IDs. + +For example: + +```jinja +{# score: render-after-needs-collection #} +{% set components = linked_needs(module_id, "includes") %} +{% for component in components %} +{{ component["title"] }} +{% endfor %} +``` + +Templates that follow links across the Need model should include the +`score: render-after-needs-collection` marker in a Jinja comment. Need fields and filters +continue to expose the corresponding backlink fields with the `_back` suffix. 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..81cb85884 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -0,0 +1,148 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.need_item import NeedItem + +from src.helper_lib import config_setdefault + +_template_environment: BuildEnvironment | None = None +# Templates containing this marker need a second read after parallel Need +# collection has been merged. +_RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection" + + +def _needs_template_folder() -> Path: + """Locate the shared ``.need`` template directory for Sphinx-Needs.""" + 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 + + +class _LinkedNeeds: + """Provide link traversal to Need templates as a pickleable callable. + + Calling the object with a Need ID and a link field returns the target + ``NeedItem`` objects in the order declared by the source Need. This lets a + template derive sections from the Need graph instead of embedding IDs. + + The object is deliberately a top-level class instance because Sphinx puts + the render context into its parallel-reader configuration. A plain + function would make that configuration unpickleable. The build environment + is kept process-local and captured once Sphinx has created ``app.env``. + """ + + def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: + if _template_environment is None: + return [] + + needs = SphinxNeedsData(_template_environment).get_needs_mutable() + source = needs.get(need_id) + if source is None: + return [] + + linked: list[NeedItem] = [] + for link in source.get_links(link_name, as_str=False): + target = needs.get(link.id) + if target is not None: + linked.append(target) + return linked + + +_linked_needs_callable = _LinkedNeeds() + + +def _complex_template_names(app: Sphinx) -> set[str]: + """Return template names opting into the post-merge rendering pass.""" + template_folder = Path(str(app.config.needs_template_folder)) + return { + template.stem + for template in template_folder.glob("*.need") + if ( + _RENDER_AFTER_NEEDS_COLLECTION_MARKER + in template.read_text(encoding="utf-8") + ) + } + + +def _rerender_pages_with_complex_templates( + app: Sphinx, env: BuildEnvironment +) -> list[str]: + """Re-read marked pages after the parallel Need environments are merged. + + Need templates are expanded while source documents are read. A parallel + worker cannot see Needs collected by other workers at that point. The + marked pages are therefore purged and read once more from the main + environment before Sphinx-Needs post-processing begins. + """ + if app.builder.name != "html": + return [] + + complex_templates = _complex_template_names(app) + if not complex_templates: + return [] + + needs_data = SphinxNeedsData(env) + if needs_data.needs_is_post_processed: + return [] + + complex_template_docs: set[str] = set() + for need in needs_data.get_needs_mutable().values(): + if need["template"] not in complex_templates: + continue + docname = need["docname"] + if isinstance(docname, str) and docname: + complex_template_docs.add(docname) + + pages_to_rerender = sorted(complex_template_docs) + for docname in pages_to_rerender: + app.events.emit("env-purge-doc", env, docname) + env.clear_doc(docname) + app.builder.read_doc(docname) + + return pages_to_rerender + + +def _capture_template_environment(app: Sphinx) -> None: + """Give the link helper the environment in which it should resolve Needs. + + The helper is registered during ``setup()``, but Sphinx creates ``app.env`` + only after extension setup has completed. ``builder-inited`` is the first + lifecycle event at which the final build environment is available. + """ + global _template_environment + _template_environment = app.env + + +def setup(app: Sphinx) -> dict[str, object]: + """Install Sphinx-Needs template helpers and the marked-page second pass.""" + app.setup_extension("sphinx_needs") + + config_setdefault( + app.config, "needs_template_folder", str(_needs_template_folder()) + ) + app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) + app.connect("builder-inited", _capture_template_environment) + app.connect("env-updated", _rerender_pages_with_complex_templates) + + return { + "version": "1.0.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/mod_ver_report_tiny.need index 1cb145c48..39d774a59 100644 --- a/src/needs_templates/mod_ver_report_tiny.need +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -1,4 +1,6 @@ {# + score: render-after-needs-collection + The report follows the content and order of the standard module verification report used by the baselibs PR report: @@ -6,9 +8,9 @@ * component overview; * component-level verification, traceability, coverage, and work products. - A need template receives the current need's fields only. The Baselibs - component list is therefore kept explicit here, while needtable and needpie - filters resolve each component's data from Sphinx-Needs at render time. + 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__", "") %} @@ -161,20 +163,7 @@ Presence of the feature-level inspection work products. :columns: status :style: table -{% set report_components = [ - ("comp__baselibs_json", "Json", "json"), - ("comp__baselibs_memory_shared", "Memory Shared", "memory_shared"), - ("comp__baselibs_result", "Result", "result"), - ("comp__baselibs_bit_manipulation", "Bit Manipulation", "bit_manipulation"), - ("comp__baselibs_containers", "Containers", "containers"), - ("comp__baselibs_filesystem", "Filesystem", "filesystem"), - ("comp__baselibs_concurrency", "Concurrency", "concurrency"), - ("comp__baselibs_safecpp", "Safecpp", "safecpp"), - ("comp__baselibs_static_reflection", "Static Reflection", "static_reflection"), - ("comp__baselibs_hash", "Hash", "hash"), - ("comp__baselibs_flatbuffers", "Flatbuffers", "flatbuffers"), - ("comp__baselibs_abi_compatible_data_types", "Abi Compatible Data Types", "abi_compatible_data_types") -] %} +{% set report_components = linked_needs(module_id, "includes") %} .. rubric:: Components @@ -363,6 +352,10 @@ this component. A dash (``—``) means the corresponding document is missing. :style: table {% endmacro %} -{% for component_id, component_title, component_slug in report_components %} -{{ render_component(component_id, component_title, component_slug) }} +{% for component in report_components %} +{{ render_component( + component["id"], + component["title"], + component["id"]|replace("comp__" ~ module_short ~ "_", "") +) }} {% endfor %} From e0a9d592aeafa294ab9e3a6057f8d3d5eb691e12 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 26 Aug 2026 23:34:40 +0200 Subject: [PATCH 03/12] add a menu --- .../score_sphinx_needs_templates/README.md | 3 +- .../score_sphinx_needs_templates/__init__.py | 100 ++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index e47e43e48..6abf076ed 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -24,7 +24,8 @@ The extension provides: * the shared `src/needs_templates` directory as the Sphinx-Needs template directory; * the `linked_needs(need_id, link_name)` helper for traversing Need links; -* support for templates whose output depends on other Needs in the model. +* support for templates whose output depends on other Needs in the model; +* local page navigation for headings generated by marked templates. ## Using `linked_needs` diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 81cb85884..a9a1f4dec 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -12,10 +12,13 @@ # ******************************************************************************* from pathlib import Path +from docutils import nodes +from sphinx import addnodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem +from sphinx_needs.nodes import Need from src.helper_lib import config_setdefault @@ -119,6 +122,100 @@ def _rerender_pages_with_complex_templates( return pages_to_rerender +def _template_rubrics( + app: Sphinx, doctree: nodes.document, complex_templates: set[str] +) -> list[nodes.Element]: + """Return headings rendered inside marked Need templates. + + Sphinx-Needs parses Need content with ``match_titles=False``. Consequently, + a normal section title inside a template cannot participate in Sphinx's + document ToC. The templates use rubrics for these headings, so those nodes + are the reliable, already-rendered representation to expose to the ToC. + """ + needs = SphinxNeedsData(app.env).get_needs_mutable() + rubrics: list[nodes.Element] = [] + + for need_node in doctree.findall(Need): + need_id = need_node.get("refid") + if not isinstance(need_id, str): + continue + + need = needs.get(need_id) + if need is None or need["template"] not in complex_templates: + continue + + # Only direct rubrics are report headings. Rubrics nested in dropdowns + # are control labels and should not become navigation entries. + rubrics.extend( + child for child in need_node.children if isinstance(child, nodes.rubric) + ) + + return rubrics + + +def _add_template_rubrics_to_toc(app: Sphinx, doctree: nodes.document) -> None: + """Expose headings inside marked Need templates in the local page ToC. + + The regular Sphinx ToC collector deliberately ignores sections nested in + arbitrary container nodes such as a Sphinx-Needs Need. This handler runs + after that collector, assigns IDs directly to the rendered rubrics, and + adds matching local references to its already-built ToC. It therefore + changes navigation only; the generated report content stays inside the + Need node and does not have to be moved or rendered twice. + """ + if app.builder.name != "html": + return + + complex_templates = _complex_template_names(app) + if not complex_templates: + return + + rubrics = _template_rubrics(app, doctree, complex_templates) + if not rubrics: + return + + docname = app.env.current_document.docname + toc = app.env.tocs.get(docname) + if toc is None: + return + + used_ids = { + node_id + for element in doctree.findall(nodes.Element) + for node_id in element.get("ids", []) + } + + for rubric in rubrics: + rubric_ids = rubric.get("ids", []) + if rubric_ids: + rubric_id = rubric_ids[0] + else: + rubric_id = nodes.make_id(f"{docname}-{rubric.astext()}") + if not rubric_id: + rubric_id = f"{docname}-template-heading" + + candidate = rubric_id + suffix = 2 + while candidate in used_ids: + candidate = f"{rubric_id}-{suffix}" + suffix += 1 + rubric_id = candidate + rubric["ids"] = [rubric_id] + used_ids.add(rubric_id) + + reference = nodes.reference( + "", + "", + *(child.deepcopy() for child in rubric.children), + internal=True, + refuri=docname, + anchorname=f"#{rubric_id}", + ) + toc += nodes.list_item("", addnodes.compact_paragraph("", "", reference)) + + app.env.toc_num_entries[docname] += len(rubrics) + + def _capture_template_environment(app: Sphinx) -> None: """Give the link helper the environment in which it should resolve Needs. @@ -139,6 +236,9 @@ def setup(app: Sphinx) -> dict[str, object]: ) app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) app.connect("builder-inited", _capture_template_environment) + # The priority is intentionally after Sphinx's environment collectors + # (default 500), so the handler can augment the collector-owned ToC. + app.connect("doctree-read", _add_template_rubrics_to_toc, priority=600) app.connect("env-updated", _rerender_pages_with_complex_templates) return { From d4f05c5bc338c09bd892e53f55ddce95d4c58124 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 27 Aug 2026 22:03:38 +0200 Subject: [PATCH 04/12] post_template --- .../score_sphinx_needs_templates/README.md | 21 +- .../score_sphinx_needs_templates/__init__.py | 163 ++++--------- src/needs_templates/mod_ver_report_tiny.need | 223 ++++++++---------- 3 files changed, 163 insertions(+), 244 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index 6abf076ed..56e540156 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -24,8 +24,10 @@ The extension provides: * the shared `src/needs_templates` directory as the Sphinx-Needs template directory; * the `linked_needs(need_id, link_name)` helper for traversing Need links; -* support for templates whose output depends on other Needs in the model; -* local page navigation for headings generated by marked templates. +* support for graph-driven `post_template`s that are rendered after parallel + Need collection has been merged; +* ordinary Sphinx page navigation for sections generated by those + `post_template`s. ## Using `linked_needs` @@ -44,5 +46,16 @@ For example: ``` Templates that follow links across the Need model should include the -`score: render-after-needs-collection` marker in a Jinja comment. Need fields and filters -continue to expose the corresponding backlink fields with the `_back` suffix. +`score: render-after-needs-collection` marker in a Jinja comment and be selected +with Sphinx-Needs' `:post_template:` option. The extension then purges and +rereads the affected report page once after parallel Need collection has been +merged, so `linked_needs` can see the complete model. + +The generated content should use normal reStructuredText sections instead of +rubrics. Sphinx-Needs parses `post_template` output after the Need and with +section matching enabled, so section IDs and the local page ToC are collected +by Sphinx itself. Ordinary `:template:` use keeps its normal Sphinx-Needs +behavior and is not part of this second-read path. + +Need fields and filters continue to expose the corresponding backlink fields +with the `_back` suffix. diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index a9a1f4dec..a36fe3aac 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -12,22 +12,40 @@ # ******************************************************************************* from pathlib import Path -from docutils import nodes -from sphinx import addnodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem -from sphinx_needs.nodes import Need from src.helper_lib import config_setdefault _template_environment: BuildEnvironment | None = None -# Templates containing this marker need a second read after parallel Need +# Post-templates containing this marker need a second read after parallel Need # collection has been merged. _RENDER_AFTER_NEEDS_COLLECTION_MARKER = "score: render-after-needs-collection" +def _base_need_id(need_id: str) -> str: + """Strip link conditions from an ID used to look up a merged Need.""" + return need_id.split("[", 1)[0] + + +def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: + """Find a Need by its address, tolerating version-qualified collection keys.""" + base_id = _base_need_id(need_id) + for candidate_id in (need_id, base_id): + candidate = needs.get(candidate_id) + if candidate is not None: + return candidate + + # Some imported collections use a qualified dictionary key even though the + # NeedItem itself keeps the canonical, unqualified ID. + for candidate_id, candidate in needs.items(): + if _base_need_id(candidate_id) == base_id or candidate["id"] == base_id: + return candidate + return None + + def _needs_template_folder() -> Path: """Locate the shared ``.need`` template directory for Sphinx-Needs.""" template_folder = Path(__file__).parents[2] / "needs_templates" @@ -56,13 +74,13 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: return [] needs = SphinxNeedsData(_template_environment).get_needs_mutable() - source = needs.get(need_id) + source = _find_need(needs, need_id) if source is None: return [] linked: list[NeedItem] = [] for link in source.get_links(link_name, as_str=False): - target = needs.get(link.id) + target = _find_need(needs, link.id) if target is not None: linked.append(target) return linked @@ -71,9 +89,9 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: _linked_needs_callable = _LinkedNeeds() -def _complex_template_names(app: Sphinx) -> set[str]: - """Return template names opting into the post-merge rendering pass.""" - template_folder = Path(str(app.config.needs_template_folder)) +def _complex_post_template_names(app: Sphinx) -> set[str]: + """Return post-template names opting into the post-merge rendering pass.""" + template_folder = _needs_template_folder() return { template.stem for template in template_folder.glob("*.need") @@ -84,36 +102,40 @@ def _complex_template_names(app: Sphinx) -> set[str]: } -def _rerender_pages_with_complex_templates( +def _rerender_pages_with_complex_post_templates( app: Sphinx, env: BuildEnvironment ) -> list[str]: - """Re-read marked pages after the parallel Need environments are merged. + """Re-read marked post-template pages after Need environments are merged. - Need templates are expanded while source documents are read. A parallel - worker cannot see Needs collected by other workers at that point. The - marked pages are therefore purged and read once more from the main - environment before Sphinx-Needs post-processing begins. + Post-templates are expanded while source documents are read. A parallel + worker cannot see Needs collected by other workers at that point. Marked + pages are therefore purged and read once more from the main environment + before Sphinx-Needs post-processing begins. """ if app.builder.name != "html": return [] - complex_templates = _complex_template_names(app) - if not complex_templates: + complex_post_templates = _complex_post_template_names(app) + if not complex_post_templates: return [] needs_data = SphinxNeedsData(env) if needs_data.needs_is_post_processed: return [] - complex_template_docs: set[str] = set() + complex_post_template_docs: set[str] = set() for need in needs_data.get_needs_mutable().values(): - if need["template"] not in complex_templates: + post_template = need.get("post_template") + if ( + not isinstance(post_template, str) + or post_template not in complex_post_templates + ): continue docname = need["docname"] if isinstance(docname, str) and docname: - complex_template_docs.add(docname) + complex_post_template_docs.add(docname) - pages_to_rerender = sorted(complex_template_docs) + pages_to_rerender = sorted(complex_post_template_docs) for docname in pages_to_rerender: app.events.emit("env-purge-doc", env, docname) env.clear_doc(docname) @@ -122,100 +144,6 @@ def _rerender_pages_with_complex_templates( return pages_to_rerender -def _template_rubrics( - app: Sphinx, doctree: nodes.document, complex_templates: set[str] -) -> list[nodes.Element]: - """Return headings rendered inside marked Need templates. - - Sphinx-Needs parses Need content with ``match_titles=False``. Consequently, - a normal section title inside a template cannot participate in Sphinx's - document ToC. The templates use rubrics for these headings, so those nodes - are the reliable, already-rendered representation to expose to the ToC. - """ - needs = SphinxNeedsData(app.env).get_needs_mutable() - rubrics: list[nodes.Element] = [] - - for need_node in doctree.findall(Need): - need_id = need_node.get("refid") - if not isinstance(need_id, str): - continue - - need = needs.get(need_id) - if need is None or need["template"] not in complex_templates: - continue - - # Only direct rubrics are report headings. Rubrics nested in dropdowns - # are control labels and should not become navigation entries. - rubrics.extend( - child for child in need_node.children if isinstance(child, nodes.rubric) - ) - - return rubrics - - -def _add_template_rubrics_to_toc(app: Sphinx, doctree: nodes.document) -> None: - """Expose headings inside marked Need templates in the local page ToC. - - The regular Sphinx ToC collector deliberately ignores sections nested in - arbitrary container nodes such as a Sphinx-Needs Need. This handler runs - after that collector, assigns IDs directly to the rendered rubrics, and - adds matching local references to its already-built ToC. It therefore - changes navigation only; the generated report content stays inside the - Need node and does not have to be moved or rendered twice. - """ - if app.builder.name != "html": - return - - complex_templates = _complex_template_names(app) - if not complex_templates: - return - - rubrics = _template_rubrics(app, doctree, complex_templates) - if not rubrics: - return - - docname = app.env.current_document.docname - toc = app.env.tocs.get(docname) - if toc is None: - return - - used_ids = { - node_id - for element in doctree.findall(nodes.Element) - for node_id in element.get("ids", []) - } - - for rubric in rubrics: - rubric_ids = rubric.get("ids", []) - if rubric_ids: - rubric_id = rubric_ids[0] - else: - rubric_id = nodes.make_id(f"{docname}-{rubric.astext()}") - if not rubric_id: - rubric_id = f"{docname}-template-heading" - - candidate = rubric_id - suffix = 2 - while candidate in used_ids: - candidate = f"{rubric_id}-{suffix}" - suffix += 1 - rubric_id = candidate - rubric["ids"] = [rubric_id] - used_ids.add(rubric_id) - - reference = nodes.reference( - "", - "", - *(child.deepcopy() for child in rubric.children), - internal=True, - refuri=docname, - anchorname=f"#{rubric_id}", - ) - toc += nodes.list_item("", addnodes.compact_paragraph("", "", reference)) - - app.env.toc_num_entries[docname] += len(rubrics) - - def _capture_template_environment(app: Sphinx) -> None: """Give the link helper the environment in which it should resolve Needs. @@ -236,10 +164,7 @@ def setup(app: Sphinx) -> dict[str, object]: ) app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) app.connect("builder-inited", _capture_template_environment) - # The priority is intentionally after Sphinx's environment collectors - # (default 500), so the handler can augment the collector-owned ToC. - app.connect("doctree-read", _add_template_rubrics_to_toc, priority=600) - app.connect("env-updated", _rerender_pages_with_complex_templates) + app.connect("env-updated", _rerender_pages_with_complex_post_templates) return { "version": "1.0.0", diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/mod_ver_report_tiny.need index 39d774a59..21e9917f7 100644 --- a/src/needs_templates/mod_ver_report_tiny.need +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -1,23 +1,60 @@ {# 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. + Content template for the ``mod_ver_report`` need type. + + The report points to its module via ``belongs_to``. The module's + ``includes`` links provide the components, and each component's + ``belongs_to`` link provides the feature. The ``linked_needs`` helper resolves + this graph during the post-collection reread, so titles and report sections + stay driven by the Need model. + + The template is applied as ``:post_template:``, not ``:template:``. A need's + *content* cannot open new sections ("Unexpected section title"), but + post-content is placed after the need at document level, where real headings + work — and real headings are what give the report its TOC entries and + per-component navigation. #} {% 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 }}: +{% set component_prefix = "comp__" ~ module_short ~ "_" %} +{% set report_components = linked_needs(module_id, "includes") %} +{% set first_component = report_components|first|default(None) %} +{% set report_features = linked_needs(first_component["id"], "belongs_to") if first_component else [] %} +{% set report_feature = report_features|first|default(None) %} +{% set feature_id = report_feature["id"] if report_feature else "" %} +{% set feature_title = report_feature["title"] if report_feature else "Feature" %} + +{% set component_workproducts = [ + ["wp__requirements_inspect", "Requirements Inspection"], + ["wp__sw_arch_verification", "Architecture Inspection"], + ["wp__sw_implementation_inspection", "Implementation Inspection"], + ["wp__sw_component_dfa", "DFA"], + ["wp__sw_component_fmea", "FMEA"], + ] %} +{% set feature_workproducts = [ + ["wp__requirements_inspect", "Requirements Inspection"], + ["wp__sw_arch_verification", "Architecture Inspection"], + ] %} + +{#- One work-product row: the need link, its kind, the realising document and + its status. Both cells are needtables over the same filter, differing only + in :columns:, so an empty match renders as an empty cell. -#} +{% macro workproduct_rows(slug_norm, workproducts) %} +{%- for wp in workproducts %} + * - :need:`{{ wp[0] }}` + - {{ wp[1] }} + - .. needtable:: + :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes + :columns: id + :style: table + - .. needtable:: + :filter: type == "document" and "{{ slug_norm }}" in id.replace("_", "").lower() and "{{ wp[0] }}" in realizes + :columns: status + :style: table +{%- endfor %} +{% endmacro %} .. raw:: html @@ -45,17 +82,21 @@ .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } -This document provides a verification report of the Baselibs feature and its -associated components. +{#- ===================================================================== -#} +{#- Feature section resolved from the first component's belongs_to link. -#} +{#- ===================================================================== -#} +{% set feature_slug_norm = module_slug %} -.. rubric:: Feature +{{ feature_title }} +{{ "-" * (feature_title|length) }} .. needtable:: :filter: id == "{{ feature_id }}" :columns: title as "Name";id as "Id";safety;security;status :style: table -.. rubric:: Feature Requirements Statistics +Feature Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -77,9 +118,9 @@ associated components. :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 + type == "feat_req" and "{{ feature_id }}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "feat_req" and "{{ feature_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "feat_req" and "{{ feature_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) .. dropdown:: Show requirements table :animate: fade-in @@ -91,7 +132,8 @@ associated components. :colwidths: 13,22,8,10,23,24 :sort: id -.. rubric:: Feature Architecture Statistics +Feature Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -126,7 +168,8 @@ associated components. :colwidths: 25,30,10,15,20 :sort: id -.. rubric:: Feature Inspection Statistics +Feature Inspection Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Presence of the feature-level inspection work products. @@ -142,51 +185,41 @@ Presence of the feature-level inspection work products. - 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 +{{ workproduct_rows(feature_slug_norm, feature_workproducts) }} -{% set report_components = linked_needs(module_id, "includes") %} +{# ===================================================================== #} +{# Components #} +{# ===================================================================== #} -.. rubric:: Components +Components +---------- -.. rubric:: Component Overview +Component Overview +~~~~~~~~~~~~~~~~~~ .. needtable:: - :filter: type == "comp" and "{{ module_id }}" in includes_back + :filter: id in [{% for component in report_components %}"{{ component["id"] }}"{% if not loop.last %}, {% endif %}{% endfor %}] :columns: id as "Component";safety;security;status :style: table :sort: id -{% macro render_component(component_id, component_title, component_slug) %} -.. raw:: html +{% for component in report_components %} +{% set component_id = component["id"] %} +{% set component_slug = component_id|replace(component_prefix, "") %} +{% set component_slug_norm = component_slug|replace("_", "")|lower %} +{% set component_title = component["title"] %} - +.. _comp-{{ component_slug|replace("_", "-")|lower }}: -.. rubric:: {{ component_title }} +{{ component_title }} +{{ "~" * (component_title|length) }} .. raw:: html
-.. rubric:: Component Requirements Statistics +Component Requirements Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. grid:: 1 2 2 2 :gutter: 3 @@ -208,11 +241,12 @@ Presence of the feature-level inspection work products. :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 + type == "comp_req" and "{{ component_id }}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{{ component_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{{ component_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) -.. rubric:: Component Architecture Statistics +Component Architecture Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. grid:: 1 2 2 2 :gutter: 3 @@ -237,10 +271,11 @@ Presence of the feature-level inspection work products. 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 -.. rubric:: Requirements Traceability +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: +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 @@ -252,7 +287,8 @@ their verification status and the tests that (fully or partially) verify them: :colwidths: 13,22,8,10,23,24 :sort: id -.. rubric:: Test Coverage +Test Coverage +^^^^^^^^^^^^^ Per-source-file line and branch coverage aggregated from the LCOV report produced by ``bazel coverage``. @@ -266,12 +302,13 @@ produced by ``bazel coverage``. with the corresponding targets and rebuild the docs to populate this table. -.. rubric:: Architectural Elements +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. +inspected carry the ``inspected`` tag; elements without that tag have not +yet been inspected. .. dropdown:: Show architectural elements table :animate: fade-in @@ -283,7 +320,8 @@ been inspected. :colwidths: 25,30,10,15,20 :sort: id -.. rubric:: Verification & Safety Analysis Documents +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. @@ -300,62 +338,5 @@ this component. A dash (``—``) means the corresponding document is missing. - 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 ~ "_", "") -) }} +{{ workproduct_rows(component_slug_norm, component_workproducts) }} {% endfor %} From ac4ab8e7f3d05754f52637761ef9f627f2143876 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 27 Aug 2026 22:41:56 +0200 Subject: [PATCH 05/12] simplify template --- src/needs_templates/mod_ver_report_tiny.need | 61 ++++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/mod_ver_report_tiny.need index 21e9917f7..917b8be4c 100644 --- a/src/needs_templates/mod_ver_report_tiny.need +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -16,15 +16,19 @@ per-component navigation. #} {% set module_id = belongs_to|first|default("") %} -{% set module_short = module_id|replace("mod__", "") %} -{% set module_slug = module_short|replace("_", "")|lower %} -{% set component_prefix = "comp__" ~ module_short ~ "_" %} -{% set report_components = linked_needs(module_id, "includes") %} -{% set first_component = report_components|first|default(None) %} -{% set report_features = linked_needs(first_component["id"], "belongs_to") if first_component else [] %} -{% set report_feature = report_features|first|default(None) %} -{% set feature_id = report_feature["id"] if report_feature else "" %} -{% set feature_title = report_feature["title"] if report_feature else "Feature" %} +{# Resolve the component list from the module's outgoing graph links. A module + may list a component more than once, so deduplicate the NeedItems by ID. #} +{% set components_in_mod = linked_needs(module_id, "includes")|unique(attribute="id")|list %} + +{# Collect every feature reachable from the module's components. A feature can + be linked by multiple components, so collect all candidates first and then + keep each feature NeedItem only once in first-seen graph order. The + namespace is required because assignments inside a Jinja loop are scoped. #} +{% set feature_candidates = namespace(items=[]) %} +{% for component in components_in_mod %} +{% set feature_candidates.items = feature_candidates.items + linked_needs(component["id"], "belongs_to") %} +{% endfor %} +{% set report_features = feature_candidates.items|unique(attribute="id")|list %} {% set component_workproducts = [ ["wp__requirements_inspect", "Requirements Inspection"], @@ -83,20 +87,26 @@ {#- ===================================================================== -#} -{#- Feature section resolved from the first component's belongs_to link. -#} +{#- Feature sections resolved from all components' belongs_to links. -#} {#- ===================================================================== -#} -{% set feature_slug_norm = module_slug %} +{% for report_feature in report_features %} +{% set feature_id = report_feature["id"] %} +{% set feature_title = report_feature["title"] %} +{# Derive the work-product document selector from the feature Need reached + through the graph, rather than reconstructing it from the module ID. #} +{% set feature_slug_norm = feature_title|replace("_", "")|replace(" ", "")|lower %} +{% set feature_heading = feature_title if report_features|length == 1 else "Feature: " ~ feature_title %} -{{ feature_title }} -{{ "-" * (feature_title|length) }} +{{ feature_heading }} +{{ "-" * (feature_heading|length) }} .. needtable:: :filter: id == "{{ feature_id }}" :columns: title as "Name";id as "Id";safety;security;status :style: table -Feature Requirements Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -132,8 +142,8 @@ Feature Requirements Statistics :colwidths: 13,22,8,10,23,24 :sort: id -Feature Architecture Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -168,8 +178,8 @@ Feature Architecture Statistics :colwidths: 25,30,10,15,20 :sort: id -Feature Inspection Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Inspection Statistics +~~~~~~~~~~~~~~~~~~~~~ Presence of the feature-level inspection work products. @@ -186,6 +196,7 @@ Presence of the feature-level inspection work products. - Realized by - Status {{ workproduct_rows(feature_slug_norm, feature_workproducts) }} +{% endfor %} {# ===================================================================== #} {# Components #} @@ -198,18 +209,20 @@ Component Overview ~~~~~~~~~~~~~~~~~~ .. needtable:: - :filter: id in [{% for component in report_components %}"{{ component["id"] }}"{% if not loop.last %}, {% endif %}{% endfor %}] + :filter: id in [{% for component in components_in_mod %}"{{ component["id"] }}"{% if not loop.last %}, {% endif %}{% endfor %}] :columns: id as "Component";safety;security;status :style: table :sort: id -{% for component in report_components %} +{% for component in components_in_mod %} {% set component_id = component["id"] %} -{% set component_slug = component_id|replace(component_prefix, "") %} -{% set component_slug_norm = component_slug|replace("_", "")|lower %} {% set component_title = component["title"] %} +{# The component Need reached through the graph supplies the component's + document selector and navigation anchor. #} +{% set component_slug_norm = component_title|replace("_", "")|replace(" ", "")|lower %} +{% set component_anchor = component_title|replace("_", "-")|replace(" ", "-")|lower %} -.. _comp-{{ component_slug|replace("_", "-")|lower }}: +.. _comp-{{ component_anchor }}: {{ component_title }} {{ "~" * (component_title|length) }} From 417d4d593382071cb5a4f05a126bfffe5ec967e3 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 13:38:40 +0200 Subject: [PATCH 06/12] feat(tools): add module-verification-reports gallery command Downstream SCORE repositories currently have no independent, at-a-glance view of whether their module verification report builds pass. This adds a standalone tool that checks out each configured repository, runs its docs build, and aggregates the results into a single gallery whose index page is designed for non-developers: a plain-language summary count, status badges, and collapsed failure details instead of a raw technical table. --- BUILD | 7 + tools/BUILD | 32 + tools/module_verification_reports.py | 955 ++++++++++++++++++ tools/module_verification_reports.toml | 25 + tools/tests/BUILD | 25 + .../tests/module_verification_reports_test.py | 657 ++++++++++++ 6 files changed, 1701 insertions(+) create mode 100644 tools/BUILD create mode 100644 tools/module_verification_reports.py create mode 100644 tools/module_verification_reports.toml create mode 100644 tools/tests/BUILD create mode 100644 tools/tests/module_verification_reports_test.py diff --git a/BUILD b/BUILD index 4d7ca80fc..d28698186 100644 --- a/BUILD +++ b/BUILD @@ -56,6 +56,7 @@ docs( ], code_targets = [ "//scripts_bazel:sources", + "//tools:module_verification_reports", "//src:all_sources", ], source_dir = "docs", @@ -72,3 +73,9 @@ alias( name = "actionlint", actual = "@score_devcontainer//tools:actionlint", ) + +# gallery for the standalone module verification report. +alias( + name = "module_verification_reports", + actual = "//tools:module_verification_reports", +) diff --git a/tools/BUILD b/tools/BUILD new file mode 100644 index 000000000..741378790 --- /dev/null +++ b/tools/BUILD @@ -0,0 +1,32 @@ +# ******************************************************************************* +# 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_binary") + +# Standalone maintenance tools for this repository (as opposed to +# //scripts_bazel, which holds executables invoked by Bazel rules). Not part +# of the public API of this module: visibility is limited to this repo so +# downstream consumers depending on score_docs_as_code via bzlmod cannot pick +# these up as dependencies. +package(default_visibility = ["//:__subpackages__"]) + +py_binary( + name = "module_verification_reports", + srcs = ["module_verification_reports.py"], + data = glob([ + "module_verification_profiles/*.toml", + "module_verification_reports_goldens/**", + ], allow_empty = True), + main = "module_verification_reports.py", + deps = [], +) diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py new file mode 100644 index 000000000..74d287852 --- /dev/null +++ b/tools/module_verification_reports.py @@ -0,0 +1,955 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Build disposable module-verification report galleries for SCORE modules. + +The command in this file deliberately owns no downstream source. It keeps a +shallow checkout in the shared repository cache, overlays the local +``score_docs_as_code`` checkout for one build, and restores the checkout in a +``finally`` block. Keeping the orchestration here (rather than in a Bazel +rule) is important: the downstream repository is the workspace for the docs +build and must remain a normal, independently configured Bzlmod workspace. +""" + +from __future__ import annotations + +import argparse +import contextlib +import dataclasses +import fcntl +import html +import json +import os +import re +import shutil +import subprocess +import sys +import time +import tomllib +from collections.abc import Callable, Iterator, Mapping, Sequence +from pathlib import Path +from urllib.parse import urlparse + +GITHUB_ORG = "eclipse-score" +TEMPLATE_NAME = "mod_ver_report_tiny" +DOCS_COMMAND = ("bazel", "run", "--lockfile_mode=off", "//:docs") +REPORT_DIRECTORY = "module_verification_reports" +REPORT_GOLDEN_DIRECTORY = "module_verification_reports_goldens" +REF_NAMESPACE = "refs/score-docs-as-code" +FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$") + + +def _progress(message: str) -> None: + """Report a step to stderr so long git/bazel operations do not look stuck.""" + + print(message, file=sys.stderr, flush=True) + + +class ReportToolError(RuntimeError): + """A user-actionable failure while preparing or rendering a report.""" + + +@dataclasses.dataclass(frozen=True) +class RepositorySpec: + """One repository entry from a gallery profile.""" + + name: str + revision: str + remote: str = "" + + @property + def git_remote(self) -> str: + return self.remote or f"https://github.com/{GITHUB_ORG}/{self.name}.git" + + +@dataclasses.dataclass(frozen=True) +class Profile: + name: str + repositories: tuple[RepositorySpec, ...] + + +@dataclasses.dataclass +class PreparedCheckout: + """The synchronized checkout and the revisions associated with a run.""" + + spec: RepositorySpec + path: Path + default_revision: str + resolved_revision: str + + +@dataclasses.dataclass +class RepositoryResult: + repository: str + requested_revision: str + resolved_sha: str | None + status: str + report_path: str | None = None + details: str = "" + + def manifest_entry(self) -> dict[str, str | None]: + return { + "repository": self.repository, + "revision": self.requested_revision, + "resolved_sha": self.resolved_sha, + "report_path": self.report_path, + "command": " ".join(DOCS_COMMAND), + "status": self.status, + "details": self.details, + } + + +def resolve_cache_dir( + cache_dir: Path | str | None = None, + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + """Resolve the cache root using the repo-cache contract. + + ``XDG_CACHE_HOME`` is used only when it is non-empty. An explicit command + line path always wins, which makes the command straightforward to use in + tests and CI jobs. + """ + + if cache_dir is not None: + return Path(cache_dir).expanduser() + env = os.environ if environ is None else environ + xdg_cache_home = env.get("XDG_CACHE_HOME", "") + if xdg_cache_home: + return Path(xdg_cache_home).expanduser() / "repo-cache" + return (Path.home() if home is None else home) / ".cache" / "repo-cache" + + +def _repositories_toml_path(config_dir: Path | None = None) -> Path: + if config_dir is not None: + return Path(config_dir) / "module_verification_reports.toml" + tools = Path(__file__).resolve().parent + return tools / "module_verification_reports.toml" + + +def _parse_repository_entry( + entry: Mapping[str, object], profile_name: str, path: Path, seen: set[str] +) -> RepositorySpec: + name = entry.get("name") + remote = entry.get("remote", "") + if not isinstance(name, str) or not name: + raise ReportToolError(f"repository entry is missing a name in {path}: {name!r}") + if name in seen: + raise ReportToolError(f"duplicate repository in {path}: {name}") + if not isinstance(remote, str): + raise ReportToolError(f"remote for {name} must be a string") + if profile_name == "main": + revision = "main" + else: + pinned = entry.get("pinned") + if not isinstance(pinned, str) or not FULL_SHA.fullmatch(pinned): + raise ReportToolError( + f"pinned profile revision for {name} is not a full SHA: {pinned!r}" + ) + revision = pinned + seen.add(name) + return RepositorySpec(name, revision, remote) + + +def load_profile(profile_name: str, config_dir: Path | None = None) -> Profile: + """Load and validate a named profile from the shared repositories file.""" + + if profile_name not in {"main", "pinned"}: + raise ReportToolError(f"unknown profile {profile_name!r}; use main or pinned") + path = _repositories_toml_path(config_dir) + try: + raw_data = tomllib.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ReportToolError(f"profile file does not exist: {path}") from exc + except tomllib.TOMLDecodeError as exc: + raise ReportToolError(f"invalid profile {path}: {exc}") from exc + + raw_repositories = raw_data.get("repositories") + if not isinstance(raw_repositories, list) or not all( + isinstance(item, dict) for item in raw_repositories + ): + raise ReportToolError( + f"repositories file must define repository tables: {path}" + ) + + repositories: list[RepositorySpec] = [] + seen: set[str] = set() + repositories.extend( + _parse_repository_entry(entry, profile_name, path, seen) + for entry in raw_repositories + ) + + if not repositories: + raise ReportToolError(f"repositories file contains no repositories: {path}") + return Profile(profile_name, tuple(repositories)) + + +def select_repositories( + profile: Profile, names: Sequence[str] | None +) -> tuple[RepositorySpec, ...]: + """Apply repeated ``--repo`` filters, bounded by the profile's repository count.""" + + limit = len(profile.repositories) + requested = list(names or ()) + if len(requested) > limit: + raise ReportToolError(f"at most {limit} --repo filters may be supplied") + if not requested: + selected = list(profile.repositories) + else: + by_name = {repository.name: repository for repository in profile.repositories} + unknown = [name for name in requested if name not in by_name] + if unknown: + raise ReportToolError( + "repository is not in the selected profile: " + ", ".join(unknown) + ) + if len(set(requested)) != len(requested): + raise ReportToolError("a repository may be requested only once") + selected = [by_name[name] for name in requested] + if not 1 <= len(selected) <= limit: + raise ReportToolError(f"select between 1 and {limit} repositories") + return tuple(selected) + + +def _canonical_remote(remote: str) -> str: + value = remote.strip().rstrip("/") + if value.startswith("git@") and ":" in value: + host, path = value.split(":", 1) + value = f"https://{host.removeprefix('git@')}/{path}" + elif value.startswith("ssh://"): + parsed = urlparse(value) + value = f"https://{parsed.hostname}/{parsed.path.lstrip('/')}" + elif value.startswith("http://"): + value = "https://" + value.removeprefix("http://") + return value.removesuffix(".git").rstrip("/") + + +def _remote_matches(actual: str, expected: str) -> bool: + return _canonical_remote(actual) == _canonical_remote(expected) + + +def _command_text(result: subprocess.CompletedProcess[str]) -> str: + output = (result.stderr or result.stdout or "").strip() + output = re.sub(r"\s+", " ", output) + return output[-900:] + + +class CheckoutManager: + """Prepare and restore synchronized disposable downstream checkouts.""" + + def __init__( + self, + cache_dir: Path, + source_root: Path, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + ) -> None: + self.cache_dir = Path(cache_dir) + self.source_root = Path(source_root).resolve() + self.runner = runner + + def repository_path(self, repository: str) -> Path: + return self.cache_dir / GITHUB_ORG / repository + + def lock_path(self, repository: str) -> Path: + # The namespace intentionally differs from repo_policy_sync's lock + # namespace. The lock is outside the checkout so git clean cannot + # remove it, and it is also outside the local override symlink. + return self.cache_dir / ".locks" / "score-docs-as-code" / f"{repository}.lock" + + @contextlib.contextmanager + def _lock(self, repository: str) -> Iterator[None]: + lock_path = self.lock_path(repository) + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def _run_git( + self, path: Path, arguments: Sequence[str] + ) -> subprocess.CompletedProcess[str]: + result = self.runner( + ["git", *arguments], + cwd=path, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + command = "git " + " ".join(arguments) + detail = _command_text(result) + raise ReportToolError( + f"{command} failed in {path}" + (f": {detail}" if detail else "") + ) + return result + + def _revision(self, path: Path) -> str: + result = self._run_git(path, ["rev-parse", "--verify", "HEAD^{commit}"]) + return result.stdout.strip() + + def _record_default_ref(self, path: Path, repository: str, revision: str) -> None: + self._run_git( + path, + ["update-ref", f"{REF_NAMESPACE}/{repository}/default", revision], + ) + + def _synchronize_default( + self, spec: RepositorySpec, path: Path, *, clone: bool + ) -> str: + if clone: + _progress(f"{spec.name}: cloning {spec.git_remote}") + path.parent.mkdir(parents=True, exist_ok=True) + result = self.runner( + [ + "gh", + "repo", + "clone", + spec.git_remote, + str(path), + "--", + "--depth", + "1", + "--branch", + "main", + ], + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + detail = _command_text(result) + raise ReportToolError( + f"initial clone failed for {spec.name}" + + (f": {detail}" if detail else "") + ) + else: + origin = self._run_git(path, ["remote", "get-url", "origin"]).stdout.strip() + if not _remote_matches(origin, spec.git_remote): + raise ReportToolError( + f"origin mismatch for {spec.name}: expected {spec.git_remote}, got {origin}" + ) + _progress(f"{spec.name}: fetching origin/main") + + self._run_git(path, ["fetch", "--depth=1", "origin", "main"]) + self._run_git(path, ["checkout", "--detach", "--force", "FETCH_HEAD"]) + self._run_git(path, ["clean", "-fdx"]) + revision = self._revision(path) + self._record_default_ref(path, spec.name, revision) + _progress(f"{spec.name}: default checkout at {revision[:12]}") + return revision + + def _select_pinned(self, spec: RepositorySpec, path: Path) -> str: + _progress(f"{spec.name}: checking out pinned {spec.revision[:12]}") + self._run_git(path, ["fetch", "--depth=1", "origin", spec.revision]) + self._run_git(path, ["checkout", "--detach", "--force", spec.revision]) + resolved = self._revision(path) + if resolved.lower() != spec.revision.lower(): + raise ReportToolError( + f"pinned checkout for {spec.name} resolved to {resolved}, " + f"expected {spec.revision}" + ) + self._run_git(path, ["clean", "-fdx"]) + return resolved + + @contextlib.contextmanager + def checkout(self, spec: RepositorySpec) -> Iterator[PreparedCheckout]: + path = self.repository_path(spec.name) + default_revision: str | None = None + active_error: BaseException | None = None + with self._lock(spec.name): + try: + if path.exists() and not path.is_dir(): + raise ReportToolError(f"checkout path is not a directory: {path}") + default_revision = self._synchronize_default( + spec, path, clone=not path.exists() + ) + resolved = ( + self._select_pinned(spec, path) + if spec.revision != "main" + else default_revision + ) + yield PreparedCheckout(spec, path, default_revision, resolved) + except BaseException as exc: + active_error = exc + raise + finally: + if default_revision is not None and path.is_dir(): + try: + self._run_git( + path, + ["checkout", "--detach", "--force", default_revision], + ) + self._run_git(path, ["clean", "-fdx"]) + self._record_default_ref(path, spec.name, default_revision) + except ReportToolError as restore_error: + if active_error is None: + raise + raise ReportToolError( + f"could not restore checkout {path}: {restore_error}" + ) from active_error + + +def _strip_score_docs_overrides(content: str) -> str: + """Remove existing score_docs_as_code override blocks from MODULE.bazel.""" + + lines = content.splitlines(keepends=True) + result: list[str] = [] + index = 0 + while index < len(lines): + line = lines[index] + if re.match(r"^\s*\w+_override\s*\(", line): + start = index + depth = line.count("(") - line.count(")") + index += 1 + while index < len(lines) and depth > 0: + depth += lines[index].count("(") - lines[index].count(")") + index += 1 + block = "".join(lines[start:index]) + if 'module_name = "score_docs_as_code"' in block or ( + "module_name = 'score_docs_as_code'" in block + ): + continue + result.extend(lines[start:index]) + continue + result.append(line) + index += 1 + return "".join(result) + + +_SCORE_DEP = re.compile( + r"(?ms)^[ \t]*bazel_dep\s*\(\s*name\s*=\s*['\"]score_docs_as_code['\"][^)]*\)[ \t]*$" +) + + +def local_module_override( + module_bazel: str, override_path: str = "../docs_as_code" +) -> str: + """Return MODULE.bazel with the local docs-as-code override installed.""" + + base = _strip_score_docs_overrides(module_bazel) + replacement = ( + 'bazel_dep(name = "score_docs_as_code")\n' + "local_path_override(\n" + ' module_name = "score_docs_as_code",\n' + f' path = "{override_path}"\n' + ")" + ) + updated, count = _SCORE_DEP.subn(replacement, base, count=1) + if count != 1: + raise ReportToolError( + "MODULE.bazel does not contain a bazel_dep for score_docs_as_code" + ) + return updated + + +def report_need(repository: str) -> str: + """Return the exact temporary Need page injected into a checkout.""" + + display_name = repository.replace("_", " ").title() + return ( + f".. auto_mod_ver_report:: {display_name} Module Verification Report\n" + f" :id: auto_mod_ver_report__{repository}\n" + f" :post_template: {TEMPLATE_NAME}\n" + f" :belongs_to: mod__{repository}\n" + f" :version: 1\n" + ) + + +def _docs_source_dir(checkout: Path) -> Path: + build_file = next( + ( + candidate + for candidate in (checkout / "BUILD", checkout / "BUILD.bazel") + if candidate.is_file() + ), + None, + ) + if build_file is None: + raise ReportToolError(f"root BUILD file is missing in {checkout}") + contents = build_file.read_text(encoding="utf-8") + docs_call = re.search(r"\bdocs\s*\((?P.*?)\n?\)", contents, re.DOTALL) + source_dir = "docs" + if docs_call: + match = re.search( + r"\bsource_dir\s*=\s*['\"]([^'\"]+)['\"]", docs_call.group("body") + ) + if match: + source_dir = match.group(1) + source = checkout / source_dir + if not (source / "index.rst").is_file(): + raise ReportToolError( + f"root documentation index is missing: {source / 'index.rst'}" + ) + return source + + +class DownstreamInjection: + """Apply and exactly restore the temporary downstream documentation edits.""" + + def __init__(self, checkout: Path, repository: str, source_root: Path) -> None: + self.checkout = checkout + self.repository = repository + self.source_root = source_root.resolve() + self.module_path = checkout / "MODULE.bazel" + self.docs_source: Path | None = None + self.index_path: Path | None = None + self.page_path: Path | None = None + self._module_original: bytes | None = None + self._index_original: bytes | None = None + self._created_page_dir = False + + def _ensure_override_link(self) -> None: + # Keep the sibling name used by the existing downstream consumer + # compatibility tests. The git ref/lock namespace remains separate + # from that test suite's namespace. + link = self.checkout.parent / "docs_as_code" + if link.is_symlink(): + if link.resolve() != self.source_root: + raise ReportToolError(f"local override link points elsewhere: {link}") + elif link.exists(): + raise ReportToolError(f"local override path is not a symlink: {link}") + else: + link.symlink_to(self.source_root, target_is_directory=True) + + def _toctree_block(self) -> str: + docname = f"{REPORT_DIRECTORY}/{self.repository}" + return ( + f".. score-docs-as-code: module-verification-report begin\n\n" + ".. toctree::\n" + " :hidden:\n\n" + f" {docname}\n\n" + f".. score-docs-as-code: module-verification-report end\n" + ) + + def apply(self) -> None: + if not self.module_path.is_file(): + raise ReportToolError(f"MODULE.bazel is missing in {self.checkout}") + self.docs_source = _docs_source_dir(self.checkout) + self.index_path = self.docs_source / "index.rst" + index_text = self.index_path.read_text(encoding="utf-8") + if ".. toctree::" not in index_text: + raise ReportToolError(f"root index has no toctree: {self.index_path}") + + self._ensure_override_link() + original_module = self.module_path.read_bytes() + original_index = self.index_path.read_bytes() + self._module_original = original_module + self._index_original = original_index + + newline = "\r\n" if b"\r\n" in original_index else "\n" + page_dir = self.docs_source / REPORT_DIRECTORY + if (page_dir / f"{self.repository}.rst").exists(): + raise ReportToolError(f"temporary report page already exists: {page_dir}") + self._created_page_dir = not page_dir.exists() + page_dir.mkdir(parents=True, exist_ok=True) + self.page_path = page_dir / f"{self.repository}.rst" + self.page_path.write_text( + report_need(self.repository), encoding="utf-8", newline=newline + ) + self.module_path.write_text( + local_module_override(original_module.decode("utf-8")), encoding="utf-8" + ) + self.index_path.write_text( + index_text.rstrip() + newline + newline + self._toctree_block(), + encoding="utf-8", + newline=newline, + ) + + def restore(self) -> None: + errors: list[str] = [] + if self._module_original is not None: + try: + self.module_path.write_bytes(self._module_original) + except OSError as exc: + errors.append(f"MODULE.bazel: {exc}") + if self._index_original is not None and self.index_path is not None: + try: + self.index_path.write_bytes(self._index_original) + except OSError as exc: + errors.append(f"index.rst: {exc}") + if self.page_path is not None: + try: + self.page_path.unlink(missing_ok=True) + if self._created_page_dir: + self.page_path.parent.rmdir() + except OSError as exc: + errors.append(f"generated report page: {exc}") + if errors: + raise ReportToolError( + "could not restore downstream injection: " + "; ".join(errors) + ) + + def __enter__(self) -> DownstreamInjection: + try: + self.apply() + except Exception: + self.restore() + raise + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: + self.restore() + return False + + +def _flatten_needs(needs_json: Path) -> dict[str, dict[str, object]]: + try: + payload = json.loads(needs_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ReportToolError( + f"cannot read generated needs JSON {needs_json}: {exc}" + ) from exc + if not isinstance(payload, dict) or not isinstance(payload.get("versions"), dict): + raise ReportToolError(f"generated needs JSON has no versions map: {needs_json}") + result: dict[str, dict[str, object]] = {} + for version in payload["versions"].values(): + if not isinstance(version, dict) or not isinstance(version.get("needs"), dict): + continue + for need_id, need in version["needs"].items(): + if isinstance(need_id, str) and isinstance(need, dict): + result[need_id] = need + return result + + +def _link_ids(need: Mapping[str, object], field: str) -> list[str]: + raw = need.get(field, []) + if isinstance(raw, str): + return [raw] + if not isinstance(raw, list): + return [] + result: list[str] = [] + for value in raw: + if isinstance(value, str): + result.append(value.split("[", 1)[0]) + elif isinstance(value, dict) and isinstance(value.get("id"), str): + result.append(value["id"]) + return result + + +def validate_module_graph( + needs: Mapping[str, Mapping[str, object]], repository: str +) -> None: + """Reject an empty module/component/feature graph before gallery collection. + + Sphinx-needs already rejects a dangling ``includes``/``belongs_to`` link as + part of the docs build itself (``needs.link_outgoing``), including links + resolved through ``external_needs`` (e.g. features maintained centrally in + ``score_platform`` rather than in the module's own repository). Reaching + this function therefore means every linked Need already resolves + *somewhere*, so this only needs to catch what Sphinx-needs does not flag: + an ``includes`` or ``belongs_to`` link list that is simply empty, which the + ``mod_ver_report_tiny`` template would otherwise render as a silently + empty section. + """ + + module_id = f"mod__{repository}" + component_ids = _link_ids(needs.get(module_id, {}), "includes") + if not component_ids: + raise ReportToolError( + f"structural failure: Need {module_id} has no includes graph" + ) + for component_id in component_ids: + component = needs.get(component_id) + if component is None: + # Not present in this repository's own needs.json, e.g. resolved + # through external_needs. Sphinx-needs already validated the link. + continue + if not _link_ids(component, "belongs_to"): + raise ReportToolError( + f"structural failure: component {component_id} has no belongs_to feature" + ) + + +def _copy_report_and_assets( + checkout: PreparedCheckout, repository: str, output_dir: Path +) -> str: + relative_html = Path(REPORT_DIRECTORY) / f"{repository}.html" + source_html = checkout.path / "_build" / relative_html + if not source_html.is_file(): + raise ReportToolError(f"rendered report page is missing: {source_html}") + + destination_repo = output_dir / repository + shutil.rmtree(destination_repo, ignore_errors=True) + destination_repo.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_html, destination_repo / "report.html") + + build_dir = checkout.path / "_build" + # Sphinx pages keep their original depth, so copying these directories to + # the gallery root preserves every relative asset URL in report.html. + for child in build_dir.iterdir(): + if child.name.startswith("_") and child.is_dir(): + shutil.copytree(child, output_dir / child.name, dirs_exist_ok=True) + return f"{repository}/report.html" + + +def _failure_detail(exc: BaseException) -> str: + detail = re.sub(r"\s+", " ", str(exc)).strip() + return detail[-1000:] or type(exc).__name__ + + +def _golden_path(golden_root: Path, profile: str, repository: str) -> Path: + return golden_root / profile / repository / "report.html" + + +def _check_golden( + report_file: Path, golden_root: Path, profile: str, repository: str +) -> None: + golden = _golden_path(golden_root, profile, repository) + if not golden.is_file(): + raise ReportToolError(f"golden file is missing: {golden}") + if report_file.read_bytes() != golden.read_bytes(): + raise ReportToolError(f"golden mismatch: {golden}") + + +def _status_badge(status: str) -> str: + icon, label = ("✓", "Success") if status == "success" else ("✗", "Failure") + return f'{icon} {label}' + + +def _report_cell(result: RepositoryResult) -> str: + if result.status == "success" and result.report_path: + href = html.escape(result.report_path, quote=True) + return f'View report' + reason = html.escape(result.details or "Verification failed") + return f"
Why did it fail?
{reason}
" + + +def _write_gallery_index( + output_dir: Path, profile: Profile, results: Sequence[RepositoryResult] +) -> None: + succeeded = sum(1 for result in results if result.status == "success") + total = len(results) + rows: list[str] = [] + for result in results: + row_class = "failed-row" if result.status != "success" else "" + rows.append( + f'' + f"{html.escape(result.repository)}" + f"{_status_badge(result.status)}" + f"{_report_cell(result)}" + "" + f"requested {html.escape(result.requested_revision)}" + f"
resolved {html.escape(result.resolved_sha or 'unknown')}" + "" + "" + ) + document = ( + """ +Module Verification Reports + + +

Module Verification Reports

+

SUCCEEDED of TOTAL modules verified successfully for profile PROFILE.

+ +ROWS
RepositoryStatusReportRevision
+""".replace("PROFILE", html.escape(profile.name)) + .replace("ROWS", "\n".join(rows)) + .replace("SUCCEEDED", str(succeeded)) + .replace("TOTAL", str(total)) + ) + (output_dir / "index.html").write_text(document, encoding="utf-8") + + +def _write_manifest( + output_dir: Path, profile: Profile, results: Sequence[RepositoryResult] +) -> None: + manifest = { + "profile": profile.name, + "repositories": [result.manifest_entry() for result in results], + } + (output_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def build_gallery( + profile: Profile, + selected: Sequence[RepositorySpec], + *, + source_root: Path, + output_dir: Path, + cache_dir: Path, + golden_root: Path | None = None, + check_goldens: bool = False, + update_goldens: bool = False, + checkout_factory: Callable[ + [RepositorySpec], contextlib.AbstractContextManager[PreparedCheckout] + ] + | None = None, + command_runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> tuple[list[RepositoryResult], bool]: + """Render all selected repositories and write the independent gallery.""" + + if update_goldens and profile.name != "pinned": + raise ReportToolError("--update-goldens is allowed only for the pinned profile") + source_root = Path(source_root).resolve() + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + golden_root = ( + source_root / "scripts_bazel" / REPORT_GOLDEN_DIRECTORY + if golden_root is None + else Path(golden_root) + ) + manager = CheckoutManager(cache_dir, source_root, runner=command_runner) + results: list[RepositoryResult] = [] + total = len(selected) + + for index, spec in enumerate(selected, start=1): + prefix = f"[{index}/{total}] {spec.name}" + result = RepositoryResult(spec.name, spec.revision, None, "failure") + try: + _progress(f"{prefix}: preparing checkout") + session = ( + checkout_factory(spec) + if checkout_factory is not None + else manager.checkout(spec) + ) + with session as checkout: + result.resolved_sha = checkout.resolved_revision + with DownstreamInjection(checkout.path, spec.name, source_root): + _progress( + f"{prefix}: running {' '.join(DOCS_COMMAND)} " + "(a Bazel/Sphinx build; this can take several minutes)" + ) + started = time.monotonic() + completed = command_runner( + list(DOCS_COMMAND), + cwd=checkout.path, + text=True, + capture_output=True, + check=False, + ) + _progress( + f"{prefix}: docs build finished in " + f"{time.monotonic() - started:.0f}s" + ) + if completed.returncode: + output = _command_text(completed) + raise ReportToolError( + f"docs build failed with exit code {completed.returncode}" + + (f": {output}" if output else "") + ) + needs = _flatten_needs(checkout.path / "_build" / "needs.json") + validate_module_graph(needs, spec.name) + report_path = _copy_report_and_assets( + checkout, spec.name, output_dir + ) + result.report_path = report_path + result.status = "success" + if check_goldens: + _check_golden( + output_dir / report_path, + golden_root, + profile.name, + spec.name, + ) + except Exception as exc: + result.status = "failure" + result.report_path = None + result.details = _failure_detail(exc) + shutil.rmtree(output_dir / spec.name, ignore_errors=True) + _progress( + f"{prefix}: {result.status}" + + (f" — {result.details}" if result.details else "") + ) + results.append(result) + + _write_gallery_index(output_dir, profile, results) + _write_manifest(output_dir, profile, results) + + if update_goldens: + failures = [result for result in results if result.status != "success"] + if failures: + raise ReportToolError( + "cannot update goldens while repositories failed: " + + ", ".join(result.repository for result in failures) + ) + for result in results: + assert result.report_path is not None + golden = _golden_path(golden_root, profile.name, result.repository) + golden.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(output_dir / result.report_path, golden) + + strict_failure = profile.name == "pinned" and any( + result.status != "success" for result in results + ) + return results, strict_failure + + +def _workspace_root() -> Path: + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if workspace: + return Path(workspace).resolve() + return Path(__file__).resolve().parents[1] + + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=("main", "pinned"), default="main") + parser.add_argument("--repo", action="append", dest="repositories", metavar="NAME") + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--check-goldens", action="store_true") + parser.add_argument("--update-goldens", action="store_true") + parser.add_argument("--cache-dir", type=Path) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argument_parser() + args = parser.parse_args(argv) + if args.check_goldens and args.update_goldens: + parser.error("--check-goldens and --update-goldens are mutually exclusive") + try: + source_root = _workspace_root() + profile = load_profile(args.profile) + selected = select_repositories(profile, args.repositories) + output_dir = args.output_dir or ( + source_root / "_build" / "module-verification-reports" / profile.name + ) + results, strict_failure = build_gallery( + profile, + selected, + source_root=source_root, + output_dir=output_dir, + cache_dir=resolve_cache_dir(args.cache_dir), + check_goldens=args.check_goldens, + update_goldens=args.update_goldens, + ) + except ReportToolError as exc: + parser.exit(2, f"module-verification-reports: {exc}\n") + + for result in results: + suffix = f" — {result.details}" if result.details else "" + print(f"{result.repository}: {result.status}{suffix}") + return 1 if strict_failure else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/module_verification_reports.toml b/tools/module_verification_reports.toml new file mode 100644 index 000000000..810870532 --- /dev/null +++ b/tools/module_verification_reports.toml @@ -0,0 +1,25 @@ +# Repositories covered by the module-verification report gallery. +# +# The "main" profile always tracks the moving branch tip; "pinned" checks +# out the immutable full SHA captured when the standalone report gallery +# was introduced. + +[[repositories]] +name = "lifecycle" +pinned = "a9ae76df50426f0761f2c487e5fe0bb00baf1745" + +[[repositories]] +name = "baselibs" +pinned = "33aad37ad3d12591b0d662ee37e430eedb1c273c" + +[[repositories]] +name = "inc_someip_gateway" +pinned = "f173ceff11c2a8e70fe3d3bad99afbf22db286e1" + +[[repositories]] +name = "persistency" +pinned = "412fe8290968a045cc403207d35f151d86115c01" + +[[repositories]] +name = "time" +pinned = "4a3a6c5a83fb2fcaa8808a6bb5a54008eb2087e6" diff --git a/tools/tests/BUILD b/tools/tests/BUILD new file mode 100644 index 000000000..e84b502a7 --- /dev/null +++ b/tools/tests/BUILD @@ -0,0 +1,25 @@ +# ******************************************************************************* +# 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("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") +load("//:score_pytest.bzl", "score_pytest") + +score_pytest( + name = "module_verification_reports_test", + srcs = ["module_verification_reports_test.py"], + deps = [ + "//tools:module_verification_reports", + "//src/helper_lib", + ] + all_requirements, + pytest_config = "//:pyproject.toml", +) diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py new file mode 100644 index 000000000..a0e7e3d15 --- /dev/null +++ b/tools/tests/module_verification_reports_test.py @@ -0,0 +1,657 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Unit and local integration coverage for the report gallery command.""" + +from __future__ import annotations + +import contextlib +import json +import subprocess +from pathlib import Path + +import pytest + +from src.helper_lib import find_git_root +from tools.module_verification_reports import ( + DOCS_COMMAND, + REPORT_DIRECTORY, + CheckoutManager, + DownstreamInjection, + PreparedCheckout, + Profile, + ReportToolError, + RepositoryResult, + RepositorySpec, + build_gallery, + load_profile, + local_module_override, + report_need, + resolve_cache_dir, + select_repositories, + validate_module_graph, +) + + +def test_cache_path_prefers_xdg_and_has_default(tmp_path: Path) -> None: + assert resolve_cache_dir(environ={"XDG_CACHE_HOME": str(tmp_path)}) == ( + tmp_path / "repo-cache" + ) + assert resolve_cache_dir(environ={}, home=tmp_path) == ( + tmp_path / ".cache" / "repo-cache" + ) + assert resolve_cache_dir( + tmp_path / "explicit", environ={"XDG_CACHE_HOME": "other"} + ) == (tmp_path / "explicit") + + +def test_profiles_and_filters_are_validated() -> None: + main = load_profile("main") + pinned = load_profile("pinned") + assert [repository.name for repository in main.repositories] == [ + "lifecycle", + "baselibs", + "inc_someip_gateway", + "persistency", + "time", + ] + assert all(repository.revision == "main" for repository in main.repositories) + assert all(len(repository.revision) == 40 for repository in pinned.repositories) + assert [ + repository.name + for repository in select_repositories(main, ["time", "lifecycle"]) + ] == [ + "time", + "lifecycle", + ] + with pytest.raises(ReportToolError, match="at most 5"): + select_repositories(main, ["lifecycle"] * 6) + with pytest.raises(ReportToolError, match="only once"): + select_repositories(main, ["lifecycle", "lifecycle"]) + + +def test_pinned_profile_rejects_non_immutable_revisions(tmp_path: Path) -> None: + (tmp_path / "module_verification_reports.toml").write_text( + '[[repositories]]\nname = "lifecycle"\npinned = "main"\n', + encoding="utf-8", + ) + with pytest.raises(ReportToolError, match="full SHA"): + load_profile("pinned", tmp_path) + + +def test_need_and_module_override_use_the_current_template_selector() -> None: + page = report_need("inc_someip_gateway") + assert ":id: auto_mod_ver_report__inc_someip_gateway" in page + assert ":post_template: mod_ver_report_tiny" in page + assert ":belongs_to: mod__inc_someip_gateway" in page + assert ":version: 1" in page + assert "mod_ver_report\n" not in page + + original = """module(name = "consumer") +bazel_dep(name = "score_docs_as_code", version = "7.0.0") +git_override( + module_name = "score_docs_as_code", + commit = "deadbeef", + remote = "https://example.invalid/docs-as-code.git", +) +""" + updated = local_module_override(original) + assert "local_path_override(" in updated + assert 'path = "../docs_as_code"' in updated + assert "deadbeef" not in updated + + +def _write_fake_checkout(path: Path, repository: str) -> None: + path.mkdir(parents=True) + (path / "MODULE.bazel").write_text( + 'module(name = "fake_consumer")\n' + 'bazel_dep(name = "score_docs_as_code", version = "7.0.0")\n', + encoding="utf-8", + ) + (path / "BUILD").write_text( + 'load("@score_docs_as_code//:docs.bzl", "docs")\n' + 'docs(source_dir = "docs", project = "Fake", project_url = "https://example.invalid")\n', + encoding="utf-8", + ) + docs = path / "docs" + docs.mkdir() + (docs / "index.rst").write_text( + "Fake consumer\n==============\n\n.. toctree::\n :maxdepth: 1\n\n", + encoding="utf-8", + ) + assert repository + + +def test_injection_is_reversible(tmp_path: Path) -> None: + checkout = tmp_path / "consumer" + _write_fake_checkout(checkout, "lifecycle") + module_before = (checkout / "MODULE.bazel").read_bytes() + index_before = (checkout / "docs/index.rst").read_bytes() + with DownstreamInjection(checkout, "lifecycle", Path(__file__).parents[2]): + injected = (checkout / "MODULE.bazel").read_text(encoding="utf-8") + index = (checkout / "docs/index.rst").read_text(encoding="utf-8") + assert "local_path_override(" in injected + assert ".. score-docs-as-code: module-verification-report begin" in index + assert ".. score-docs-as-code: module-verification-report end" in index + assert (checkout / "docs/module_verification_reports/lifecycle.rst").is_file() + assert (checkout / "MODULE.bazel").read_bytes() == module_before + assert (checkout / "docs/index.rst").read_bytes() == index_before + assert not (checkout / "docs/module_verification_reports").exists() + + +def test_missing_graph_is_a_structural_failure() -> None: + with pytest.raises(ReportToolError, match="no includes graph"): + validate_module_graph({"mod__lifecycle": {"id": "mod__lifecycle"}}, "lifecycle") + + +def test_component_without_a_feature_link_is_a_structural_failure() -> None: + with pytest.raises(ReportToolError, match="has no belongs_to feature"): + validate_module_graph( + { + "mod__lifecycle": {"includes": ["comp__lifecycle"]}, + "comp__lifecycle": {}, + }, + "lifecycle", + ) + + +def test_feature_resolved_only_through_external_needs_is_not_a_failure() -> None: + """A component may point at a feature that lives in another repository's + needs.json (e.g. score_platform), reached only through Sphinx-needs' + ``external_needs``. That feature never appears in this repository's own + needs.json, but Sphinx-needs already validated the link during the docs + build, so it must not be treated as a structural failure here. + """ + + validate_module_graph( + { + "mod__lifecycle": {"includes": ["comp__lifecycle"]}, + "comp__lifecycle": {"belongs_to": ["feat__lifecycle"]}, + }, + "lifecycle", + ) + + +class _GitCommandRecorder: + def __init__(self, checkout: Path, *, origin: str) -> None: + self.checkout = checkout + self.origin = origin + self.commands: list[list[str]] = [] + self.revisions = iter(["b" * 40, "a" * 40]) + + def __call__( + self, command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + if command[:3] == ["gh", "repo", "clone"]: + self.checkout.mkdir(parents=True, exist_ok=True) + return subprocess.CompletedProcess(command, 0, "", "") + if command[1:] == ["remote", "get-url", "origin"]: + return subprocess.CompletedProcess(command, 0, self.origin + "\n", "") + if command[1:] == ["rev-parse", "--verify", "HEAD^{commit}"]: + return subprocess.CompletedProcess( + command, 0, next(self.revisions) + "\n", "" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + +def test_checkout_clone_refresh_pinned_selection_and_cleanup(tmp_path: Path) -> None: + source_root = Path(__file__).parents[2] + cache = tmp_path / "cache" + spec = RepositorySpec("lifecycle", "a" * 40) + checkout = cache / "eclipse-score/lifecycle" + recorder = _GitCommandRecorder( + checkout, origin="git@github.com:eclipse-score/lifecycle.git" + ) + manager = CheckoutManager(cache, source_root, runner=recorder) + with manager.checkout(spec) as prepared: + assert prepared.default_revision == "b" * 40 + assert prepared.resolved_revision == "a" * 40 + assert any(command[:3] == ["gh", "repo", "clone"] for command in recorder.commands) + assert ["git", "fetch", "--depth=1", "origin", "main"] in recorder.commands + assert ["git", "fetch", "--depth=1", "origin", "a" * 40] in recorder.commands + assert ["git", "checkout", "--detach", "--force", "b" * 40] in recorder.commands + assert manager.lock_path("lifecycle").parent.name == "score-docs-as-code" + + +def test_cached_checkout_rejects_a_remote_mismatch(tmp_path: Path) -> None: + checkout = tmp_path / "cache/eclipse-score/lifecycle" + checkout.mkdir(parents=True) + recorder = _GitCommandRecorder(checkout, origin="https://example.invalid/other.git") + manager = CheckoutManager( + tmp_path / "cache", Path(__file__).parents[2], runner=recorder + ) + with ( + pytest.raises(ReportToolError, match="origin mismatch"), + manager.checkout(RepositorySpec("lifecycle", "main")), + ): + pass + + +def _fake_result( + returncode: int = 0, stdout: str = "", stderr: str = "" +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(list(DOCS_COMMAND), returncode, stdout, stderr) + + +def test_local_fake_downstream_build_populates_gallery_and_assets( + tmp_path: Path, +) -> None: + source_root = Path(__file__).parents[2] + checkout = tmp_path / "lifecycle" + _write_fake_checkout(checkout, "lifecycle") + output = tmp_path / "gallery" + spec = RepositorySpec("lifecycle", "main") + profile = Profile("main", (spec,)) + + @contextlib.contextmanager + def fake_checkout(_spec: RepositorySpec): + yield PreparedCheckout(spec, checkout, "a" * 40, "a" * 40) + + def fake_command( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + assert command == list(DOCS_COMMAND) + build = checkout / "_build" + (build / "module_verification_reports").mkdir(parents=True) + (build / "module_verification_reports/lifecycle.html").write_text( + "fake report\n", encoding="utf-8" + ) + (build / "_static").mkdir() + (build / "_static/site.css").write_text("body {}\n", encoding="utf-8") + needs = { + "versions": { + "1.0": { + "needs": { + "mod__lifecycle": {"includes": ["comp__lifecycle"]}, + "comp__lifecycle": {"belongs_to": ["feat__lifecycle"]}, + "feat__lifecycle": {"title": "Lifecycle"}, + } + } + } + } + (build / "needs.json").write_text(json.dumps(needs), encoding="utf-8") + return _fake_result() + + results, strict_failure = build_gallery( + profile, + (spec,), + source_root=source_root, + output_dir=output, + cache_dir=tmp_path / "cache", + checkout_factory=fake_checkout, + command_runner=fake_command, + ) + assert not strict_failure + assert results[0].status == "success" + assert (output / "lifecycle/report.html").read_text(encoding="utf-8") == ( + "fake report\n" + ) + assert (output / "_static/site.css").is_file() + assert json.loads((output / "manifest.json").read_text())["repositories"][0][ + "report_path" + ] == ("lifecycle/report.html") + + +def test_local_fake_downstream_runs_the_real_docs_target(tmp_path: Path) -> None: + """Exercise injection and a real Bazel/Sphinx build without network access.""" + + source_root = find_git_root() + if source_root is None: + pytest.skip("a real workspace checkout is required for the nested Bazel smoke") + checkout = tmp_path / "lifecycle" + _write_fake_checkout(checkout, "lifecycle") + # Reuse the repository's docs-as-code fixture through its public external + # macro. The fake checkout remains the Bazel workspace and all inputs are + # local; the test needs no downstream clone or network access. + (checkout / "BUILD").write_text( + 'load("@score_docs_as_code//:docs.bzl", "docs")\n' + 'docs(source_dir = "docs", metamodel = "docs/metamodel.yaml", ' + 'project = "Fake", project_url = "https://example.invalid")\n', + encoding="utf-8", + ) + (checkout / "docs/metamodel.yaml").write_text( + """needs_types: + feat: + title: Feature + prefix: feat__ + parts: 2 + mandatory_options: + id: ^feat__.*$ + security: ^(YES|NO)$ + safety: ^(QM|ASIL_B)$ + status: ^(valid|invalid)$ + version: ^[0-9]+$ + optional_options: + tags: .* + content: .* + template: .* + comp: + title: Component + prefix: comp__ + parts: 2 + mandatory_options: + id: ^comp__.*$ + security: ^(YES|NO)$ + safety: ^(QM|ASIL_B)$ + status: ^(valid|invalid)$ + version: ^[0-9]+$ + mandatory_links: + belongs_to: feat + optional_options: + tags: .* + content: .* + template: .* + mod: + title: Module + prefix: mod__ + parts: 2 + mandatory_options: + id: ^mod__.*$ + security: ^(YES|NO)$ + safety: ^(QM|ASIL_B)$ + status: ^(valid|invalid)$ + version: ^[0-9]+$ + mandatory_links: + includes: comp + optional_options: + tags: .* + content: .* + template: .* + workproduct: + title: Workproduct + prefix: wp__ + parts: 2 + mandatory_options: + id: ^wp__.*$ + status: ^(valid|draft)$ + version: ^[0-9]+$ + optional_options: + tags: .* + content: .* + template: .* + auto_mod_ver_report: + title: Module Verification Report (automated) + prefix: auto_mod_ver_report__ + parts: 2 + mandatory_options: + version: ^[0-9]+$ + mandatory_links: + belongs_to: mod + optional_options: + tags: .* + content: .* + template: .* +links: {} +needs_extra_links: + belongs_to: + incoming: has + outgoing: belongs to + includes: + incoming: included by + outgoing: includes +""", + encoding="utf-8", + ) + (checkout / ".bazelversion").write_bytes( + (source_root / ".bazelversion").read_bytes() + ) + (checkout / ".bazelrc").write_bytes((source_root / ".bazelrc").read_bytes()) + # A real downstream clone always carries its own committed lockfile; + # incremental.py hashes it unconditionally as a build-cache sentinel. + (checkout / "MODULE.bazel.lock").write_text("{}\n", encoding="utf-8") + (checkout / "MODULE.bazel").write_text( + 'module(name = "fake_report_consumer")\n' + 'bazel_dep(name = "rules_python", version = "1.8.5")\n' + 'python = use_extension("@rules_python//python/extensions:python.bzl", "python")\n' + 'python.toolchain(is_default = True, python_version = "3.12")\n' + 'bazel_dep(name = "sphinxdocs", version = "2.2.0")\n' + 'bazel_dep(name = "aspect_rules_py", version = "1.4.0")\n' + 'bazel_dep(name = "buildifier_prebuilt", version = "8.2.0.2")\n' + 'bazel_dep(name = "rules_java", version = "8.15.1")\n' + 'bazel_dep(name = "score_process_description", version = "2.1.2")\n' + 'bazel_dep(name = "score_devcontainer", version = "1.11.0")\n' + 'bazel_dep(name = "score_docs_as_code", version = "4.6.0")\n', + encoding="utf-8", + ) + (checkout / "docs/index.rst").write_text( + """Fake report consumer +===================== + +.. toctree:: + :maxdepth: 1 + +.. feat:: Lifecycle Feature + :id: feat__lifecycle + :security: YES + :safety: ASIL_B + :status: valid + :version: 1 + +.. comp:: Lifecycle Component + :id: comp__lifecycle + :security: YES + :safety: ASIL_B + :status: valid + :belongs_to: feat__lifecycle + :version: 1 + +.. mod:: Lifecycle Module + :id: mod__lifecycle + :security: YES + :safety: ASIL_B + :status: valid + :includes: comp__lifecycle + :version: 1 + +.. workproduct:: Requirements Inspection + :id: wp__requirements_inspect + :status: valid + :version: 1 + +.. workproduct:: Architecture Inspection + :id: wp__sw_arch_verification + :status: valid + :version: 1 + +.. workproduct:: Implementation Inspection + :id: wp__sw_implementation_inspection + :status: valid + :version: 1 + +.. workproduct:: DFA + :id: wp__sw_component_dfa + :status: valid + :version: 1 + +.. workproduct:: FMEA + :id: wp__sw_component_fmea + :status: valid + :version: 1 +""", + encoding="utf-8", + ) + (checkout / "docs/conf.py").write_text( + "project = 'Fake'\n" + "project_url = 'https://example.invalid'\n" + "version = '0.0.0'\n" + "required_in_id = ['lifecycle']\n" + "extensions = ['score_sphinx_bundle']\n", + encoding="utf-8", + ) + # A few docs extensions use the workspace Git root to compute source + # links. A disposable empty repository gives the fake checkout the same + # local-workspace contract as a real downstream clone. + subprocess.run( + ["git", "init", "--quiet", "--initial-branch=main", str(checkout)], + check=True, + text=True, + capture_output=True, + ) + spec = RepositorySpec("lifecycle", "main") + profile = Profile("main", (spec,)) + + @contextlib.contextmanager + def fake_checkout(_spec: RepositorySpec): + yield PreparedCheckout(spec, checkout, "a" * 40, "a" * 40) + + def real_docs_command( + command: list[str], *, cwd: Path, **_: object + ) -> subprocess.CompletedProcess[str]: + assert command == list(DOCS_COMMAND) + return subprocess.run( + command, + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + + results, strict_failure = build_gallery( + profile, + (spec,), + source_root=source_root, + output_dir=tmp_path / "gallery", + cache_dir=tmp_path / "cache", + checkout_factory=fake_checkout, + command_runner=real_docs_command, + ) + assert not strict_failure + assert results[0].status == "success", results[0].details + assert (tmp_path / "gallery/lifecycle/report.html").is_file() + + +def test_main_is_partial_but_pinned_is_strict_on_build_failure(tmp_path: Path) -> None: + source_root = Path(__file__).parents[2] + specs = (RepositorySpec("lifecycle", "main"), RepositorySpec("baselibs", "main")) + + def run( + profile_name: str, revision: str, output: Path + ) -> tuple[list[RepositoryResult], bool]: + profile = Profile( + profile_name, tuple(RepositorySpec(spec.name, revision) for spec in specs) + ) + + @contextlib.contextmanager + def fake_checkout(spec: RepositorySpec): + checkout = tmp_path / f"{profile_name}-{spec.name}" + _write_fake_checkout(checkout, spec.name) + yield PreparedCheckout(spec, checkout, "b" * 40, "b" * 40) + + def partially_failing_command( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + assert command == list(DOCS_COMMAND) + checkout_path = kwargs["cwd"] + assert isinstance(checkout_path, Path) + if checkout_path.name.endswith("lifecycle"): + build = checkout_path / "_build" + (build / REPORT_DIRECTORY).mkdir(parents=True) + (build / f"{REPORT_DIRECTORY}/lifecycle.html").write_bytes( + b"successful report\n" + ) + (build / "needs.json").write_text( + json.dumps( + { + "versions": { + "1": { + "needs": { + "mod__lifecycle": { + "includes": ["comp__lifecycle"] + }, + "comp__lifecycle": { + "belongs_to": ["feat__lifecycle"] + }, + "feat__lifecycle": {}, + } + } + } + } + ), + encoding="utf-8", + ) + return _fake_result() + return _fake_result(1, stderr="intentional build failure") + + return build_gallery( + profile, + specs, + source_root=source_root, + output_dir=output, + cache_dir=tmp_path / "cache", + checkout_factory=fake_checkout, + command_runner=partially_failing_command, + ) + + main_results, main_strict = run("main", "main", tmp_path / "main-gallery") + pinned_results, pinned_strict = run("pinned", "a" * 40, tmp_path / "pinned-gallery") + assert [result.status for result in main_results] == ["success", "failure"] + assert (tmp_path / "main-gallery/lifecycle/report.html").is_file() + assert not main_strict + assert [result.status for result in pinned_results] == ["success", "failure"] + assert (tmp_path / "pinned-gallery/lifecycle/report.html").is_file() + assert pinned_strict + + +def test_pinned_golden_comparison_is_byte_for_byte(tmp_path: Path) -> None: + source_root = Path(__file__).parents[2] + checkout = tmp_path / "lifecycle" + _write_fake_checkout(checkout, "lifecycle") + spec = RepositorySpec("lifecycle", "a" * 40) + profile = Profile("pinned", (spec,)) + + @contextlib.contextmanager + def fake_checkout(_spec: RepositorySpec): + yield PreparedCheckout(spec, checkout, "a" * 40, "a" * 40) + + def fake_command( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + build = checkout / "_build" + (build / "module_verification_reports").mkdir(parents=True) + (build / "module_verification_reports/lifecycle.html").write_bytes(b"report\n") + (build / "needs.json").write_text( + json.dumps( + { + "versions": { + "1": { + "needs": { + "mod__lifecycle": {"includes": ["comp__lifecycle"]}, + "comp__lifecycle": {"belongs_to": ["feat__lifecycle"]}, + "feat__lifecycle": {}, + } + } + } + } + ), + encoding="utf-8", + ) + return _fake_result() + + golden_root = tmp_path / "goldens" + golden = golden_root / "pinned/lifecycle/report.html" + golden.parent.mkdir(parents=True) + golden.write_bytes(b"different\n") + results, strict = build_gallery( + profile, + (spec,), + source_root=source_root, + output_dir=tmp_path / "gallery", + cache_dir=tmp_path / "cache", + golden_root=golden_root, + check_goldens=True, + checkout_factory=fake_checkout, + command_runner=fake_command, + ) + assert results[0].status == "failure" + assert "golden mismatch" in results[0].details + assert strict From 8ec578bdfcbf31df639c90a9488b545b585f8438 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 13:47:11 +0200 Subject: [PATCH 07/12] refactor(tools): use the generic document type for module verification reports The generated report page used a bespoke auto_mod_ver_report need type that only supported belongs_to, so it could not participate in the document/workproduct realization model the rest of the metamodel uses. Switching it to the generic document type and linking realizes: wp__verification_module_ver_report brings it in line with that model. document has no belongs_to link, so mod_ver_report_tiny now recovers the module id from the report's own id (doc___verification_report) instead. --- src/extensions/score_metamodel/metamodel.yaml | 6 ----- src/needs_templates/mod_ver_report_tiny.need | 14 ++++++----- tools/module_verification_reports.py | 18 ++++++++++---- .../tests/module_verification_reports_test.py | 24 ++++++++++++++----- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index bebb693b2..ed60080bc 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -998,12 +998,6 @@ needs_types: - verification_report parts: 3 - auto_mod_ver_report: - title: Module Verification Report (automated) - parts: 2 - mandatory_links: - belongs_to: mod - # https://eclipse-score.github.io/process_description/main/permalink.html?id=gd_temp__change_decision_record dec_rec: title: Decision Record diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/mod_ver_report_tiny.need index 917b8be4c..907b3a229 100644 --- a/src/needs_templates/mod_ver_report_tiny.need +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -3,11 +3,13 @@ Content template for the ``mod_ver_report`` need type. - The report points to its module via ``belongs_to``. The module's - ``includes`` links provide the components, and each component's - ``belongs_to`` link provides the feature. The ``linked_needs`` helper resolves - this graph during the post-collection reread, so titles and report sections - stay driven by the Need model. + The report is a ``document`` need whose id encodes the module it covers + (``doc___verification_report``) — the ``document`` type has no + ``belongs_to`` link, so the module id is recovered from this need's own id + instead. The module's ``includes`` links provide the components, and each + component's ``belongs_to`` link provides the feature. The ``linked_needs`` + helper resolves this graph during the post-collection reread, so titles and + report sections stay driven by the Need model. The template is applied as ``:post_template:``, not ``:template:``. A need's *content* cannot open new sections ("Unexpected section title"), but @@ -15,7 +17,7 @@ work — and real headings are what give the report its TOC entries and per-component navigation. #} -{% set module_id = belongs_to|first|default("") %} +{% set module_id = "mod__" ~ id|replace("doc__", "")|replace("_verification_report", "") %} {# Resolve the component list from the module's outgoing graph links. A module may list a component more than once, so deduplicate the NeedItems by ID. #} {% set components_in_mod = linked_needs(module_id, "includes")|unique(attribute="id")|list %} diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py index 74d287852..35b090386 100644 --- a/tools/module_verification_reports.py +++ b/tools/module_verification_reports.py @@ -41,6 +41,7 @@ GITHUB_ORG = "eclipse-score" TEMPLATE_NAME = "mod_ver_report_tiny" +REPORT_WORKPRODUCT = "wp__verification_module_ver_report" DOCS_COMMAND = ("bazel", "run", "--lockfile_mode=off", "//:docs") REPORT_DIRECTORY = "module_verification_reports" REPORT_GOLDEN_DIRECTORY = "module_verification_reports_goldens" @@ -455,14 +456,23 @@ def local_module_override( def report_need(repository: str) -> str: - """Return the exact temporary Need page injected into a checkout.""" + """Return the exact temporary Need page injected into a checkout. + + The report is a ``document`` that realizes the module-verification-report + workproduct rather than linking ``belongs_to`` the module directly — the + ``document`` type does not support that link. ``mod_ver_report_tiny`` + recovers the module id from this need's own id instead. + """ display_name = repository.replace("_", " ").title() return ( - f".. auto_mod_ver_report:: {display_name} Module Verification Report\n" - f" :id: auto_mod_ver_report__{repository}\n" + f".. document:: {display_name} Module Verification Report\n" + f" :id: doc__{repository}_verification_report\n" f" :post_template: {TEMPLATE_NAME}\n" - f" :belongs_to: mod__{repository}\n" + f" :status: valid\n" + f" :safety: QM\n" + f" :security: NO\n" + f" :realizes: {REPORT_WORKPRODUCT}\n" f" :version: 1\n" ) diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py index a0e7e3d15..4c9940584 100644 --- a/tools/tests/module_verification_reports_test.py +++ b/tools/tests/module_verification_reports_test.py @@ -90,9 +90,10 @@ def test_pinned_profile_rejects_non_immutable_revisions(tmp_path: Path) -> None: def test_need_and_module_override_use_the_current_template_selector() -> None: page = report_need("inc_someip_gateway") - assert ":id: auto_mod_ver_report__inc_someip_gateway" in page + assert ":id: doc__inc_someip_gateway_verification_report" in page assert ":post_template: mod_ver_report_tiny" in page - assert ":belongs_to: mod__inc_someip_gateway" in page + assert ":realizes: wp__verification_module_ver_report" in page + assert ":belongs_to:" not in page assert ":version: 1" in page assert "mod_ver_report\n" not in page @@ -379,14 +380,17 @@ def test_local_fake_downstream_runs_the_real_docs_target(tmp_path: Path) -> None tags: .* content: .* template: .* - auto_mod_ver_report: - title: Module Verification Report (automated) - prefix: auto_mod_ver_report__ + document: + title: Generic Document + prefix: doc__ parts: 2 mandatory_options: + status: ^(valid|draft|invalid)$ + safety: ^(QM|ASIL_B)$ + security: ^(YES|NO)$ version: ^[0-9]+$ mandatory_links: - belongs_to: mod + realizes: workproduct optional_options: tags: .* content: .* @@ -399,6 +403,9 @@ def test_local_fake_downstream_runs_the_real_docs_target(tmp_path: Path) -> None includes: incoming: included by outgoing: includes + realizes: + incoming: realized by + outgoing: realizes """, encoding="utf-8", ) @@ -477,6 +484,11 @@ def test_local_fake_downstream_runs_the_real_docs_target(tmp_path: Path) -> None :id: wp__sw_component_fmea :status: valid :version: 1 + +.. workproduct:: Module Verification Report + :id: wp__verification_module_ver_report + :status: valid + :version: 1 """, encoding="utf-8", ) From 32c69fbded2eb10dfef41ecdacafc64807af2905 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 14:57:17 +0200 Subject: [PATCH 08/12] local checkout caching --- .gitignore | 3 ++ tools/module_verification_reports.py | 40 ++++++++----------- .../tests/module_verification_reports_test.py | 13 +++--- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index e5f5d2329..f3f103704 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ user.bazelrc _build/ ubproject.toml +# tools/module_verification_reports.py downstream repository checkouts +/.cache/ + # Vale - editorial style guide .vale.ini styles/ diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py index 35b090386..95e658997 100644 --- a/tools/module_verification_reports.py +++ b/tools/module_verification_reports.py @@ -10,14 +10,17 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Build disposable module-verification report galleries for SCORE modules. +"""Build module-verification report galleries for SCORE modules. The command in this file deliberately owns no downstream source. It keeps a -shallow checkout in the shared repository cache, overlays the local -``score_docs_as_code`` checkout for one build, and restores the checkout in a -``finally`` block. Keeping the orchestration here (rather than in a Bazel -rule) is important: the downstream repository is the workspace for the docs -build and must remain a normal, independently configured Bzlmod workspace. +shallow checkout per repository under this repo's own ``.cache`` directory, +reused (not cleaned) across runs so ``_build`` and other incremental-build +state survive for fast ``//:docs`` iteration. It overlays the local +``score_docs_as_code`` checkout for one build and restores the checkout to +its default branch in a ``finally`` block. Keeping the orchestration here +(rather than in a Bazel rule) is important: the downstream repository is the +workspace for the docs build and must remain a normal, independently +configured Bzlmod workspace. """ from __future__ import annotations @@ -110,25 +113,19 @@ def manifest_entry(self) -> dict[str, str | None]: def resolve_cache_dir( - cache_dir: Path | str | None = None, - *, - environ: Mapping[str, str] | None = None, - home: Path | None = None, + cache_dir: Path | str | None = None, *, workspace_root: Path ) -> Path: - """Resolve the cache root using the repo-cache contract. + """Resolve the cache root for downstream repository checkouts. - ``XDG_CACHE_HOME`` is used only when it is non-empty. An explicit command - line path always wins, which makes the command straightforward to use in - tests and CI jobs. + An explicit ``--cache-dir`` always wins. Otherwise the checkouts live + inside this repository's own ``.cache`` directory (not the user's global + ``~/.cache``), so they persist across runs for fast incremental ``//:docs`` + iteration and are trivial to find and delete. """ if cache_dir is not None: return Path(cache_dir).expanduser() - env = os.environ if environ is None else environ - xdg_cache_home = env.get("XDG_CACHE_HOME", "") - if xdg_cache_home: - return Path(xdg_cache_home).expanduser() / "repo-cache" - return (Path.home() if home is None else home) / ".cache" / "repo-cache" + return Path(workspace_root) / ".cache" / "repo-cache" def _repositories_toml_path(config_dir: Path | None = None) -> Path: @@ -345,7 +342,6 @@ def _synchronize_default( self._run_git(path, ["fetch", "--depth=1", "origin", "main"]) self._run_git(path, ["checkout", "--detach", "--force", "FETCH_HEAD"]) - self._run_git(path, ["clean", "-fdx"]) revision = self._revision(path) self._record_default_ref(path, spec.name, revision) _progress(f"{spec.name}: default checkout at {revision[:12]}") @@ -361,7 +357,6 @@ def _select_pinned(self, spec: RepositorySpec, path: Path) -> str: f"pinned checkout for {spec.name} resolved to {resolved}, " f"expected {spec.revision}" ) - self._run_git(path, ["clean", "-fdx"]) return resolved @contextlib.contextmanager @@ -392,7 +387,6 @@ def checkout(self, spec: RepositorySpec) -> Iterator[PreparedCheckout]: path, ["checkout", "--detach", "--force", default_revision], ) - self._run_git(path, ["clean", "-fdx"]) self._record_default_ref(path, spec.name, default_revision) except ReportToolError as restore_error: if active_error is None: @@ -948,7 +942,7 @@ def main(argv: Sequence[str] | None = None) -> int: selected, source_root=source_root, output_dir=output_dir, - cache_dir=resolve_cache_dir(args.cache_dir), + cache_dir=resolve_cache_dir(args.cache_dir, workspace_root=source_root), check_goldens=args.check_goldens, update_goldens=args.update_goldens, ) diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py index 4c9940584..f9c21b446 100644 --- a/tools/tests/module_verification_reports_test.py +++ b/tools/tests/module_verification_reports_test.py @@ -42,16 +42,13 @@ ) -def test_cache_path_prefers_xdg_and_has_default(tmp_path: Path) -> None: - assert resolve_cache_dir(environ={"XDG_CACHE_HOME": str(tmp_path)}) == ( - tmp_path / "repo-cache" - ) - assert resolve_cache_dir(environ={}, home=tmp_path) == ( +def test_cache_path_defaults_inside_the_workspace(tmp_path: Path) -> None: + assert resolve_cache_dir(workspace_root=tmp_path) == ( tmp_path / ".cache" / "repo-cache" ) - assert resolve_cache_dir( - tmp_path / "explicit", environ={"XDG_CACHE_HOME": "other"} - ) == (tmp_path / "explicit") + assert resolve_cache_dir(tmp_path / "explicit", workspace_root=tmp_path) == ( + tmp_path / "explicit" + ) def test_profiles_and_filters_are_validated() -> None: From 60644713b65a056650d84fec4024655c7234d86a Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 15:05:11 +0200 Subject: [PATCH 09/12] rename template to module_verification_report --- ...ver_report_tiny.need => module_verification_report.need} | 2 +- tools/module_verification_reports.py | 6 +++--- tools/tests/module_verification_reports_test.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/needs_templates/{mod_ver_report_tiny.need => module_verification_report.need} (99%) diff --git a/src/needs_templates/mod_ver_report_tiny.need b/src/needs_templates/module_verification_report.need similarity index 99% rename from src/needs_templates/mod_ver_report_tiny.need rename to src/needs_templates/module_verification_report.need index 907b3a229..c81606914 100644 --- a/src/needs_templates/mod_ver_report_tiny.need +++ b/src/needs_templates/module_verification_report.need @@ -1,7 +1,7 @@ {# score: render-after-needs-collection - Content template for the ``mod_ver_report`` need type. + Content template for the ``document`` need type for ``module_verification_report``. The report is a ``document`` need whose id encodes the module it covers (``doc___verification_report``) — the ``document`` type has no diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py index 95e658997..0cb0a2f5e 100644 --- a/tools/module_verification_reports.py +++ b/tools/module_verification_reports.py @@ -43,7 +43,7 @@ from urllib.parse import urlparse GITHUB_ORG = "eclipse-score" -TEMPLATE_NAME = "mod_ver_report_tiny" +TEMPLATE_NAME = "module_verification_report" REPORT_WORKPRODUCT = "wp__verification_module_ver_report" DOCS_COMMAND = ("bazel", "run", "--lockfile_mode=off", "//:docs") REPORT_DIRECTORY = "module_verification_reports" @@ -454,7 +454,7 @@ def report_need(repository: str) -> str: The report is a ``document`` that realizes the module-verification-report workproduct rather than linking ``belongs_to`` the module directly — the - ``document`` type does not support that link. ``mod_ver_report_tiny`` + ``document`` type does not support that link. ``module_verification_report`` recovers the module id from this need's own id instead. """ @@ -654,7 +654,7 @@ def validate_module_graph( this function therefore means every linked Need already resolves *somewhere*, so this only needs to catch what Sphinx-needs does not flag: an ``includes`` or ``belongs_to`` link list that is simply empty, which the - ``mod_ver_report_tiny`` template would otherwise render as a silently + ``module_verification_report`` template would otherwise render as a silently empty section. """ diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py index f9c21b446..2c0c39b18 100644 --- a/tools/tests/module_verification_reports_test.py +++ b/tools/tests/module_verification_reports_test.py @@ -88,11 +88,11 @@ def test_pinned_profile_rejects_non_immutable_revisions(tmp_path: Path) -> None: def test_need_and_module_override_use_the_current_template_selector() -> None: page = report_need("inc_someip_gateway") assert ":id: doc__inc_someip_gateway_verification_report" in page - assert ":post_template: mod_ver_report_tiny" in page + assert ":post_template: module_verification_report" in page assert ":realizes: wp__verification_module_ver_report" in page assert ":belongs_to:" not in page assert ":version: 1" in page - assert "mod_ver_report\n" not in page + assert "module_verification_report\n" not in page original = """module(name = "consumer") bazel_dep(name = "score_docs_as_code", version = "7.0.0") From 69dcfd5a0d1680248aa5370c05fcbc78106a7bd3 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 15:08:54 +0200 Subject: [PATCH 10/12] address Copilot review findings on PR #763 Drop the locals()/len() guards around the fully_verifies_back / partially_verifies_back needpie filters - those backlink fields always exist on comp_req/feat_req needs, so a plain truthy check suffices and avoids a possible NameError in the filter eval context. Also swap the internal app.events.emit() call for the public app.emit() wrapper. --- .../score_sphinx_needs_templates/__init__.py | 2 +- src/needs_templates/module_verification_report.need | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index a36fe3aac..04ba28c02 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -137,7 +137,7 @@ def _rerender_pages_with_complex_post_templates( pages_to_rerender = sorted(complex_post_template_docs) for docname in pages_to_rerender: - app.events.emit("env-purge-doc", env, docname) + app.emit("env-purge-doc", env, docname) env.clear_doc(docname) app.builder.read_doc(docname) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index c81606914..674c4380f 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -130,9 +130,9 @@ Requirements Statistics :colors: #37a12d, #f0a500, #ca2828 :legend: - type == "feat_req" and "{{ feature_id }}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "feat_req" and "{{ feature_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "feat_req" and "{{ feature_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) + type == "feat_req" and "{{ feature_id }}" in satisfied_by and fully_verifies_back + type == "feat_req" and "{{ feature_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back + type == "feat_req" and "{{ feature_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back .. dropdown:: Show requirements table :animate: fade-in @@ -256,9 +256,9 @@ Component Requirements Statistics :colors: #37a12d, #f0a500, #ca2828 :legend: - type == "comp_req" and "{{ component_id }}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{{ component_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{{ component_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) + type == "comp_req" and "{{ component_id }}" in satisfied_by and fully_verifies_back + type == "comp_req" and "{{ component_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back + type == "comp_req" and "{{ component_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 9a38fa7688497685530b136f3d30ba5cabb9b632 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 15:31:24 +0200 Subject: [PATCH 11/12] fix more Copilot review findings on PR #763 - tools/BUILD: the data glob only matched a nonexistent module_verification_profiles/ directory, so the real module_verification_reports.toml next to the script was missing from runfiles. List it explicitly. - module_verification_reports.py: default golden root pointed at scripts_bazel/, but tools/BUILD packages goldens under tools/ - align the two so --check-goldens/--update-goldens find the right path. - score_sphinx_needs_templates: resolve linked needs by the link's full qualified string (id[condition]) instead of the bare id, so a version-qualified link resolves the matching NeedItem instead of an arbitrary same-id candidate. --- src/extensions/score_sphinx_needs_templates/__init__.py | 2 +- tools/BUILD | 8 ++++---- tools/module_verification_reports.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 04ba28c02..3863046af 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -80,7 +80,7 @@ def __call__(self, need_id: str, link_name: str) -> list[NeedItem]: linked: list[NeedItem] = [] for link in source.get_links(link_name, as_str=False): - target = _find_need(needs, link.id) + target = _find_need(needs, link.to_link_string()) if target is not None: linked.append(target) return linked diff --git a/tools/BUILD b/tools/BUILD index 741378790..8736e0af8 100644 --- a/tools/BUILD +++ b/tools/BUILD @@ -23,10 +23,10 @@ package(default_visibility = ["//:__subpackages__"]) py_binary( name = "module_verification_reports", srcs = ["module_verification_reports.py"], - data = glob([ - "module_verification_profiles/*.toml", - "module_verification_reports_goldens/**", - ], allow_empty = True), + data = ["module_verification_reports.toml"] + glob( + ["module_verification_reports_goldens/**"], + allow_empty = True, + ), main = "module_verification_reports.py", deps = [], ) diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py index 0cb0a2f5e..2b6762699 100644 --- a/tools/module_verification_reports.py +++ b/tools/module_verification_reports.py @@ -817,7 +817,7 @@ def build_gallery( output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) golden_root = ( - source_root / "scripts_bazel" / REPORT_GOLDEN_DIRECTORY + source_root / "tools" / REPORT_GOLDEN_DIRECTORY if golden_root is None else Path(golden_root) ) From 61d15672880c29325fa22ae55387aa3dd5529ec6 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 28 Aug 2026 15:43:50 +0200 Subject: [PATCH 12/12] fix stale test assertion and bazelignore .cache The rename in 60644713 mechanically turned an old regression guard (":post_template: mod_ver_report_tiny" must not collapse to bare "mod_ver_report") into a self-contradiction once the template's name became "module_verification_report" itself - the assertion now directly contradicted the line above it, failing bazel test //... Remove the now-meaningless guard. Also add .cache to .bazelignore: module_verification_reports.py's downstream checkout cache (introduced in 32c69fbd) includes a self-referencing overlay of this repo, which sends Bazel's package discovery into an infinite symlink loop for anyone with a populated cache. --- .bazelignore | 5 +++++ tools/tests/module_verification_reports_test.py | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.bazelignore b/.bazelignore index b247bce54..5ec4b50a7 100644 --- a/.bazelignore +++ b/.bazelignore @@ -2,3 +2,8 @@ # As it contains multiple copies of the entire repository, this totally tripps bazel, # which then tries to build all the files in there. .claude + +# tools/module_verification_reports.py keeps its shallow downstream checkouts +# here, including a self-referencing overlay of this repo, which Bazel's +# package discovery cannot traverse. +.cache diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py index 2c0c39b18..9d16e47e6 100644 --- a/tools/tests/module_verification_reports_test.py +++ b/tools/tests/module_verification_reports_test.py @@ -92,7 +92,6 @@ def test_need_and_module_override_use_the_current_template_selector() -> None: assert ":realizes: wp__verification_module_ver_report" in page assert ":belongs_to:" not in page assert ":version: 1" in page - assert "module_verification_report\n" not in page original = """module(name = "consumer") bazel_dep(name = "score_docs_as_code", version = "7.0.0")