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_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/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..56e540156 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -0,0 +1,61 @@ + + +# `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 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` + +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 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 new file mode 100644 index 000000000..a36fe3aac --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -0,0 +1,173 @@ +# ******************************************************************************* +# 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 +# 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" + 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 = _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 = _find_need(needs, link.id) + if target is not None: + linked.append(target) + return linked + + +_linked_needs_callable = _LinkedNeeds() + + +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") + if ( + _RENDER_AFTER_NEEDS_COLLECTION_MARKER + in template.read_text(encoding="utf-8") + ) + } + + +def _rerender_pages_with_complex_post_templates( + app: Sphinx, env: BuildEnvironment +) -> list[str]: + """Re-read marked post-template pages after Need environments are merged. + + 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_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_post_template_docs: set[str] = set() + for need in needs_data.get_needs_mutable().values(): + 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_post_template_docs.add(docname) + + 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) + 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_post_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 new file mode 100644 index 000000000..917b8be4c --- /dev/null +++ b/src/needs_templates/mod_ver_report_tiny.need @@ -0,0 +1,355 @@ +{# + score: render-after-needs-collection + + 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("") %} +{# 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"], + ["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 + + + +{#- ===================================================================== -#} +{#- Feature sections resolved from all components' belongs_to links. -#} +{#- ===================================================================== -#} +{% 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_heading }} +{{ "-" * (feature_heading|length) }} + +.. needtable:: + :filter: id == "{{ feature_id }}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +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 ("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 + + .. 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 + +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 + +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 +{{ workproduct_rows(feature_slug_norm, feature_workproducts) }} +{% endfor %} + +{# ===================================================================== #} +{# Components #} +{# ===================================================================== #} + +Components +---------- + +Component Overview +~~~~~~~~~~~~~~~~~~ + +.. needtable:: + :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 components_in_mod %} +{% set component_id = component["id"] %} +{% 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_anchor }}: + +{{ component_title }} +{{ "~" * (component_title|length) }} + +.. raw:: html + +