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/.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/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/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..ed60080bc 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -998,7 +998,6 @@ needs_types: - verification_report parts: 3 - # 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/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..3863046af --- /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.to_link_string()) + 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.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/module_verification_report.need b/src/needs_templates/module_verification_report.need new file mode 100644 index 000000000..674c4380f --- /dev/null +++ b/src/needs_templates/module_verification_report.need @@ -0,0 +1,357 @@ +{# + score: render-after-needs-collection + + 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 + ``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 + 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 = "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 %} + +{# 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 + 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 + + .. 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 + +
+ +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 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 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "valid" + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" in tags + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" not in tags + +Requirements Traceability +^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following table lists all requirements of this component together with their +verification status and the tests that (fully or partially) verify them: + +.. dropdown:: Show requirements table + :animate: fade-in + + .. needtable:: + :filter: type == "comp_req" and "{{ component_id }}" in satisfied_by + :style: table + :columns: id;title;safety;status;fully_verifies_back;partially_verifies_back + :colwidths: 13,22,8,10,23,24 + :sort: id + +Test Coverage +^^^^^^^^^^^^^ + +Per-source-file line and branch coverage aggregated from the LCOV report +produced by ``bazel coverage``. + +.. dropdown:: Show test coverage table + :animate: fade-in + + .. note:: + + No coverage data available for this component. Run ``bazel coverage`` + with the corresponding targets and rebuild the docs to populate this + table. + +Architectural Elements +^^^^^^^^^^^^^^^^^^^^^^ + +The following table lists the architectural elements of this component +together with their inspection status. Elements that have been formally +inspected carry the ``inspected`` tag; elements without that tag have not +yet been inspected. + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +Verification & Safety Analysis Documents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Presence of the standard verification and safety analysis work products for +this component. A dash (``—``) means the corresponding document is missing. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status +{{ workproduct_rows(component_slug_norm, component_workproducts) }} +{% endfor %} diff --git a/tools/BUILD b/tools/BUILD new file mode 100644 index 000000000..8736e0af8 --- /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 = ["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 new file mode 100644 index 000000000..2b6762699 --- /dev/null +++ b/tools/module_verification_reports.py @@ -0,0 +1,959 @@ +# ******************************************************************************* +# 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 module-verification report galleries for SCORE modules. + +The command in this file deliberately owns no downstream source. It keeps a +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 + +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 = "module_verification_report" +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" +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, *, workspace_root: Path +) -> Path: + """Resolve the cache root for downstream repository checkouts. + + 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() + return Path(workspace_root) / ".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"]) + 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}" + ) + 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._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. + + 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. ``module_verification_report`` + recovers the module id from this need's own id instead. + """ + + display_name = repository.replace("_", " ").title() + return ( + f".. document:: {display_name} Module Verification Report\n" + f" :id: doc__{repository}_verification_report\n" + f" :post_template: {TEMPLATE_NAME}\n" + f" :status: valid\n" + f" :safety: QM\n" + f" :security: NO\n" + f" :realizes: {REPORT_WORKPRODUCT}\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 + ``module_verification_report`` 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 / "tools" / 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, workspace_root=source_root), + 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..9d16e47e6 --- /dev/null +++ b/tools/tests/module_verification_reports_test.py @@ -0,0 +1,665 @@ +# ******************************************************************************* +# 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_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", workspace_root=tmp_path) == ( + 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: doc__inc_someip_gateway_verification_report" 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 + + 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: .* + 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: + realizes: workproduct + optional_options: + tags: .* + content: .* + template: .* +links: {} +needs_extra_links: + belongs_to: + incoming: has + outgoing: belongs to + includes: + incoming: included by + outgoing: includes + realizes: + incoming: realized by + outgoing: realizes +""", + 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 + +.. workproduct:: Module Verification Report + :id: wp__verification_module_ver_report + :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