From 7dc67d920debaeb3896c7bbed8ae13436cf07ddb Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Wed, 19 Aug 2026 11:50:31 +0000 Subject: [PATCH 01/25] feat(module-verification-report): new bundled sphinx extension Adds score_module_verification_report to the score_sphinx_bundle. Provides the .. module-verification-report:: directive that renders a per-module verification report body from a YAML config: - Component overview via sphinx-needs - Per-component Verification & Safety Analysis Documents table with Status column, delegated to sphinx-needs via .. needtable:: - Feature section (requirements / architecture / inspection stats) Extracted from baselibs' local docs/_ext/module_report.py so any consumer of docs-as-code can now use the directive without a local copy. --- .../score_module_verification_report/BUILD | 30 + .../__init__.py | 909 ++++++++++++++++++ src/extensions/score_sphinx_bundle/BUILD | 1 + .../score_sphinx_bundle/__init__.py | 1 + 4 files changed, 941 insertions(+) create mode 100644 src/extensions/score_module_verification_report/BUILD create mode 100644 src/extensions/score_module_verification_report/__init__.py diff --git a/src/extensions/score_module_verification_report/BUILD b/src/extensions/score_module_verification_report/BUILD new file mode 100644 index 000000000..6796261c5 --- /dev/null +++ b/src/extensions/score_module_verification_report/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 Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") + +filegroup( + name = "all_sources", + srcs = glob(["*.py"]), + visibility = ["//visibility:public"], +) + +py_library( + name = "score_module_verification_report", + srcs = [":all_sources"], + imports = ["."], + visibility = ["//visibility:public"], + deps = all_requirements + [ + "@score_docs_as_code//src/helper_lib", + ], +) diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py new file mode 100644 index 000000000..b3fe7a1e1 --- /dev/null +++ b/src/extensions/score_module_verification_report/__init__.py @@ -0,0 +1,909 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Sphinx directive that generates the per-module verification report body. + +Components are discovered by a shallow filesystem scan of the docs source +tree for ``.. comp::`` (and ``.. document::``) directives, cached on the +Sphinx environment at ``env-before-read-docs`` time. This keeps the +extension ``parallel_read_safe`` — no dependency on sphinx-needs internal +state during the read phase. + +Per-work-product realization is delegated to sphinx-needs: each +"Realized by" cell renders a ``.. needlist::`` filtered by the component +slug (normalised underscore-free substring match on the doc id) and by +the ``realizes`` link to the work product. Coverage status is derived +from ``coverage_summary.json``. Only components whose docs are named +differently from the component slug (see ``overrides`` in the config) +need explicit per-component data. + +Usage in RST:: + + .. module-verification-report:: + :config: reporting/module_verification_report.yaml # optional + +The config file has the shape:: + + module_id: mod__baselibs + # optional; derived from module_id if omitted + component_prefix: comp__baselibs_ + # optional; standard workproducts checked per component + workproducts: + - key: requirements_inspect + label: Requirements Inspection + wp_id: wp__requirements_inspect + - ... + # optional; per-component overrides for irregular cases (documents + # whose id does not contain the component slug, e.g. + # ``comp__baselibs_nlohman_json`` → ``doc__json_*``) + overrides: + comp__baselibs_some_component: + workproducts: + requirements_inspect: doc__some_component_req_inspection + ... +""" +from __future__ import annotations + +import json +import os +import re +from typing import Any + +import yaml +from docutils import nodes +from docutils.statemachine import ViewList +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles + + +# --------------------------------------------------------------------------- +# Need discovery (filesystem scan) +# --------------------------------------------------------------------------- +# +# We deliberately do **not** query ``SphinxNeedsData`` here: doing so would +# require reading the report source strictly after every source registering +# a ``.. comp::`` / ``.. document::`` need, which forces ``parallel_read_safe +# = False`` on the extension and produces two Sphinx-level warnings per +# build (``the score_module_verification_report extension is not safe for +# parallel reading`` / ``doing serial read``). Those warnings are fatal +# under ``-W``. +# +# The RST directive syntax used across baselibs is stable: +# +# .. comp:: +# :id: comp__baselibs_<slug> +# :safety: ASIL_B +# :security: NO +# :status: valid +# ... +# +# .. document:: <title> +# :id: doc__<slug>_<suffix> +# :realizes: wp__<key>[version==<N>] +# ... +# +# Documents are matched to a work product entirely on the sphinx-needs +# side, at render time: each per-component "Realized by" cell is a +# ``.. needlist::`` filtered by ``type == "document"``, by a +# normalised-slug substring match of the component slug against the doc +# id, and by the ``realizes`` link containing the WP id. This scan only +# needs to enumerate components and their titles. +# +# A shallow regex scan of the source tree at ``env-before-read-docs`` +# gives us everything the directive needs, and works in every process of +# a parallel build. + +_DIRECTIVE_HEADER_RE = re.compile( + r"^\.\.[ \t]+(?P<name>[a-z_-]+)::[ \t]*(?P<title>.*?)\s*$" +) +_OPTION_LINE_RE = re.compile(r"^[ \t]+:(?P<key>[^:]+):[ \t]*(?P<value>.*?)\s*$") + +# Options captured from ``:key: value`` lines. Everything else +# (safety, security, status, realizes, tags, ...) is intentionally +# dropped: the report delegates all attribute and link resolution to +# sphinx-needs at render time (``.. needtable::`` / ``.. needlist::``). +# ``includes`` is captured only for ``.. mod::`` needs (whitelist of +# components; see :func:`_module_includes`); ``version`` is captured +# on ``.. comp::`` needs to honour ``[version==N]`` filters coming from +# that whitelist. +_SCANNED_OPTIONS = frozenset({"id", "includes", "version"}) + + +def _scan_rst_needs(srcdir: str, directives: set[str]) -> list[dict]: + """Return every need declared by one of *directives* under *srcdir*. + + Each result carries ``directive`` (e.g. ``comp``), ``id`` and + ``title``. Silently skips unreadable files. + """ + results: list[dict] = [] + for root, _dirs, files in os.walk(srcdir): + for fname in files: + if not fname.endswith(".rst"): + continue + path = os.path.join(root, fname) + try: + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + except (OSError, UnicodeDecodeError): + continue + i = 0 + while i < len(lines): + m = _DIRECTIVE_HEADER_RE.match(lines[i]) + if not m or m.group("name") not in directives: + i += 1 + continue + entry: dict[str, Any] = { + "directive": m.group("name"), + "title": m.group("title").strip(), + } + j = i + 1 + while j < len(lines): + opt = _OPTION_LINE_RE.match(lines[j]) + if not opt: + break + key = opt.group("key").strip() + if key in _SCANNED_OPTIONS: + entry[key] = opt.group("value").strip() + j += 1 + if "id" in entry: + results.append(entry) + i = j if j > i else i + 1 + return results + + +def _normalize_slug(text: str) -> str: + """Return *text* stripped of underscores and lower-cased. + + Component ids and document ids sometimes spell the same component + with different underscoring (``bit_manipulation`` vs. + ``bitmanipulation``). Comparing on the underscore-free form makes + that difference invisible without introducing per-component config. + """ + return text.replace("_", "").lower() + + +_INCLUDE_ENTRY_RE = re.compile( + r"^(?P<id>[^\[\s]+)(?:\[version==(?P<version>[^\]]+)\])?\s*$" +) + + +def _module_includes( + needs: list[dict], module_id: str +) -> dict[str, str | None] | None: + """Return the component ids listed in ``:includes:`` on the + ``.. mod::`` need whose id equals *module_id*, mapped to their + required version (or ``None`` if no ``[version==N]`` filter was set). + + Entries in ``:includes:`` have the form ``<id>[version==<N>]`` and + are comma-separated. Returns ``None`` if no matching mod need is + found in *needs* (spec error → caller renders an ``error`` node). + """ + for entry in needs: + if entry.get("directive") != "mod" or entry.get("id") != module_id: + continue + raw = entry.get("includes", "") + result: dict[str, str | None] = {} + for part in raw.split(","): + m = _INCLUDE_ENTRY_RE.match(part.strip()) + if m: + result[m.group("id")] = m.group("version") + return result + return None + + +def _discover_components( + env, component_prefix: str, whitelist: dict[str, str | None] +) -> list[dict]: + """Return every ``.. comp::`` need whose id (and, when a + ``[version==N]`` filter was declared, whose ``:version:``) matches + an entry in *whitelist*. + + Sourced from the filesystem scan cached on ``env`` at + ``env-before-read-docs``. Components are returned sorted by id for a + stable display order. Whitelist entries with no matching + ``.. comp::`` scan result are silently ignored (caller may want to + warn). + """ + result: list[dict] = [] + for entry in getattr(env, "module_verification_report_needs", []): + if entry.get("directive") != "comp": + continue + need_id = entry.get("id", "") + if need_id not in whitelist: + continue + required_version = whitelist[need_id] + if required_version is not None and entry.get("version") != required_version: + continue + slug = ( + need_id[len(component_prefix):] + if need_id.startswith(component_prefix) + else need_id + ) + result.append( + { + "id": need_id, + "slug": slug, + "title": entry.get("title") or need_id, + } + ) + result.sort(key=lambda c: c["id"]) + return result + + +# --------------------------------------------------------------------------- +# RST rendering +# --------------------------------------------------------------------------- + +_COMPONENT_TEMPLATE = """ +.. _{ref}: + +{title} +{title_underline} + +.. raw:: html + + <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> + +Component Requirements Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {title} Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "comp_req" and "{comp_id}" in satisfied_by and status == "valid" + type == "comp_req" and "{comp_id}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: {title} Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "comp_req" and "{comp_id}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{comp_id}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{comp_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) + +Component Architecture Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {title} Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "valid" + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: {title} Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and "inspected" in tags + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_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 "{comp_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 + +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 "{comp_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 (``\u2014``) 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} +""" + + +# Kept for later re-activation. To re-enable the Unit Test Coverage +# section, append this fragment to ``_COMPONENT_TEMPLATE`` and restore +# the ``coverage_intro=_coverage_intro(comp, coverage_data)`` kwarg in +# ``_render_component``. +_COMPONENT_COVERAGE_SECTION_DISABLED = """\ + +Unit Test Coverage +^^^^^^^^^^^^^^^^^^ + +{coverage_intro} +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Metric + - Coverage + * - Lines + - |coverage_{slug}_lines| + * - Functions + - |coverage_{slug}_functions| + * - Branches + - |coverage_{slug}_branches| +""" + + +_COVERAGE_INTRO_MEASURED = ( + "Aggregated from ``bazel coverage``. Regenerate via\n" + "``python3 tools/extract_coverage.py \"$(bazel info output_path)" + "/_coverage/_coverage_report.dat\" docs/reporting/coverage_summary.json``.\n" +) +_COVERAGE_INTRO_SPEC_ONLY = ( + "This component is specification-only and has no dedicated unit\n" + "test binary in ``//score/\u2026``.\n" +) + +_COVERAGE_SUMMARY_REL_PATH = os.path.join("reporting", "coverage_summary.json") + + +def _load_coverage_summary(env) -> dict: + """Return the parsed ``coverage_summary.json`` (empty dict on failure). + + The JSON is produced by ``tools/extract_coverage.py`` from an LCOV + report. Its top-level keys are component slugs (matching + ``comp__<module>_<slug>`` in the sphinx-needs data). Presence of a + slug with real metric values marks that component as *measured*; + absence marks it as *specification-only*. + """ + path = os.path.join(env.srcdir, _COVERAGE_SUMMARY_REL_PATH) + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + if os.path.isfile(path): + env.note_dependency(path) + return data or {} + + +_DEFAULT_WORKPRODUCTS = [ + {"key": "requirements_inspect", "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect"}, + {"key": "sw_arch_verification", "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification"}, + {"key": "sw_implementation_inspection", "label": "Implementation Inspection", + "wp_id": "wp__sw_implementation_inspection"}, + {"key": "sw_component_dfa", "label": "DFA", + "wp_id": "wp__sw_component_dfa"}, + {"key": "sw_component_fmea", "label": "FMEA", + "wp_id": "wp__sw_component_fmea"}, +] + + +def _slugify(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + + +def _workproduct_rows( + slug_norm: str, + overrides: dict, + workproducts: list[dict], +) -> str: + """Render the work-product rows for one component or the feature. + + Each row has four cells: the work-product ``:need:`` link, its + label, the realising document, and its status. The "Realized by" + and "Status" cells are populated by sphinx-needs so their content + stays in sync with the actual sphinx-needs data model: + + 1. **Overrides** — when ``overrides['workproducts'][wp_key]`` names + an explicit doc id, the row renders a direct ``:need:`` link + and a ``:ndf:`copy('status', ...)``` call that pulls the doc's + status field verbatim. + 2. **Filter** — otherwise, both cells render a ``.. needtable::`` + with the same filter (``type == "document"``, normalised-slug + substring match on the doc id, ``realizes`` link containing + ``wp['wp_id']``) but different ``:columns:``. If nothing matches, + both cells are empty. + """ + explicit = overrides.get("workproducts") or {} + lines: list[str] = [] + for wp in workproducts: + override_doc = explicit.get(wp["key"]) + lines.append(f" * - :need:`{wp['wp_id']}`") + lines.append(f" - {wp['label']}") + if override_doc: + lines.append(f" - :need:`{override_doc}`") + lines.append( + f" - :ndf:`copy('status', " + f"need_id='{override_doc}')`" + ) + else: + filter_expr = ( + f"type == \"document\" and " + f"\"{slug_norm}\" in id.replace(\"_\", \"\") and " + f"\"{wp['wp_id']}\" in realizes" + ) + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: id") + lines.append(" :style: table") + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: status") + lines.append(" :style: table") + return "\n".join(lines) + + +def _coverage_intro(comp: dict, coverage_data: dict) -> str: + """Choose the intro paragraph based on ``coverage_summary.json``. + + A component counts as *measured* iff its slug appears in the JSON + with at least one non-null metric percentage. Otherwise it is + treated as specification-only. This mirrors how the + ``|coverage_<slug>_*|`` substitutions in ``conf.py`` decide between + numeric output and ``"not measured"``. + """ + entry = coverage_data.get(comp["slug"]) or {} + measured = any( + entry.get(f"{m}_pct") is not None + for m in ("lines", "functions", "branches") + ) + return (_COVERAGE_INTRO_MEASURED if measured else _COVERAGE_INTRO_SPEC_ONLY) + "\n" + + +def _render_component( + comp: dict, + overrides: dict, + workproducts: list[dict], + coverage_data: dict, +) -> str: + title = comp["title"] + slug = comp["slug"] + ref = "comp-" + _slugify(title) + return _COMPONENT_TEMPLATE.format( + ref=ref, + title=title, + title_underline="~" * len(title), + comp_id=comp["id"], + slug=slug, + workproduct_rows=_workproduct_rows( + _normalize_slug(slug), overrides, workproducts + ), + # Unit Test Coverage section disabled — see + # ``_COMPONENT_COVERAGE_SECTION_DISABLED``. Restore by passing + # ``coverage_intro=_coverage_intro(comp, coverage_data)``. + ) + + +_DEFAULT_FEATURE_WORKPRODUCTS = [ + {"key": "requirements_inspect", "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect"}, + {"key": "sw_arch_verification", "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification"}, +] + + +_FEATURE_TEMPLATE = """\ +Feature +------- + +.. needtable:: + :filter: id == "{feature_id}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +Feature Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "feat_req" and "{feature_id}" in satisfied_by and status == "valid" + type == "feat_req" and "{feature_id}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "feat_req" and "{feature_id}" in satisfied_by and ("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 + +Feature Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "valid" + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" in tags + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" not in tags + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +Feature Inspection Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Presence of the feature-level inspection work products. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status +{feature_workproduct_rows} +""" + + +def _render_feature( + feature_id: str, + feature_slug: str, + feature_overrides: dict, + feature_workproducts: list[dict], +) -> str: + """Render the ``Feature`` section (Requirements / Architecture / + Inspection Statistics), delegating all attribute filtering to + sphinx-needs. + + Feature statistics filter ``feat_req`` / ``feat_arc_*`` by + ``"{feature_id}" in belongs_to`` — the same link that the source + RST declares — so the report tracks the sphinx-needs data model + directly instead of guessing from id substrings. The Feature summary + ``needtable`` pulls title / safety / security / status from the + ``feat__*`` need itself. The Inspection Statistics work-product + rows still substring-match the ``feature_slug`` against document + ids because documents have no direct link back to the feature. + """ + return _FEATURE_TEMPLATE.format( + feature_id=feature_id, + feature_slug=feature_slug, + feature_workproduct_rows=_workproduct_rows( + _normalize_slug(feature_slug), + feature_overrides, + feature_workproducts, + ), + ) + + +_COMPONENTS_HEADER = """\ +Components +---------- + +""" + + +# Hide the auto-generated header / chrome of the inner ``.. needtable::`` +# widgets that render the "Realized by" / "Status" cells of the WP tables. +# Without this the cells show a nested table with its own "ID" / "Status" +# header row and datatables toolbar, which is visually noisy for a single +# value. Scoped to ``.wp-doc-table`` set as the outer list-table's class. +_WP_TABLE_CSS = """\ +.. raw:: html + + <style> + .wp-doc-table td .needstable_wrapper, + .wp-doc-table td .pst-scrollable-table-container { + margin: 0; padding: 0; overflow: visible; + } + .wp-doc-table td table.NEEDS_TABLE, + .wp-doc-table td table.NEEDS_DATATABLES { + border: 0; margin: 0; box-shadow: none; background: transparent; + width: auto; + } + .wp-doc-table td table.NEEDS_TABLE thead, + .wp-doc-table td table.NEEDS_DATATABLES thead { display: none; } + .wp-doc-table td table.NEEDS_TABLE tbody tr, + .wp-doc-table td table.NEEDS_DATATABLES tbody tr { background: transparent; } + .wp-doc-table td table.NEEDS_TABLE tbody td, + .wp-doc-table td table.NEEDS_DATATABLES tbody td { + border: 0; padding: 0; background: transparent; + } + .wp-doc-table td .dataTables_wrapper .dataTables_length, + .wp-doc-table td .dataTables_wrapper .dataTables_filter, + .wp-doc-table td .dataTables_wrapper .dataTables_info, + .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } + </style> +""" + + +_OVERVIEW_TEMPLATE = """\ +Component Overview +~~~~~~~~~~~~~~~~~~ + +.. needtable:: + :filter: id in {ids_literal} + :columns: id as "Component";safety;security;status + :style: table + :sort: id +""" + + +def _render_overview(components: list[dict]) -> str: + """Render the component overview as a ``.. needtable::``. + + Delegating to sphinx-needs means ``safety``/``security``/``status`` + come from its data model (validated, normalised, consistent with the + rest of the site) rather than from raw strings scraped by our + filesystem scan. Trade-off: the ``Component`` cell links to the + need's detail page, not to the per-component section further down + this page. + """ + ids_literal = "[" + ", ".join(f'"{c["id"]}"' for c in components) + "]" + return _OVERVIEW_TEMPLATE.format(ids_literal=ids_literal) + + +def _render_report( + components: list[dict], + feature_id: str, + feature_slug: str, + overrides_by_id: dict[str, dict], + workproducts: list[dict], + feature_workproducts: list[dict], + coverage_data: dict, +) -> str: + feature_overrides = overrides_by_id.get(feature_id, {}) + parts = [ + _WP_TABLE_CSS, + _render_feature( + feature_id, feature_slug, feature_overrides, feature_workproducts + ), + _COMPONENTS_HEADER, + _render_overview(components), + ] + for comp in components: + overrides = overrides_by_id.get(comp["id"], {}) + parts.append( + _render_component( + comp, + overrides, + workproducts, + coverage_data, + ) + ) + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Sphinx directive +# --------------------------------------------------------------------------- + + +class ModuleVerificationReportDirective(SphinxDirective): + """Expand to the per-module verification report body. + + Discovers components dynamically from the sphinx-needs data model by + filtering all needs by ``type == "comp"`` and + ``id.startswith(component_prefix)``. + """ + + required_arguments = 0 + optional_arguments = 0 + option_spec = {"config": str} + has_content = False + + def _load_config(self, rel_config: str | None) -> dict: + if not rel_config: + return {} + srcdir = self.env.srcdir + config_path = os.path.join(srcdir, rel_config) + if not os.path.isfile(config_path): + self.state_machine.reporter.warning( + f"module-verification-report: config not found: {config_path}", + line=self.lineno, + ) + return {} + with open(config_path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + self.env.note_dependency(config_path) + return data + + def run(self) -> list[nodes.Node]: + config = self._load_config(self.options.get("config")) + + module_id = config.get("module_id", "") + component_prefix = config.get("component_prefix") or ( + "comp__" + module_id[len("mod__"):] + "_" + if module_id.startswith("mod__") + else "comp__" + ) + module_short = ( + module_id[len("mod__"):] + if module_id.startswith("mod__") + else module_id + ) + feature_id = config.get("feature_id") or f"feat__{module_short}" + feature_slug = ( + feature_id.split("__", 1)[1] + if "__" in feature_id + else feature_id + ) + workproducts = config.get("workproducts") or _DEFAULT_WORKPRODUCTS + feature_workproducts = ( + config.get("feature_workproducts") or _DEFAULT_FEATURE_WORKPRODUCTS + ) + overrides_by_id: dict[str, dict] = config.get("overrides") or {} + + all_needs = getattr(self.env, "module_verification_report_needs", []) + include_ids = _module_includes(all_needs, module_id) + if include_ids is None: + error = self.state_machine.reporter.error( + f"module-verification-report: no '.. mod::' need with " + f"id '{module_id}' found in the source tree " + f"(is 'module_id' set correctly in the config?)", + line=self.lineno, + ) + return [error] + + components = _discover_components( + self.env, component_prefix, include_ids + ) + missing = set(include_ids) - {c["id"] for c in components} + for m in sorted(missing): + required = include_ids[m] + hint = f" (version=={required})" if required else "" + self.state_machine.reporter.warning( + f"module-verification-report: '{module_id}' includes " + f"'{m}'{hint} but no matching '.. comp::' need was found", + line=self.lineno, + ) + if not components: + error = self.state_machine.reporter.error( + f"module-verification-report: '{module_id}' has no " + f"resolvable components in ':includes:'", + line=self.lineno, + ) + return [error] + + coverage_data = _load_coverage_summary(self.env) + + rst_text = _render_report( + components, + feature_id, + feature_slug, + overrides_by_id, + workproducts, + feature_workproducts, + coverage_data, + ) + view_list = ViewList() + source = "<module-verification-report>" + for lineno, line in enumerate(rst_text.splitlines()): + view_list.append(line, source, lineno) + + # Parse into a plain container (not a ``nodes.section``): a section + # wrapper would push every heading we emit one level deeper than the + # surrounding document sections, so ``Component Overview`` would + # render as ``<h4>`` instead of ``<h3>`` alongside + # ``Feature Requirements Statistics``. + container = nodes.container() + container.document = self.state.document + nested_parse_with_titles(self.state, view_list, container) + return container.children + + +def _scan_source_tree(app, env, docnames): + """Cache a filesystem scan of all ``.. mod::`` and ``.. comp::`` + needs on ``env`` so the directive can enumerate its components in + every process of a parallel build. + """ + env.module_verification_report_needs = _scan_rst_needs( + env.srcdir, directives={"mod", "comp"} + ) + + +def setup(app: Any) -> dict: + app.add_directive( + "module-verification-report", ModuleVerificationReportDirective + ) + app.connect("env-before-read-docs", _scan_source_tree) + return { + "version": "0.6", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index 113803b97..0cb93f404 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -37,6 +37,7 @@ py_library( "@score_docs_as_code//src/extensions/score_mounts", "@score_docs_as_code//src/extensions/score_source_code_linker", "@score_docs_as_code//src/extensions/score_metrics", + "@score_docs_as_code//src/extensions/score_module_verification_report", "@score_docs_as_code//src/extensions/score_sync_toml", "@score_docs_as_code//src/helper_lib", ], diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index f3399e507..6c0546aae 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -41,6 +41,7 @@ "needs_config_writer", "score_sync_toml", "score_metrics", + "score_module_verification_report", "broken_link_fix", ] From ab7f2ac5fc29bdd0e6770479f23301626cf42793 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 19 Aug 2026 13:21:09 +0000 Subject: [PATCH 02/25] refactor(module-verification-report): split extension into focused modules Move the 909-line monolithic __init__.py into five thin modules so each layer can be reviewed and tested independently: * scanner.py - filesystem regex scan for .. mod:: / .. comp:: * coverage.py - coverage_summary.json loading + intro selection * templates.py - RST templates, WP-table CSS, default workproducts * rendering.py - pure template-expansion helpers * directive.py - the Sphinx directive class __init__.py is now a thin entry point (only setup() and re-exports). BUILD is aligned with score_mounts (sources / tests filegroups) and still exposes the same py_library target name, so the sphinx bundle consumer needs no change. The public surface is unchanged: the directive name, the config schema, env.module_verification_report_needs and the setup() return value all stay identical. Verified end-to-end against baselibs via local_path_override: the rendered report is byte-for-byte equivalent (same 42 wp-doc-table occurrences). --- .../score_module_verification_report/BUILD | 26 +- .../__init__.py | 870 +----------------- .../coverage.py | 67 ++ .../directive.py | 140 +++ .../rendering.py | 189 ++++ .../scanner.py | 184 ++++ .../templates.py | 336 +++++++ 7 files changed, 953 insertions(+), 859 deletions(-) create mode 100644 src/extensions/score_module_verification_report/coverage.py create mode 100644 src/extensions/score_module_verification_report/directive.py create mode 100644 src/extensions/score_module_verification_report/rendering.py create mode 100644 src/extensions/score_module_verification_report/scanner.py create mode 100644 src/extensions/score_module_verification_report/templates.py diff --git a/src/extensions/score_module_verification_report/BUILD b/src/extensions/score_module_verification_report/BUILD index 6796261c5..80d1e70d5 100644 --- a/src/extensions/score_module_verification_report/BUILD +++ b/src/extensions/score_module_verification_report/BUILD @@ -12,19 +12,41 @@ # ******************************************************************************* load("@aspect_rules_py//py:defs.bzl", "py_library") load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") +load("//:score_pytest.bzl", "score_pytest") filegroup( - name = "all_sources", + name = "sources", srcs = glob(["*.py"]), +) + +filegroup( + name = "tests", + srcs = glob(["tests/*.py"]), +) + +filegroup( + name = "all_sources", + srcs = [ + ":sources", + ":tests", + ], visibility = ["//visibility:public"], ) py_library( name = "score_module_verification_report", - srcs = [":all_sources"], + srcs = [":sources"], imports = ["."], visibility = ["//visibility:public"], deps = all_requirements + [ "@score_docs_as_code//src/helper_lib", ], ) + +score_pytest( + name = "score_module_verification_report_tests", + size = "small", + srcs = glob(["tests/*.py"]), + deps = [":score_module_verification_report"], + pytest_config = "//:pyproject.toml", +) diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index b3fe7a1e1..0557c7685 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -10,21 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Sphinx directive that generates the per-module verification report body. - -Components are discovered by a shallow filesystem scan of the docs source -tree for ``.. comp::`` (and ``.. document::``) directives, cached on the -Sphinx environment at ``env-before-read-docs`` time. This keeps the -extension ``parallel_read_safe`` — no dependency on sphinx-needs internal -state during the read phase. - -Per-work-product realization is delegated to sphinx-needs: each -"Realized by" cell renders a ``.. needlist::`` filtered by the component -slug (normalised underscore-free substring match on the doc id) and by -the ``realizes`` link to the work product. Coverage status is derived -from ``coverage_summary.json``. Only components whose docs are named -differently from the component slug (see ``overrides`` in the config) -need explicit per-component data. +"""Sphinx extension that generates the per-module verification report body. Usage in RST:: @@ -44,864 +30,34 @@ - ... # optional; per-component overrides for irregular cases (documents # whose id does not contain the component slug, e.g. - # ``comp__baselibs_nlohman_json`` → ``doc__json_*``) + # ``comp__baselibs_nlohman_json`` -> ``doc__json_*``) overrides: comp__baselibs_some_component: workproducts: requirements_inspect: doc__some_component_req_inspection ... -""" -from __future__ import annotations - -import json -import os -import re -from typing import Any - -import yaml -from docutils import nodes -from docutils.statemachine import ViewList -from sphinx.util.docutils import SphinxDirective -from sphinx.util.nodes import nested_parse_with_titles - - -# --------------------------------------------------------------------------- -# Need discovery (filesystem scan) -# --------------------------------------------------------------------------- -# -# We deliberately do **not** query ``SphinxNeedsData`` here: doing so would -# require reading the report source strictly after every source registering -# a ``.. comp::`` / ``.. document::`` need, which forces ``parallel_read_safe -# = False`` on the extension and produces two Sphinx-level warnings per -# build (``the score_module_verification_report extension is not safe for -# parallel reading`` / ``doing serial read``). Those warnings are fatal -# under ``-W``. -# -# The RST directive syntax used across baselibs is stable: -# -# .. comp:: <title> -# :id: comp__baselibs_<slug> -# :safety: ASIL_B -# :security: NO -# :status: valid -# ... -# -# .. document:: <title> -# :id: doc__<slug>_<suffix> -# :realizes: wp__<key>[version==<N>] -# ... -# -# Documents are matched to a work product entirely on the sphinx-needs -# side, at render time: each per-component "Realized by" cell is a -# ``.. needlist::`` filtered by ``type == "document"``, by a -# normalised-slug substring match of the component slug against the doc -# id, and by the ``realizes`` link containing the WP id. This scan only -# needs to enumerate components and their titles. -# -# A shallow regex scan of the source tree at ``env-before-read-docs`` -# gives us everything the directive needs, and works in every process of -# a parallel build. - -_DIRECTIVE_HEADER_RE = re.compile( - r"^\.\.[ \t]+(?P<name>[a-z_-]+)::[ \t]*(?P<title>.*?)\s*$" -) -_OPTION_LINE_RE = re.compile(r"^[ \t]+:(?P<key>[^:]+):[ \t]*(?P<value>.*?)\s*$") - -# Options captured from ``:key: value`` lines. Everything else -# (safety, security, status, realizes, tags, ...) is intentionally -# dropped: the report delegates all attribute and link resolution to -# sphinx-needs at render time (``.. needtable::`` / ``.. needlist::``). -# ``includes`` is captured only for ``.. mod::`` needs (whitelist of -# components; see :func:`_module_includes`); ``version`` is captured -# on ``.. comp::`` needs to honour ``[version==N]`` filters coming from -# that whitelist. -_SCANNED_OPTIONS = frozenset({"id", "includes", "version"}) - - -def _scan_rst_needs(srcdir: str, directives: set[str]) -> list[dict]: - """Return every need declared by one of *directives* under *srcdir*. - - Each result carries ``directive`` (e.g. ``comp``), ``id`` and - ``title``. Silently skips unreadable files. - """ - results: list[dict] = [] - for root, _dirs, files in os.walk(srcdir): - for fname in files: - if not fname.endswith(".rst"): - continue - path = os.path.join(root, fname) - try: - with open(path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - except (OSError, UnicodeDecodeError): - continue - i = 0 - while i < len(lines): - m = _DIRECTIVE_HEADER_RE.match(lines[i]) - if not m or m.group("name") not in directives: - i += 1 - continue - entry: dict[str, Any] = { - "directive": m.group("name"), - "title": m.group("title").strip(), - } - j = i + 1 - while j < len(lines): - opt = _OPTION_LINE_RE.match(lines[j]) - if not opt: - break - key = opt.group("key").strip() - if key in _SCANNED_OPTIONS: - entry[key] = opt.group("value").strip() - j += 1 - if "id" in entry: - results.append(entry) - i = j if j > i else i + 1 - return results - - -def _normalize_slug(text: str) -> str: - """Return *text* stripped of underscores and lower-cased. - - Component ids and document ids sometimes spell the same component - with different underscoring (``bit_manipulation`` vs. - ``bitmanipulation``). Comparing on the underscore-free form makes - that difference invisible without introducing per-component config. - """ - return text.replace("_", "").lower() - - -_INCLUDE_ENTRY_RE = re.compile( - r"^(?P<id>[^\[\s]+)(?:\[version==(?P<version>[^\]]+)\])?\s*$" -) - - -def _module_includes( - needs: list[dict], module_id: str -) -> dict[str, str | None] | None: - """Return the component ids listed in ``:includes:`` on the - ``.. mod::`` need whose id equals *module_id*, mapped to their - required version (or ``None`` if no ``[version==N]`` filter was set). - - Entries in ``:includes:`` have the form ``<id>[version==<N>]`` and - are comma-separated. Returns ``None`` if no matching mod need is - found in *needs* (spec error → caller renders an ``error`` node). - """ - for entry in needs: - if entry.get("directive") != "mod" or entry.get("id") != module_id: - continue - raw = entry.get("includes", "") - result: dict[str, str | None] = {} - for part in raw.split(","): - m = _INCLUDE_ENTRY_RE.match(part.strip()) - if m: - result[m.group("id")] = m.group("version") - return result - return None - - -def _discover_components( - env, component_prefix: str, whitelist: dict[str, str | None] -) -> list[dict]: - """Return every ``.. comp::`` need whose id (and, when a - ``[version==N]`` filter was declared, whose ``:version:``) matches - an entry in *whitelist*. - - Sourced from the filesystem scan cached on ``env`` at - ``env-before-read-docs``. Components are returned sorted by id for a - stable display order. Whitelist entries with no matching - ``.. comp::`` scan result are silently ignored (caller may want to - warn). - """ - result: list[dict] = [] - for entry in getattr(env, "module_verification_report_needs", []): - if entry.get("directive") != "comp": - continue - need_id = entry.get("id", "") - if need_id not in whitelist: - continue - required_version = whitelist[need_id] - if required_version is not None and entry.get("version") != required_version: - continue - slug = ( - need_id[len(component_prefix):] - if need_id.startswith(component_prefix) - else need_id - ) - result.append( - { - "id": need_id, - "slug": slug, - "title": entry.get("title") or need_id, - } - ) - result.sort(key=lambda c: c["id"]) - return result - - -# --------------------------------------------------------------------------- -# RST rendering -# --------------------------------------------------------------------------- - -_COMPONENT_TEMPLATE = """ -.. _{ref}: - -{title} -{title_underline} - -.. raw:: html - - <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> - -Component Requirements Statistics -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {title} Requirements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type == "comp_req" and "{comp_id}" in satisfied_by and status == "valid" - type == "comp_req" and "{comp_id}" in satisfied_by and status == "invalid" - - .. grid-item:: - - .. needpie:: {title} Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "comp_req" and "{comp_id}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{comp_id}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{comp_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) - -Component Architecture Statistics -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {title} Architecture Elements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "valid" - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "invalid" - - .. grid-item:: - - .. needpie:: {title} Architecture Elements Inspection Status - :labels: inspected, not inspected - :colors: #37a12d, #ca2828 - :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and "inspected" in tags - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_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 "{comp_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 - -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 "{comp_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 (``\u2014``) 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} -""" - - -# Kept for later re-activation. To re-enable the Unit Test Coverage -# section, append this fragment to ``_COMPONENT_TEMPLATE`` and restore -# the ``coverage_intro=_coverage_intro(comp, coverage_data)`` kwarg in -# ``_render_component``. -_COMPONENT_COVERAGE_SECTION_DISABLED = """\ - -Unit Test Coverage -^^^^^^^^^^^^^^^^^^ - -{coverage_intro} -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Metric - - Coverage - * - Lines - - |coverage_{slug}_lines| - * - Functions - - |coverage_{slug}_functions| - * - Branches - - |coverage_{slug}_branches| -""" - - -_COVERAGE_INTRO_MEASURED = ( - "Aggregated from ``bazel coverage``. Regenerate via\n" - "``python3 tools/extract_coverage.py \"$(bazel info output_path)" - "/_coverage/_coverage_report.dat\" docs/reporting/coverage_summary.json``.\n" -) -_COVERAGE_INTRO_SPEC_ONLY = ( - "This component is specification-only and has no dedicated unit\n" - "test binary in ``//score/\u2026``.\n" -) - -_COVERAGE_SUMMARY_REL_PATH = os.path.join("reporting", "coverage_summary.json") - - -def _load_coverage_summary(env) -> dict: - """Return the parsed ``coverage_summary.json`` (empty dict on failure). - - The JSON is produced by ``tools/extract_coverage.py`` from an LCOV - report. Its top-level keys are component slugs (matching - ``comp__<module>_<slug>`` in the sphinx-needs data). Presence of a - slug with real metric values marks that component as *measured*; - absence marks it as *specification-only*. - """ - path = os.path.join(env.srcdir, _COVERAGE_SUMMARY_REL_PATH) - try: - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - except (OSError, ValueError): - return {} - if os.path.isfile(path): - env.note_dependency(path) - return data or {} - -_DEFAULT_WORKPRODUCTS = [ - {"key": "requirements_inspect", "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect"}, - {"key": "sw_arch_verification", "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification"}, - {"key": "sw_implementation_inspection", "label": "Implementation Inspection", - "wp_id": "wp__sw_implementation_inspection"}, - {"key": "sw_component_dfa", "label": "DFA", - "wp_id": "wp__sw_component_dfa"}, - {"key": "sw_component_fmea", "label": "FMEA", - "wp_id": "wp__sw_component_fmea"}, -] - - -def _slugify(text: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") - - -def _workproduct_rows( - slug_norm: str, - overrides: dict, - workproducts: list[dict], -) -> str: - """Render the work-product rows for one component or the feature. - - Each row has four cells: the work-product ``:need:`` link, its - label, the realising document, and its status. The "Realized by" - and "Status" cells are populated by sphinx-needs so their content - stays in sync with the actual sphinx-needs data model: - - 1. **Overrides** — when ``overrides['workproducts'][wp_key]`` names - an explicit doc id, the row renders a direct ``:need:`` link - and a ``:ndf:`copy('status', ...)``` call that pulls the doc's - status field verbatim. - 2. **Filter** — otherwise, both cells render a ``.. needtable::`` - with the same filter (``type == "document"``, normalised-slug - substring match on the doc id, ``realizes`` link containing - ``wp['wp_id']``) but different ``:columns:``. If nothing matches, - both cells are empty. - """ - explicit = overrides.get("workproducts") or {} - lines: list[str] = [] - for wp in workproducts: - override_doc = explicit.get(wp["key"]) - lines.append(f" * - :need:`{wp['wp_id']}`") - lines.append(f" - {wp['label']}") - if override_doc: - lines.append(f" - :need:`{override_doc}`") - lines.append( - f" - :ndf:`copy('status', " - f"need_id='{override_doc}')`" - ) - else: - filter_expr = ( - f"type == \"document\" and " - f"\"{slug_norm}\" in id.replace(\"_\", \"\") and " - f"\"{wp['wp_id']}\" in realizes" - ) - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: id") - lines.append(" :style: table") - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: status") - lines.append(" :style: table") - return "\n".join(lines) - - -def _coverage_intro(comp: dict, coverage_data: dict) -> str: - """Choose the intro paragraph based on ``coverage_summary.json``. - - A component counts as *measured* iff its slug appears in the JSON - with at least one non-null metric percentage. Otherwise it is - treated as specification-only. This mirrors how the - ``|coverage_<slug>_*|`` substitutions in ``conf.py`` decide between - numeric output and ``"not measured"``. - """ - entry = coverage_data.get(comp["slug"]) or {} - measured = any( - entry.get(f"{m}_pct") is not None - for m in ("lines", "functions", "branches") - ) - return (_COVERAGE_INTRO_MEASURED if measured else _COVERAGE_INTRO_SPEC_ONLY) + "\n" - - -def _render_component( - comp: dict, - overrides: dict, - workproducts: list[dict], - coverage_data: dict, -) -> str: - title = comp["title"] - slug = comp["slug"] - ref = "comp-" + _slugify(title) - return _COMPONENT_TEMPLATE.format( - ref=ref, - title=title, - title_underline="~" * len(title), - comp_id=comp["id"], - slug=slug, - workproduct_rows=_workproduct_rows( - _normalize_slug(slug), overrides, workproducts - ), - # Unit Test Coverage section disabled — see - # ``_COMPONENT_COVERAGE_SECTION_DISABLED``. Restore by passing - # ``coverage_intro=_coverage_intro(comp, coverage_data)``. - ) - - -_DEFAULT_FEATURE_WORKPRODUCTS = [ - {"key": "requirements_inspect", "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect"}, - {"key": "sw_arch_verification", "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification"}, -] - - -_FEATURE_TEMPLATE = """\ -Feature -------- - -.. needtable:: - :filter: id == "{feature_id}" - :columns: title as "Name";id as "Id";safety;security;status - :style: table - -Feature Requirements Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: Feature Requirements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type == "feat_req" and "{feature_id}" in satisfied_by and status == "valid" - type == "feat_req" and "{feature_id}" in satisfied_by and status == "invalid" - - .. grid-item:: - - .. needpie:: Feature Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "feat_req" and "{feature_id}" in satisfied_by and ("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 - -Feature Architecture Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: Feature Architecture Elements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "valid" - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "invalid" - - .. grid-item:: - - .. needpie:: Feature Architecture Elements Inspection Status - :labels: inspected, not inspected - :colors: #37a12d, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" in tags - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" not in tags - -.. dropdown:: Show architectural elements table - :animate: fade-in - - .. needtable:: - :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to - :style: table - :columns: id;title;safety;status;tags - :colwidths: 25,30,10,15,20 - :sort: id - -Feature Inspection Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Presence of the feature-level inspection work products. - -.. dropdown:: Show work products table - :animate: fade-in - - .. list-table:: - :header-rows: 1 - :widths: 30 25 25 20 - :class: wp-doc-table - - * - Work Product - - Kind - - Realized by - - Status -{feature_workproduct_rows} -""" - - -def _render_feature( - feature_id: str, - feature_slug: str, - feature_overrides: dict, - feature_workproducts: list[dict], -) -> str: - """Render the ``Feature`` section (Requirements / Architecture / - Inspection Statistics), delegating all attribute filtering to - sphinx-needs. - - Feature statistics filter ``feat_req`` / ``feat_arc_*`` by - ``"{feature_id}" in belongs_to`` — the same link that the source - RST declares — so the report tracks the sphinx-needs data model - directly instead of guessing from id substrings. The Feature summary - ``needtable`` pulls title / safety / security / status from the - ``feat__*`` need itself. The Inspection Statistics work-product - rows still substring-match the ``feature_slug`` against document - ids because documents have no direct link back to the feature. - """ - return _FEATURE_TEMPLATE.format( - feature_id=feature_id, - feature_slug=feature_slug, - feature_workproduct_rows=_workproduct_rows( - _normalize_slug(feature_slug), - feature_overrides, - feature_workproducts, - ), - ) - - -_COMPONENTS_HEADER = """\ -Components ----------- +Implementation is split across: +* :mod:`.scanner` — filesystem scan for ``.. mod::`` / ``.. comp::`` +* :mod:`.coverage` — ``coverage_summary.json`` loading +* :mod:`.templates` — RST templates + default workproduct lists + CSS +* :mod:`.rendering` — template expansion / report body assembly +* :mod:`.directive` — the ``ModuleVerificationReportDirective`` class """ +from __future__ import annotations +from typing import Any -# Hide the auto-generated header / chrome of the inner ``.. needtable::`` -# widgets that render the "Realized by" / "Status" cells of the WP tables. -# Without this the cells show a nested table with its own "ID" / "Status" -# header row and datatables toolbar, which is visually noisy for a single -# value. Scoped to ``.wp-doc-table`` set as the outer list-table's class. -_WP_TABLE_CSS = """\ -.. raw:: html - - <style> - .wp-doc-table td .needstable_wrapper, - .wp-doc-table td .pst-scrollable-table-container { - margin: 0; padding: 0; overflow: visible; - } - .wp-doc-table td table.NEEDS_TABLE, - .wp-doc-table td table.NEEDS_DATATABLES { - border: 0; margin: 0; box-shadow: none; background: transparent; - width: auto; - } - .wp-doc-table td table.NEEDS_TABLE thead, - .wp-doc-table td table.NEEDS_DATATABLES thead { display: none; } - .wp-doc-table td table.NEEDS_TABLE tbody tr, - .wp-doc-table td table.NEEDS_DATATABLES tbody tr { background: transparent; } - .wp-doc-table td table.NEEDS_TABLE tbody td, - .wp-doc-table td table.NEEDS_DATATABLES tbody td { - border: 0; padding: 0; background: transparent; - } - .wp-doc-table td .dataTables_wrapper .dataTables_length, - .wp-doc-table td .dataTables_wrapper .dataTables_filter, - .wp-doc-table td .dataTables_wrapper .dataTables_info, - .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - </style> -""" - - -_OVERVIEW_TEMPLATE = """\ -Component Overview -~~~~~~~~~~~~~~~~~~ - -.. needtable:: - :filter: id in {ids_literal} - :columns: id as "Component";safety;security;status - :style: table - :sort: id -""" - - -def _render_overview(components: list[dict]) -> str: - """Render the component overview as a ``.. needtable::``. - - Delegating to sphinx-needs means ``safety``/``security``/``status`` - come from its data model (validated, normalised, consistent with the - rest of the site) rather than from raw strings scraped by our - filesystem scan. Trade-off: the ``Component`` cell links to the - need's detail page, not to the per-component section further down - this page. - """ - ids_literal = "[" + ", ".join(f'"{c["id"]}"' for c in components) + "]" - return _OVERVIEW_TEMPLATE.format(ids_literal=ids_literal) - - -def _render_report( - components: list[dict], - feature_id: str, - feature_slug: str, - overrides_by_id: dict[str, dict], - workproducts: list[dict], - feature_workproducts: list[dict], - coverage_data: dict, -) -> str: - feature_overrides = overrides_by_id.get(feature_id, {}) - parts = [ - _WP_TABLE_CSS, - _render_feature( - feature_id, feature_slug, feature_overrides, feature_workproducts - ), - _COMPONENTS_HEADER, - _render_overview(components), - ] - for comp in components: - overrides = overrides_by_id.get(comp["id"], {}) - parts.append( - _render_component( - comp, - overrides, - workproducts, - coverage_data, - ) - ) - return "\n".join(parts) - - -# --------------------------------------------------------------------------- -# Sphinx directive -# --------------------------------------------------------------------------- - - -class ModuleVerificationReportDirective(SphinxDirective): - """Expand to the per-module verification report body. - - Discovers components dynamically from the sphinx-needs data model by - filtering all needs by ``type == "comp"`` and - ``id.startswith(component_prefix)``. - """ - - required_arguments = 0 - optional_arguments = 0 - option_spec = {"config": str} - has_content = False - - def _load_config(self, rel_config: str | None) -> dict: - if not rel_config: - return {} - srcdir = self.env.srcdir - config_path = os.path.join(srcdir, rel_config) - if not os.path.isfile(config_path): - self.state_machine.reporter.warning( - f"module-verification-report: config not found: {config_path}", - line=self.lineno, - ) - return {} - with open(config_path, "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) or {} - self.env.note_dependency(config_path) - return data - - def run(self) -> list[nodes.Node]: - config = self._load_config(self.options.get("config")) - - module_id = config.get("module_id", "") - component_prefix = config.get("component_prefix") or ( - "comp__" + module_id[len("mod__"):] + "_" - if module_id.startswith("mod__") - else "comp__" - ) - module_short = ( - module_id[len("mod__"):] - if module_id.startswith("mod__") - else module_id - ) - feature_id = config.get("feature_id") or f"feat__{module_short}" - feature_slug = ( - feature_id.split("__", 1)[1] - if "__" in feature_id - else feature_id - ) - workproducts = config.get("workproducts") or _DEFAULT_WORKPRODUCTS - feature_workproducts = ( - config.get("feature_workproducts") or _DEFAULT_FEATURE_WORKPRODUCTS - ) - overrides_by_id: dict[str, dict] = config.get("overrides") or {} - - all_needs = getattr(self.env, "module_verification_report_needs", []) - include_ids = _module_includes(all_needs, module_id) - if include_ids is None: - error = self.state_machine.reporter.error( - f"module-verification-report: no '.. mod::' need with " - f"id '{module_id}' found in the source tree " - f"(is 'module_id' set correctly in the config?)", - line=self.lineno, - ) - return [error] - - components = _discover_components( - self.env, component_prefix, include_ids - ) - missing = set(include_ids) - {c["id"] for c in components} - for m in sorted(missing): - required = include_ids[m] - hint = f" (version=={required})" if required else "" - self.state_machine.reporter.warning( - f"module-verification-report: '{module_id}' includes " - f"'{m}'{hint} but no matching '.. comp::' need was found", - line=self.lineno, - ) - if not components: - error = self.state_machine.reporter.error( - f"module-verification-report: '{module_id}' has no " - f"resolvable components in ':includes:'", - line=self.lineno, - ) - return [error] - - coverage_data = _load_coverage_summary(self.env) - - rst_text = _render_report( - components, - feature_id, - feature_slug, - overrides_by_id, - workproducts, - feature_workproducts, - coverage_data, - ) - view_list = ViewList() - source = "<module-verification-report>" - for lineno, line in enumerate(rst_text.splitlines()): - view_list.append(line, source, lineno) - - # Parse into a plain container (not a ``nodes.section``): a section - # wrapper would push every heading we emit one level deeper than the - # surrounding document sections, so ``Component Overview`` would - # render as ``<h4>`` instead of ``<h3>`` alongside - # ``Feature Requirements Statistics``. - container = nodes.container() - container.document = self.state.document - nested_parse_with_titles(self.state, view_list, container) - return container.children - - -def _scan_source_tree(app, env, docnames): - """Cache a filesystem scan of all ``.. mod::`` and ``.. comp::`` - needs on ``env`` so the directive can enumerate its components in - every process of a parallel build. - """ - env.module_verification_report_needs = _scan_rst_needs( - env.srcdir, directives={"mod", "comp"} - ) +from .directive import ModuleVerificationReportDirective +from .scanner import scan_source_tree def setup(app: Any) -> dict: app.add_directive( "module-verification-report", ModuleVerificationReportDirective ) - app.connect("env-before-read-docs", _scan_source_tree) + app.connect("env-before-read-docs", scan_source_tree) return { "version": "0.6", "parallel_read_safe": True, diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py new file mode 100644 index 000000000..830b5ea2a --- /dev/null +++ b/src/extensions/score_module_verification_report/coverage.py @@ -0,0 +1,67 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Coverage summary loading and intro-paragraph selection. + +The JSON is produced by ``tools/extract_coverage.py`` from an LCOV +report. Its top-level keys are component slugs (matching +``comp__<module>_<slug>`` in the sphinx-needs data). Presence of a slug +with real metric values marks that component as *measured*; absence +marks it as *specification-only*. +""" +from __future__ import annotations + +import json +import os + + +COVERAGE_INTRO_MEASURED = ( + "Aggregated from ``bazel coverage``. Regenerate via\n" + "``python3 tools/extract_coverage.py \"$(bazel info output_path)" + "/_coverage/_coverage_report.dat\" docs/reporting/coverage_summary.json``.\n" +) +COVERAGE_INTRO_SPEC_ONLY = ( + "This component is specification-only and has no dedicated unit\n" + "test binary in ``//score/\u2026``.\n" +) + +COVERAGE_SUMMARY_REL_PATH = os.path.join("reporting", "coverage_summary.json") + + +def load_coverage_summary(env) -> dict: + """Return the parsed ``coverage_summary.json`` (empty dict on failure).""" + path = os.path.join(env.srcdir, COVERAGE_SUMMARY_REL_PATH) + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + if os.path.isfile(path): + env.note_dependency(path) + return data or {} + + +def coverage_intro(comp: dict, coverage_data: dict) -> str: + """Choose the intro paragraph based on ``coverage_summary.json``. + + A component counts as *measured* iff its slug appears in the JSON + with at least one non-null metric percentage. Otherwise it is + treated as specification-only. This mirrors how the + ``|coverage_<slug>_*|`` substitutions in ``conf.py`` decide between + numeric output and ``"not measured"``. + """ + entry = coverage_data.get(comp["slug"]) or {} + measured = any( + entry.get(f"{m}_pct") is not None + for m in ("lines", "functions", "branches") + ) + return (COVERAGE_INTRO_MEASURED if measured else COVERAGE_INTRO_SPEC_ONLY) + "\n" diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py new file mode 100644 index 000000000..424d2e2d6 --- /dev/null +++ b/src/extensions/score_module_verification_report/directive.py @@ -0,0 +1,140 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""The ``.. module-verification-report::`` Sphinx directive.""" +from __future__ import annotations + +import os + +import yaml +from docutils import nodes +from docutils.statemachine import ViewList +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles + +from .coverage import load_coverage_summary +from .rendering import render_report +from .scanner import discover_components, module_includes +from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS + + +class ModuleVerificationReportDirective(SphinxDirective): + """Expand to the per-module verification report body. + + Discovers components dynamically from the sphinx-needs data model by + filtering all needs by ``type == "comp"`` and + ``id.startswith(component_prefix)``. + """ + + required_arguments = 0 + optional_arguments = 0 + option_spec = {"config": str} + has_content = False + + def _load_config(self, rel_config: str | None) -> dict: + if not rel_config: + return {} + srcdir = self.env.srcdir + config_path = os.path.join(srcdir, rel_config) + if not os.path.isfile(config_path): + self.state_machine.reporter.warning( + f"module-verification-report: config not found: {config_path}", + line=self.lineno, + ) + return {} + with open(config_path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + self.env.note_dependency(config_path) + return data + + def run(self) -> list[nodes.Node]: + config = self._load_config(self.options.get("config")) + + module_id = config.get("module_id", "") + component_prefix = config.get("component_prefix") or ( + "comp__" + module_id[len("mod__"):] + "_" + if module_id.startswith("mod__") + else "comp__" + ) + module_short = ( + module_id[len("mod__"):] + if module_id.startswith("mod__") + else module_id + ) + feature_id = config.get("feature_id") or f"feat__{module_short}" + feature_slug = ( + feature_id.split("__", 1)[1] + if "__" in feature_id + else feature_id + ) + workproducts = config.get("workproducts") or DEFAULT_WORKPRODUCTS + feature_workproducts = ( + config.get("feature_workproducts") or DEFAULT_FEATURE_WORKPRODUCTS + ) + overrides_by_id: dict[str, dict] = config.get("overrides") or {} + + all_needs = getattr(self.env, "module_verification_report_needs", []) + include_ids = module_includes(all_needs, module_id) + if include_ids is None: + error = self.state_machine.reporter.error( + f"module-verification-report: no '.. mod::' need with " + f"id '{module_id}' found in the source tree " + f"(is 'module_id' set correctly in the config?)", + line=self.lineno, + ) + return [error] + + components = discover_components( + self.env, component_prefix, include_ids + ) + missing = set(include_ids) - {c["id"] for c in components} + for m in sorted(missing): + required = include_ids[m] + hint = f" (version=={required})" if required else "" + self.state_machine.reporter.warning( + f"module-verification-report: '{module_id}' includes " + f"'{m}'{hint} but no matching '.. comp::' need was found", + line=self.lineno, + ) + if not components: + error = self.state_machine.reporter.error( + f"module-verification-report: '{module_id}' has no " + f"resolvable components in ':includes:'", + line=self.lineno, + ) + return [error] + + coverage_data = load_coverage_summary(self.env) + + rst_text = render_report( + components, + feature_id, + feature_slug, + overrides_by_id, + workproducts, + feature_workproducts, + coverage_data, + ) + view_list = ViewList() + source = "<module-verification-report>" + for lineno, line in enumerate(rst_text.splitlines()): + view_list.append(line, source, lineno) + + # Parse into a plain container (not a ``nodes.section``): a section + # wrapper would push every heading we emit one level deeper than the + # surrounding document sections, so ``Component Overview`` would + # render as ``<h4>`` instead of ``<h3>`` alongside + # ``Feature Requirements Statistics``. + container = nodes.container() + container.document = self.state.document + nested_parse_with_titles(self.state, view_list, container) + return container.children diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py new file mode 100644 index 000000000..355343767 --- /dev/null +++ b/src/extensions/score_module_verification_report/rendering.py @@ -0,0 +1,189 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Rendering functions that expand :mod:`.templates` for the report body.""" +from __future__ import annotations + +import re + +from .templates import ( + COMPONENT_TEMPLATE, + COMPONENTS_HEADER, + FEATURE_TEMPLATE, + OVERVIEW_TEMPLATE, + WP_TABLE_CSS, +) + + +def normalize_slug(text: str) -> str: + """Return *text* stripped of underscores and lower-cased. + + Component ids and document ids sometimes spell the same component + with different underscoring (``bit_manipulation`` vs. + ``bitmanipulation``). Comparing on the underscore-free form makes + that difference invisible without introducing per-component config. + """ + return text.replace("_", "").lower() + + +def slugify(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + + +def workproduct_rows( + slug_norm: str, + overrides: dict, + workproducts: list[dict], +) -> str: + """Render the work-product rows for one component or the feature. + + Each row has four cells: the work-product ``:need:`` link, its + label, the realising document, and its status. The "Realized by" + and "Status" cells are populated by sphinx-needs so their content + stays in sync with the actual sphinx-needs data model: + + 1. **Overrides** — when ``overrides['workproducts'][wp_key]`` names + an explicit doc id, the row renders a direct ``:need:`` link + and a ``:ndf:`copy('status', ...)``` call that pulls the doc's + status field verbatim. + 2. **Filter** — otherwise, both cells render a ``.. needtable::`` + with the same filter (``type == "document"``, normalised-slug + substring match on the doc id, ``realizes`` link containing + ``wp['wp_id']``) but different ``:columns:``. If nothing matches, + both cells are empty. + """ + explicit = overrides.get("workproducts") or {} + lines: list[str] = [] + for wp in workproducts: + override_doc = explicit.get(wp["key"]) + lines.append(f" * - :need:`{wp['wp_id']}`") + lines.append(f" - {wp['label']}") + if override_doc: + lines.append(f" - :need:`{override_doc}`") + lines.append( + f" - :ndf:`copy('status', " + f"need_id='{override_doc}')`" + ) + else: + filter_expr = ( + f"type == \"document\" and " + f"\"{slug_norm}\" in id.replace(\"_\", \"\") and " + f"\"{wp['wp_id']}\" in realizes" + ) + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: id") + lines.append(" :style: table") + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: status") + lines.append(" :style: table") + return "\n".join(lines) + + +def render_component( + comp: dict, + overrides: dict, + workproducts: list[dict], + coverage_data: dict, +) -> str: + title = comp["title"] + slug = comp["slug"] + ref = "comp-" + slugify(title) + return COMPONENT_TEMPLATE.format( + ref=ref, + title=title, + title_underline="~" * len(title), + comp_id=comp["id"], + slug=slug, + workproduct_rows=workproduct_rows( + normalize_slug(slug), overrides, workproducts + ), + # Unit Test Coverage section disabled — see + # ``templates.COMPONENT_COVERAGE_SECTION_DISABLED``. Restore by + # passing ``coverage_intro=coverage_intro(comp, coverage_data)``. + ) + + +def render_feature( + feature_id: str, + feature_slug: str, + feature_overrides: dict, + feature_workproducts: list[dict], +) -> str: + """Render the ``Feature`` section (Requirements / Architecture / + Inspection Statistics), delegating all attribute filtering to + sphinx-needs. + + Feature statistics filter ``feat_req`` / ``feat_arc_*`` by + ``"{feature_id}" in belongs_to`` — the same link that the source + RST declares — so the report tracks the sphinx-needs data model + directly instead of guessing from id substrings. The Feature summary + ``needtable`` pulls title / safety / security / status from the + ``feat__*`` need itself. The Inspection Statistics work-product + rows still substring-match the ``feature_slug`` against document + ids because documents have no direct link back to the feature. + """ + return FEATURE_TEMPLATE.format( + feature_id=feature_id, + feature_slug=feature_slug, + feature_workproduct_rows=workproduct_rows( + normalize_slug(feature_slug), + feature_overrides, + feature_workproducts, + ), + ) + + +def render_overview(components: list[dict]) -> str: + """Render the component overview as a ``.. needtable::``. + + Delegating to sphinx-needs means ``safety``/``security``/``status`` + come from its data model (validated, normalised, consistent with the + rest of the site) rather than from raw strings scraped by our + filesystem scan. Trade-off: the ``Component`` cell links to the + need's detail page, not to the per-component section further down + this page. + """ + ids_literal = "[" + ", ".join(f'"{c["id"]}"' for c in components) + "]" + return OVERVIEW_TEMPLATE.format(ids_literal=ids_literal) + + +def render_report( + components: list[dict], + feature_id: str, + feature_slug: str, + overrides_by_id: dict[str, dict], + workproducts: list[dict], + feature_workproducts: list[dict], + coverage_data: dict, +) -> str: + feature_overrides = overrides_by_id.get(feature_id, {}) + parts = [ + WP_TABLE_CSS, + render_feature( + feature_id, feature_slug, feature_overrides, feature_workproducts + ), + COMPONENTS_HEADER, + render_overview(components), + ] + for comp in components: + overrides = overrides_by_id.get(comp["id"], {}) + parts.append( + render_component( + comp, + overrides, + workproducts, + coverage_data, + ) + ) + return "\n".join(parts) diff --git a/src/extensions/score_module_verification_report/scanner.py b/src/extensions/score_module_verification_report/scanner.py new file mode 100644 index 000000000..848fa2db4 --- /dev/null +++ b/src/extensions/score_module_verification_report/scanner.py @@ -0,0 +1,184 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Filesystem scanner for ``.. mod::`` / ``.. comp::`` needs. + +We deliberately do **not** query ``SphinxNeedsData`` here: doing so would +require reading the report source strictly after every source registering +a ``.. comp::`` / ``.. document::`` need, which forces +``parallel_read_safe = False`` on the extension and produces two +Sphinx-level warnings per build (``the score_module_verification_report +extension is not safe for parallel reading`` / ``doing serial read``). +Those warnings are fatal under ``-W``. + +The RST directive syntax used across baselibs is stable:: + + .. comp:: <title> + :id: comp__baselibs_<slug> + :safety: ASIL_B + :security: NO + :status: valid + ... + + .. document:: <title> + :id: doc__<slug>_<suffix> + :realizes: wp__<key>[version==<N>] + ... + +Documents are matched to a work product entirely on the sphinx-needs +side, at render time. This scan only needs to enumerate components and +their titles. + +A shallow regex scan of the source tree at ``env-before-read-docs`` +gives us everything the directive needs, and works in every process of +a parallel build. +""" +from __future__ import annotations + +import os +import re +from typing import Any + + +_DIRECTIVE_HEADER_RE = re.compile( + r"^\.\.[ \t]+(?P<name>[a-z_-]+)::[ \t]*(?P<title>.*?)\s*$" +) +_OPTION_LINE_RE = re.compile(r"^[ \t]+:(?P<key>[^:]+):[ \t]*(?P<value>.*?)\s*$") + +# Options captured from ``:key: value`` lines. Everything else +# (safety, security, status, realizes, tags, ...) is intentionally +# dropped: the report delegates all attribute and link resolution to +# sphinx-needs at render time (``.. needtable::`` / ``.. needlist::``). +# ``includes`` is captured only for ``.. mod::`` needs (whitelist of +# components; see :func:`module_includes`); ``version`` is captured +# on ``.. comp::`` needs to honour ``[version==N]`` filters coming from +# that whitelist. +_SCANNED_OPTIONS = frozenset({"id", "includes", "version"}) + +_INCLUDE_ENTRY_RE = re.compile( + r"^(?P<id>[^\[\s]+)(?:\[version==(?P<version>[^\]]+)\])?\s*$" +) + + +def scan_rst_needs(srcdir: str, directives: set[str]) -> list[dict]: + """Return every need declared by one of *directives* under *srcdir*. + + Each result carries ``directive`` (e.g. ``comp``), ``id`` and + ``title``. Silently skips unreadable files. + """ + results: list[dict] = [] + for root, _dirs, files in os.walk(srcdir): + for fname in files: + if not fname.endswith(".rst"): + continue + path = os.path.join(root, fname) + try: + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + except (OSError, UnicodeDecodeError): + continue + i = 0 + while i < len(lines): + m = _DIRECTIVE_HEADER_RE.match(lines[i]) + if not m or m.group("name") not in directives: + i += 1 + continue + entry: dict[str, Any] = { + "directive": m.group("name"), + "title": m.group("title").strip(), + } + j = i + 1 + while j < len(lines): + opt = _OPTION_LINE_RE.match(lines[j]) + if not opt: + break + key = opt.group("key").strip() + if key in _SCANNED_OPTIONS: + entry[key] = opt.group("value").strip() + j += 1 + if "id" in entry: + results.append(entry) + i = j if j > i else i + 1 + return results + + +def module_includes( + needs: list[dict], module_id: str +) -> dict[str, str | None] | None: + """Return the component ids listed in ``:includes:`` on the + ``.. mod::`` need whose id equals *module_id*, mapped to their + required version (or ``None`` if no ``[version==N]`` filter was set). + + Entries in ``:includes:`` have the form ``<id>[version==<N>]`` and + are comma-separated. Returns ``None`` if no matching mod need is + found in *needs* (spec error → caller renders an ``error`` node). + """ + for entry in needs: + if entry.get("directive") != "mod" or entry.get("id") != module_id: + continue + raw = entry.get("includes", "") + result: dict[str, str | None] = {} + for part in raw.split(","): + m = _INCLUDE_ENTRY_RE.match(part.strip()) + if m: + result[m.group("id")] = m.group("version") + return result + return None + + +def discover_components( + env, component_prefix: str, whitelist: dict[str, str | None] +) -> list[dict]: + """Return every ``.. comp::`` need whose id (and, when a + ``[version==N]`` filter was declared, whose ``:version:``) matches + an entry in *whitelist*. + + Sourced from the filesystem scan cached on ``env`` at + ``env-before-read-docs``. Components are returned sorted by id for a + stable display order. Whitelist entries with no matching + ``.. comp::`` scan result are silently ignored (caller may want to + warn). + """ + result: list[dict] = [] + for entry in getattr(env, "module_verification_report_needs", []): + if entry.get("directive") != "comp": + continue + need_id = entry.get("id", "") + if need_id not in whitelist: + continue + required_version = whitelist[need_id] + if required_version is not None and entry.get("version") != required_version: + continue + slug = ( + need_id[len(component_prefix):] + if need_id.startswith(component_prefix) + else need_id + ) + result.append( + { + "id": need_id, + "slug": slug, + "title": entry.get("title") or need_id, + } + ) + result.sort(key=lambda c: c["id"]) + return result + + +def scan_source_tree(app, env, docnames): + """Cache a filesystem scan of all ``.. mod::`` and ``.. comp::`` + needs on ``env`` so the directive can enumerate its components in + every process of a parallel build. + """ + env.module_verification_report_needs = scan_rst_needs( + env.srcdir, directives={"mod", "comp"} + ) diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py new file mode 100644 index 000000000..210c3be91 --- /dev/null +++ b/src/extensions/score_module_verification_report/templates.py @@ -0,0 +1,336 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""RST templates for the module verification report.""" +from __future__ import annotations + + +COMPONENT_TEMPLATE = """ +.. _{ref}: + +{title} +{title_underline} + +.. raw:: html + + <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> + +Component Requirements Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {title} Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "comp_req" and "{comp_id}" in satisfied_by and status == "valid" + type == "comp_req" and "{comp_id}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: {title} Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "comp_req" and "{comp_id}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{comp_id}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{comp_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) + +Component Architecture Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {title} Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "valid" + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: {title} Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and "inspected" in tags + type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_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 "{comp_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 + +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 "{comp_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 (``\u2014``) 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} +""" + + +# Kept for later re-activation. To re-enable the Unit Test Coverage +# section, append this fragment to ``COMPONENT_TEMPLATE`` and restore +# the ``coverage_intro=coverage_intro(comp, coverage_data)`` kwarg in +# ``render_component``. +COMPONENT_COVERAGE_SECTION_DISABLED = """\ + +Unit Test Coverage +^^^^^^^^^^^^^^^^^^ + +{coverage_intro} +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Metric + - Coverage + * - Lines + - |coverage_{slug}_lines| + * - Functions + - |coverage_{slug}_functions| + * - Branches + - |coverage_{slug}_branches| +""" + + +FEATURE_TEMPLATE = """\ +Feature +------- + +.. needtable:: + :filter: id == "{feature_id}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +Feature Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "feat_req" and "{feature_id}" in satisfied_by and status == "valid" + type == "feat_req" and "{feature_id}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "feat_req" and "{feature_id}" in satisfied_by and ("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 + +Feature Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "valid" + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" in tags + type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" not in tags + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +Feature Inspection Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Presence of the feature-level inspection work products. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status +{feature_workproduct_rows} +""" + + +COMPONENTS_HEADER = """\ +Components +---------- + +""" + + +# Hide the auto-generated header / chrome of the inner ``.. needtable::`` +# widgets that render the "Realized by" / "Status" cells of the WP tables. +# Without this the cells show a nested table with its own "ID" / "Status" +# header row and datatables toolbar, which is visually noisy for a single +# value. Scoped to ``.wp-doc-table`` set as the outer list-table's class. +WP_TABLE_CSS = """\ +.. raw:: html + + <style> + .wp-doc-table td .needstable_wrapper, + .wp-doc-table td .pst-scrollable-table-container { + margin: 0; padding: 0; overflow: visible; + } + .wp-doc-table td table.NEEDS_TABLE, + .wp-doc-table td table.NEEDS_DATATABLES { + border: 0; margin: 0; box-shadow: none; background: transparent; + width: auto; + } + .wp-doc-table td table.NEEDS_TABLE thead, + .wp-doc-table td table.NEEDS_DATATABLES thead { display: none; } + .wp-doc-table td table.NEEDS_TABLE tbody tr, + .wp-doc-table td table.NEEDS_DATATABLES tbody tr { background: transparent; } + .wp-doc-table td table.NEEDS_TABLE tbody td, + .wp-doc-table td table.NEEDS_DATATABLES tbody td { + border: 0; padding: 0; background: transparent; + } + .wp-doc-table td .dataTables_wrapper .dataTables_length, + .wp-doc-table td .dataTables_wrapper .dataTables_filter, + .wp-doc-table td .dataTables_wrapper .dataTables_info, + .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } + </style> +""" + + +OVERVIEW_TEMPLATE = """\ +Component Overview +~~~~~~~~~~~~~~~~~~ + +.. needtable:: + :filter: id in {ids_literal} + :columns: id as "Component";safety;security;status + :style: table + :sort: id +""" + + +DEFAULT_WORKPRODUCTS = [ + {"key": "requirements_inspect", "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect"}, + {"key": "sw_arch_verification", "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification"}, + {"key": "sw_implementation_inspection", "label": "Implementation Inspection", + "wp_id": "wp__sw_implementation_inspection"}, + {"key": "sw_component_dfa", "label": "DFA", + "wp_id": "wp__sw_component_dfa"}, + {"key": "sw_component_fmea", "label": "FMEA", + "wp_id": "wp__sw_component_fmea"}, +] + + +DEFAULT_FEATURE_WORKPRODUCTS = [ + {"key": "requirements_inspect", "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect"}, + {"key": "sw_arch_verification", "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification"}, +] From 01faf8c828e2a139f719e20f44cb8838f9342032 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 19 Aug 2026 13:21:20 +0000 Subject: [PATCH 03/25] test(module-verification-report): add unit tests for the split modules 36 pytest cases exercising every branch of the pure layers: * test_scanner.py (15 tests): scan_rst_needs, module_includes, discover_components, scan_source_tree. Covers directive filtering, version-pinned includes, non-utf-8 files, deep walks, prefix mismatches, missing env attribute. * test_coverage.py (8 tests): load_coverage_summary (missing / invalid / null JSON, note_dependency), coverage_intro (measured vs. specification-only decision, trailing blank line). * test_rendering.py (13 tests): slug utilities, override vs. filter work-product rows, render_component / render_feature substitutions and end-to-end render_report assembly (with feature and component overrides). BUILD adds score_pytest(name = 'score_module_verification_report_tests'). Follows the existing score_mounts test pattern. --- .../tests/test_coverage.py | 106 +++++++++ .../tests/test_rendering.py | 184 +++++++++++++++ .../tests/test_scanner.py | 212 ++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 src/extensions/score_module_verification_report/tests/test_coverage.py create mode 100644 src/extensions/score_module_verification_report/tests/test_rendering.py create mode 100644 src/extensions/score_module_verification_report/tests/test_scanner.py diff --git a/src/extensions/score_module_verification_report/tests/test_coverage.py b/src/extensions/score_module_verification_report/tests/test_coverage.py new file mode 100644 index 000000000..92523ede9 --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_coverage.py @@ -0,0 +1,106 @@ +# ******************************************************************************* +# 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 tests for :mod:`score_module_verification_report.coverage`.""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from src.extensions.score_module_verification_report.coverage import ( + COVERAGE_INTRO_MEASURED, + COVERAGE_INTRO_SPEC_ONLY, + COVERAGE_SUMMARY_REL_PATH, + coverage_intro, + load_coverage_summary, +) + + +def _env(srcdir: Path) -> SimpleNamespace: + env = SimpleNamespace(srcdir=str(srcdir), _deps=[]) + env.note_dependency = env._deps.append # type: ignore[attr-defined] + return env + + +def _write_summary(srcdir: Path, payload: object) -> Path: + path = srcdir / COVERAGE_SUMMARY_REL_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# load_coverage_summary +# --------------------------------------------------------------------------- + + +def test_load_coverage_summary_reads_json_and_notes_dependency(tmp_path: Path) -> None: + payload = {"comp_a": {"lines_pct": 87.5}} + path = _write_summary(tmp_path, payload) + env = _env(tmp_path) + + data = load_coverage_summary(env) + + assert data == payload + assert env._deps == [str(path)] + + +def test_load_coverage_summary_missing_file_returns_empty(tmp_path: Path) -> None: + env = _env(tmp_path) + assert load_coverage_summary(env) == {} + assert env._deps == [] + + +def test_load_coverage_summary_invalid_json_returns_empty(tmp_path: Path) -> None: + path = tmp_path / COVERAGE_SUMMARY_REL_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + env = _env(tmp_path) + assert load_coverage_summary(env) == {} + + +def test_load_coverage_summary_null_content_returns_empty(tmp_path: Path) -> None: + _write_summary(tmp_path, None) + env = _env(tmp_path) + assert load_coverage_summary(env) == {} + + +# --------------------------------------------------------------------------- +# coverage_intro +# --------------------------------------------------------------------------- + + +def test_coverage_intro_measured_when_pct_present() -> None: + comp = {"slug": "kvs"} + data = {"kvs": {"lines_pct": 90.0, "functions_pct": None, "branches_pct": None}} + assert coverage_intro(comp, data).startswith(COVERAGE_INTRO_MEASURED[:32]) + + +def test_coverage_intro_spec_only_when_slug_missing() -> None: + assert coverage_intro({"slug": "kvs"}, {}).startswith( + COVERAGE_INTRO_SPEC_ONLY[:32] + ) + + +def test_coverage_intro_spec_only_when_all_metrics_none() -> None: + data = { + "kvs": {"lines_pct": None, "functions_pct": None, "branches_pct": None}, + } + assert coverage_intro({"slug": "kvs"}, data).startswith( + COVERAGE_INTRO_SPEC_ONLY[:32] + ) + + +def test_coverage_intro_terminates_with_blank_line() -> None: + result = coverage_intro({"slug": "kvs"}, {}) + assert result.endswith("\n\n") diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py new file mode 100644 index 000000000..4f6ffbb40 --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -0,0 +1,184 @@ +# ******************************************************************************* +# 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 tests for :mod:`score_module_verification_report.rendering`.""" +from __future__ import annotations + +from src.extensions.score_module_verification_report.rendering import ( + normalize_slug, + render_component, + render_feature, + render_overview, + render_report, + slugify, + workproduct_rows, +) + + +_WP = [ + {"key": "req", "label": "Requirements Inspection", "wp_id": "wp__req"}, + {"key": "arc", "label": "Architecture Inspection", "wp_id": "wp__arc"}, +] + + +# --------------------------------------------------------------------------- +# slug utilities +# --------------------------------------------------------------------------- + + +def test_normalize_slug_strips_underscores_and_lowercases() -> None: + assert normalize_slug("Bit_Manipulation") == "bitmanipulation" + + +def test_slugify_converts_non_alnum_to_dashes() -> None: + assert slugify("Foo Bar / Baz!") == "foo-bar-baz" + + +def test_slugify_strips_leading_trailing_dashes() -> None: + assert slugify("---weird---") == "weird" + + +# --------------------------------------------------------------------------- +# workproduct_rows +# --------------------------------------------------------------------------- + + +def test_workproduct_rows_uses_needtable_by_default() -> None: + out = workproduct_rows("kvs", overrides={}, workproducts=_WP) + assert ":need:`wp__req`" in out + assert "Requirements Inspection" in out + # Both id and status cells rendered as needtables with matching filter. + assert out.count(".. needtable::") == 4 + # Slug substring match (id.replace("_", "")) is emitted verbatim. + assert '"kvs" in id.replace("_", "")' in out + assert '"wp__req" in realizes' in out + + +def test_workproduct_rows_override_uses_direct_need_and_ndf_copy() -> None: + overrides = {"workproducts": {"req": "doc__custom_req"}} + out = workproduct_rows("kvs", overrides, _WP) + # Overridden row: no needtable, direct :need: + :ndf: copy on status. + assert ":need:`doc__custom_req`" in out + assert "copy('status', need_id='doc__custom_req')" in out + # Non-overridden row (arc) still uses needtable. + assert '"wp__arc" in realizes' in out + + +def test_workproduct_rows_no_rows_when_workproducts_empty() -> None: + assert workproduct_rows("kvs", {}, []) == "" + + +# --------------------------------------------------------------------------- +# render_overview +# --------------------------------------------------------------------------- + + +def test_render_overview_builds_id_list_literal() -> None: + components = [{"id": "comp__a"}, {"id": "comp__b"}] + out = render_overview(components) + assert 'id in ["comp__a", "comp__b"]' in out + assert ".. needtable::" in out + + +def test_render_overview_empty_components() -> None: + assert 'id in []' in render_overview([]) + + +# --------------------------------------------------------------------------- +# render_component / render_feature +# --------------------------------------------------------------------------- + + +def test_render_component_contains_component_specific_filters() -> None: + comp = {"id": "comp__demo_kvs", "slug": "kvs", "title": "Key-Value Store"} + out = render_component(comp, overrides={}, workproducts=_WP, coverage_data={}) + # Title underline (~ * len(title)). + assert "~" * len("Key-Value Store") in out + # comp_id substituted into all filter expressions. + assert '"comp__demo_kvs" in satisfied_by' in out + assert '"comp__demo_kvs" in belongs_to' in out + # Anchor uses slugified title (spaces / punctuation collapsed). + assert ".. _comp-key-value-store:" in out + # Workproduct table headers present. + assert "Verification & Safety Analysis Documents" in out + + +def test_render_feature_substitutes_feature_id_and_slug() -> None: + out = render_feature( + feature_id="feat__demo", + feature_slug="demo", + feature_overrides={}, + feature_workproducts=_WP, + ) + assert 'id == "feat__demo"' in out + assert '"feat__demo" in satisfied_by' in out + assert '"feat__demo" in belongs_to' in out + assert "Feature Inspection Statistics" in out + + +# --------------------------------------------------------------------------- +# render_report +# --------------------------------------------------------------------------- + + +def test_render_report_assembles_all_sections() -> None: + components = [ + {"id": "comp__demo_a", "slug": "a", "title": "A"}, + {"id": "comp__demo_b", "slug": "b", "title": "B"}, + ] + out = render_report( + components=components, + feature_id="feat__demo", + feature_slug="demo", + overrides_by_id={}, + workproducts=_WP, + feature_workproducts=_WP, + coverage_data={}, + ) + # CSS block for wp-doc-table styling. + assert ".wp-doc-table" in out + # Feature, Components header, overview needtable, per-component sections. + assert 'id == "feat__demo"' in out + assert "Components\n----------" in out + assert 'id in ["comp__demo_a", "comp__demo_b"]' in out + assert '"comp__demo_a" in satisfied_by' in out + assert '"comp__demo_b" in satisfied_by' in out + + +def test_render_report_applies_feature_overrides() -> None: + out = render_report( + components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], + feature_id="feat__demo", + feature_slug="demo", + overrides_by_id={ + "feat__demo": {"workproducts": {"req": "doc__feat_req"}}, + }, + workproducts=_WP, + feature_workproducts=_WP, + coverage_data={}, + ) + assert ":need:`doc__feat_req`" in out + + +def test_render_report_applies_component_overrides() -> None: + out = render_report( + components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], + feature_id="feat__demo", + feature_slug="demo", + overrides_by_id={ + "comp__demo_a": {"workproducts": {"req": "doc__comp_a_req"}}, + }, + workproducts=_WP, + feature_workproducts=_WP, + coverage_data={}, + ) + assert ":need:`doc__comp_a_req`" in out diff --git a/src/extensions/score_module_verification_report/tests/test_scanner.py b/src/extensions/score_module_verification_report/tests/test_scanner.py new file mode 100644 index 000000000..f5afde7ac --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_scanner.py @@ -0,0 +1,212 @@ +# ******************************************************************************* +# 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 tests for :mod:`score_module_verification_report.scanner`.""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from src.extensions.score_module_verification_report.scanner import ( + discover_components, + module_includes, + scan_rst_needs, + scan_source_tree, +) + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# scan_rst_needs +# --------------------------------------------------------------------------- + + +def test_scan_rst_needs_captures_mod_and_comp(tmp_path: Path) -> None: + _write( + tmp_path / "a.rst", + ".. mod:: My Module\n" + " :id: mod__demo\n" + " :includes: comp__demo_x, comp__demo_y[version==2]\n" + "\n" + ".. comp:: X\n" + " :id: comp__demo_x\n" + " :safety: ASIL_B\n" + "\n" + ".. comp:: Y\n" + " :id: comp__demo_y\n" + " :version: 2\n", + ) + needs = scan_rst_needs(str(tmp_path), directives={"mod", "comp"}) + assert len(needs) == 3 + mod = next(n for n in needs if n["directive"] == "mod") + assert mod["id"] == "mod__demo" + assert "comp__demo_x" in mod["includes"] + comp_y = next(n for n in needs if n["id"] == "comp__demo_y") + assert comp_y["version"] == "2" + assert comp_y["title"] == "Y" + + +def test_scan_rst_needs_ignores_other_directives(tmp_path: Path) -> None: + _write( + tmp_path / "a.rst", + ".. comp:: Kept\n" + " :id: comp__kept\n" + "\n" + ".. document:: Skipped\n" + " :id: doc__skipped\n", + ) + needs = scan_rst_needs(str(tmp_path), directives={"mod", "comp"}) + assert [n["id"] for n in needs] == ["comp__kept"] + + +def test_scan_rst_needs_skips_entry_without_id(tmp_path: Path) -> None: + _write( + tmp_path / "a.rst", + ".. comp:: Anonymous\n" + " :safety: QM\n", + ) + assert scan_rst_needs(str(tmp_path), directives={"comp"}) == [] + + +def test_scan_rst_needs_skips_non_rst_and_bad_encoding(tmp_path: Path) -> None: + _write(tmp_path / "a.md", ".. comp:: not rst\n :id: comp__x\n") + (tmp_path / "b.rst").write_bytes(b"\xff\xfe not utf-8") + assert scan_rst_needs(str(tmp_path), directives={"comp"}) == [] + + +def test_scan_rst_needs_walks_recursively(tmp_path: Path) -> None: + _write(tmp_path / "a.rst", ".. comp:: A\n :id: comp__a\n") + _write(tmp_path / "sub" / "b.rst", ".. comp:: B\n :id: comp__b\n") + ids = {n["id"] for n in scan_rst_needs(str(tmp_path), directives={"comp"})} + assert ids == {"comp__a", "comp__b"} + + +# --------------------------------------------------------------------------- +# module_includes +# --------------------------------------------------------------------------- + + +def test_module_includes_parses_versions() -> None: + needs = [ + { + "directive": "mod", + "id": "mod__demo", + "includes": "comp__a, comp__b[version==3]", + }, + ] + result = module_includes(needs, "mod__demo") + assert result == {"comp__a": None, "comp__b": "3"} + + +def test_module_includes_returns_none_when_mod_not_found() -> None: + assert module_includes([], "mod__missing") is None + + +def test_module_includes_ignores_other_mod_needs() -> None: + needs = [ + {"directive": "mod", "id": "mod__other", "includes": "comp__x"}, + {"directive": "comp", "id": "mod__demo"}, + ] + assert module_includes(needs, "mod__demo") is None + + +def test_module_includes_empty_includes() -> None: + needs = [{"directive": "mod", "id": "mod__demo", "includes": ""}] + assert module_includes(needs, "mod__demo") == {} + + +# --------------------------------------------------------------------------- +# discover_components +# --------------------------------------------------------------------------- + + +def _env(needs: list[dict]) -> SimpleNamespace: + return SimpleNamespace(module_verification_report_needs=needs) + + +def test_discover_components_filters_by_whitelist_and_version() -> None: + env = _env( + [ + {"directive": "comp", "id": "comp__demo_a", "title": "A"}, + {"directive": "comp", "id": "comp__demo_b", "title": "B", + "version": "1"}, + {"directive": "comp", "id": "comp__demo_c", "title": "C"}, + ] + ) + result = discover_components( + env, + component_prefix="comp__demo_", + whitelist={"comp__demo_a": None, "comp__demo_b": "2"}, + ) + assert [c["id"] for c in result] == ["comp__demo_a"] + assert result[0]["slug"] == "a" + assert result[0]["title"] == "A" + + +def test_discover_components_sorted_by_id() -> None: + env = _env( + [ + {"directive": "comp", "id": "comp__demo_b", "title": "B"}, + {"directive": "comp", "id": "comp__demo_a", "title": "A"}, + ] + ) + result = discover_components( + env, + component_prefix="comp__demo_", + whitelist={"comp__demo_a": None, "comp__demo_b": None}, + ) + assert [c["id"] for c in result] == ["comp__demo_a", "comp__demo_b"] + + +def test_discover_components_prefix_mismatch_keeps_full_id_as_slug() -> None: + env = _env( + [{"directive": "comp", "id": "custom__x", "title": "X"}], + ) + result = discover_components( + env, + component_prefix="comp__demo_", + whitelist={"custom__x": None}, + ) + assert result[0]["slug"] == "custom__x" + + +def test_discover_components_falls_back_to_id_when_title_missing() -> None: + env = _env([{"directive": "comp", "id": "comp__demo_a", "title": ""}]) + result = discover_components( + env, + component_prefix="comp__demo_", + whitelist={"comp__demo_a": None}, + ) + assert result[0]["title"] == "comp__demo_a" + + +def test_discover_components_missing_attr_on_env() -> None: + # Env may not have the attr yet if the read-hook did not run. + assert discover_components(SimpleNamespace(), "comp__x_", {"a": None}) == [] + + +# --------------------------------------------------------------------------- +# scan_source_tree +# --------------------------------------------------------------------------- + + +def test_scan_source_tree_populates_env(tmp_path: Path) -> None: + _write(tmp_path / "a.rst", ".. mod:: M\n :id: mod__demo\n") + env = SimpleNamespace(srcdir=str(tmp_path)) + scan_source_tree(app=None, env=env, docnames=None) + assert [n["id"] for n in env.module_verification_report_needs] == [ + "mod__demo" + ] From ab711cd03131e5a0a3b928efb7a24a1e6615d45a Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 19 Aug 2026 13:21:32 +0000 Subject: [PATCH 04/25] docs(module-verification-report): add extension reference page New src/extensions/docs/module_verification_report.rst documents: * the .. module-verification-report:: directive (arguments, options, fatal errors, warnings); * the YAML config schema, with defaults derived from module_id; * the sphinx-needs data model the extension reads or filters by (.. mod:: / .. comp:: / .. feat:: / .. wp:: / .. document:: / comp_req / comp_arc_* / feat_req / feat_arc_*); * what the read-hook actually scans and why (regex over srcdir, not SphinxNeedsData, to keep parallel_read_safe = True); * the report structure (CSS, feature section, component overview, per component sections); * work-product row rendering (override vs. sphinx-needs filter path, including the underscore-free slug normalisation); * the optional coverage_summary.json integration and its currently disabled section; * the 5-module architecture and public surface; * known limitations (feature-only repos, line-scan boundary conditions, nested DataTables cost); * how to run the unit tests. The extensions landing page (index.rst) gains a grid card and a toctree entry pointing to the new page. --- src/extensions/docs/index.rst | 9 + .../docs/module_verification_report.rst | 447 ++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 src/extensions/docs/module_verification_report.rst diff --git a/src/extensions/docs/index.rst b/src/extensions/docs/index.rst index 0ae5047f4..81134129e 100644 --- a/src/extensions/docs/index.rst +++ b/src/extensions/docs/index.rst @@ -70,6 +70,14 @@ Extensions Architecture and design of the ``score_mounts`` bridge extension. :ref:`Mounts Extension Internals<score_mounts_internals>` + .. grid-item-card:: + + Module Verification Report + ^^^ + The ``.. module-verification-report::`` directive that expands + into the standard per-module verification report body. + :ref:`Module Verification Report<module_verification_report>` + .. toctree:: :maxdepth: 1 @@ -81,3 +89,4 @@ Extensions Extension Guide <extension_guide> Sync TOML <sync_toml> mounts_internals + module_verification_report diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst new file mode 100644 index 000000000..14b59c594 --- /dev/null +++ b/src/extensions/docs/module_verification_report.rst @@ -0,0 +1,447 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +.. _module_verification_report: + +Module Verification Report extension +==================================== + +``score_module_verification_report`` provides a single Sphinx directive, +``.. module-verification-report::``, that expands into the standard +per-module verification report body: one feature-level section, one +component overview table, and one detailed section per component. The +extension does **not** re-implement any traceability logic — every +attribute, link and status shown in the report is resolved by +sphinx-needs at render time from ``.. needtable::`` / ``.. needpie::`` +widgets that the directive emits. + +Typical use is in a module's ``verification_report/module_verification_report.rst``: + +.. code-block:: rst + + Auto-generated Report + --------------------- + + .. module-verification-report:: + :config: verification_report/module_report.yaml + +The extension is shipped as part of the +:ref:`score_sphinx_bundle<extensions>`; consumers only need to add +``score_docs_as_code`` and reference the directive. + +At a glance +----------- + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Aspect + - Contract + + * - Directive + - ``.. module-verification-report::`` — no arguments, no content; + one optional ``:config:`` option. + + * - Config option + - ``:config: <path>`` — YAML file resolved relative to Sphinx's + ``srcdir`` (i.e. the directory containing ``conf.py``). + + * - Reads from the source tree + - Every ``.. mod::`` and ``.. comp::`` need, discovered by a + shallow regex scan at ``env-before-read-docs`` time. + + * - Reads from JSON + - ``<srcdir>/reporting/coverage_summary.json`` (optional; produced + by ``tools/extract_coverage.py``). + + * - Delegates to sphinx-needs + - Status, safety, security, requirement / architecture element + tables, pie charts, "Realized by" cells. + + * - Parallel-read safe + - Yes (``parallel_read_safe = True``). The filesystem scan is + reproducible in every worker. + +.. _mvr_directive: + +Directive reference +------------------- + +.. code-block:: rst + + .. module-verification-report:: + :config: <path/to/module_report.yaml> + +**Arguments** + None. + +**Content** + None (``has_content = False``). + +**Options** + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Option + - Meaning + + * - ``:config:`` + - Optional path to a YAML config file, resolved relative to + ``srcdir``. When omitted, all defaults apply and ``module_id`` is + treated as the empty string — which will fail the ``.. mod::`` + lookup below (i.e. the option is effectively required). + +**Errors** (fatal — the directive returns an ``error`` node): + +* ``no '.. mod::' need with id '<module_id>' found in the source tree`` + — the config's ``module_id`` does not match any ``.. mod::`` directive + visible under ``srcdir``. +* ``'<module_id>' has no resolvable components in ':includes:'`` — the + ``.. mod::`` need was found but no whitelisted component id matched a + ``.. comp::`` directive. + +**Warnings** (non-fatal): + +* ``'<module_id>' includes '<id>' but no matching '.. comp::' need was + found`` — one entry of ``:includes:`` is dangling; the report still + renders for the remaining components. +* ``config not found: <abs path>`` — the ``:config:`` path does not + exist; the report falls back to defaults and will almost certainly + fail the ``mod`` lookup. + +.. _mvr_config: + +Config file schema +------------------ + +The YAML file drives every module-specific choice. Only ``module_id`` is +strictly required in practice; everything else has a sensible default. + +.. code-block:: yaml + + # Mandatory. Must match the ``:id:`` of a ``.. mod::`` need under + # ``srcdir``. Example: ``mod__baselibs``. + module_id: mod__<module> + + # Optional. Prefix stripped from each component id to produce its + # short "slug" (used for anchors and coverage lookup). Default: + # ``comp__<module>_`` — derived from ``module_id`` by stripping the + # ``mod__`` prefix. + component_prefix: comp__<module>_ + + # Optional. The feature ``.. feat::`` need whose summary and + # statistics form the "Feature" section. Default: + # ``feat__<module>`` — same rule as component_prefix. + feature_id: feat__<module> + + # Optional. The per-component work products checked in the + # "Verification & Safety Analysis Documents" table. + # Default: the five standard SCORE items — Requirements Inspection, + # Architecture Inspection, Implementation Inspection, DFA, FMEA. + workproducts: + - key: <stable_short_key> # only used for override lookup + label: <human-readable label> # shown in the "Kind" column + wp_id: wp__<work_product> # sphinx-needs id + + # Optional. Feature-level work products. Default: Requirements + # Inspection + Architecture Inspection only. + feature_workproducts: + - key: ... + label: ... + wp_id: ... + + # Optional. Per-need overrides for the "Realized by" / "Status" + # cells. Keyed by the ``.. comp::`` need id, or by the ``feat__`` + # need id for feature-level overrides. + overrides: + comp__<module>_<slug>: + workproducts: + # For each row you want to pin explicitly, map the WP ``key`` + # (see above) to the concrete document need id. + requirements_inspect: doc__<something>_req_inspection + sw_arch_verification: doc__<something>_arc_inspection + feat__<module>: + workproducts: + requirements_inspect: doc__<feature>_req_inspection + +.. _mvr_data_model: + +Data model expectations +----------------------- + +The extension looks up its subject through a chain of sphinx-needs +directives. Every id follows the ``<kind>__<slug>`` convention and every +link uses the field the sphinx-needs data model already defines. + +.. list-table:: Needs the extension reads or filters by + :header-rows: 1 + :widths: 15 20 65 + + * - Directive + - Where it lives + - How the extension uses it + + * - ``.. mod::`` + - ``docs/module/index.rst`` (or wherever the module chooses) + - Filesystem scan; ``:id:`` must equal ``module_id`` from the + config. ``:includes:`` is a comma-separated list of the form + ``comp__<slug>`` or ``comp__<slug>[version==<N>]`` — the + whitelist of components rendered by the report. + + * - ``.. comp::`` + - Under any component's ``docs/`` folder + - Filesystem scan; must appear in ``:includes:``. Its ``:id:`` + drives all component-scoped ``.. needtable::`` / + ``.. needpie::`` filters (``"<comp_id>" in satisfied_by``, ``"<comp_id>" in belongs_to``). + If ``[version==N]`` was requested in ``:includes:``, the + ``.. comp::``'s ``:version:`` must match. + + * - ``.. feat::`` + - The module's feature documentation + - Not scanned; resolved at render time by sphinx-needs. The + "Feature" summary uses ``id == "<feature_id>"``; feature + statistics filter ``feat_req`` / ``feat_arc_*`` by + ``"<feature_id>" in belongs_to``. + + * - ``.. wp::`` + - Process repo (external) + - Never scanned; the ``:need:\`wp__...\``` links assume the + ``wp__*`` needs are registered on the external needs source. + + * - ``.. document::`` + - Anywhere; typically the ``verification_report/`` folders + - Not scanned; resolved by sphinx-needs. Each "Realized by" cell + is a ``.. needtable::`` filtered by + ``type == "document" and "<slug_norm>" in id.replace("_", "") + and "<wp_id>" in realizes``. + + * - ``.. comp_req::``, ``.. comp_arc_sta::``, ``.. comp_arc_dyn::`` + - Under the component + - Not scanned; sphinx-needs handles the "Requirements Statistics" + and "Architecture Statistics" pies and tables via filters on + ``satisfied_by`` / ``belongs_to``. + + * - ``.. feat_req::``, ``.. feat_arc_sta::``, ``.. feat_arc_dyn::`` + - Under the feature + - Same as above at the feature level. + +.. _mvr_scan: + +Filesystem scan (what the extension actually reads) +--------------------------------------------------- + +At ``env-before-read-docs`` the extension performs one recursive +``os.walk`` of ``env.srcdir`` and stores the result on +``env.module_verification_report_needs`` (see +:mod:`.scanner`). Only ``.rst`` files are considered; unreadable files +are silently skipped. + +The scan captures every directive whose header matches +``^\.\. (mod|comp):: <title>`` and reads its ``:id:``, +``:includes:`` (mod only) and ``:version:`` (comp only) option lines. +**All other option lines are dropped** — safety, security, status, +tags, satisfies, etc. are resolved by sphinx-needs at render time from +the same source RST, so the scanner does not need to interpret them. + +Rationale: querying ``SphinxNeedsData`` at directive-run time would +force ``parallel_read_safe = False`` and produce a "doing serial read" +warning that is fatal under ``-W``. A shallow regex scan is cheap, +reproducible in every worker of a parallel build, and only needs to +enumerate ids and titles. + +.. _mvr_output: + +What the report renders +----------------------- + +Given a valid config the directive emits, in order: + +1. **CSS block** — inline ``<style>`` scoped to the ``wp-doc-table`` + class, hiding the internal chrome (headers, datatables toolbar) of + the nested ``.. needtable::`` widgets used in the WP tables. + +2. **Feature** section — ``.. needtable::`` filtered by + ``id == "<feature_id>"``; then Requirements Statistics + (``needpie`` for status + verification coverage, plus a + ``needtable`` in a dropdown), Architecture Statistics (status + + inspection pie, plus a ``needtable``), and Inspection Statistics (a + work-product presence table). + +3. **Components** section — an ``H2`` heading followed by: + + a. **Component Overview** — ``.. needtable::`` filtered by + ``id in [ ... ]`` over the whitelisted component ids, showing + safety / security / status columns. + + b. One **per-component section** with, in order: + + * Requirements Statistics (pies + traceability table); + * Architecture Statistics (pies + inspection table); + * a **Verification & Safety Analysis Documents** work-product + table. + +The Unit Test Coverage section (`_COMPONENT_COVERAGE_SECTION_DISABLED` +in :mod:`.templates`) is currently disabled at the template level. +Re-enable by appending the fragment to ``COMPONENT_TEMPLATE`` and +passing ``coverage_intro=coverage_intro(comp, coverage_data)`` from +:func:`.rendering.render_component`. + +.. _mvr_wp_rows: + +Work-product row rendering +-------------------------- + +Each row of a "Verification & Safety Analysis Documents" (or feature +"Inspection Statistics") table has four columns: **Work Product**, +**Kind**, **Realized by**, **Status**. The first two are plain text +(``:need:`` link and the WP label from the config). The last two are +resolved either by an explicit override or by sphinx-needs: + +* **Override** — when the config sets + ``overrides[<need_id>].workproducts[<wp_key>] = <doc_id>``, the + row renders + + .. code-block:: rst + + - :need:`<doc_id>` + - :ndf:`copy('status', need_id='<doc_id>')` + +* **Filter** (default) — both cells emit an identical inner + ``.. needtable::`` with the filter + + .. code-block:: text + + type == "document" + and "<slug_norm>" in id.replace("_", "") + and "<wp_id>" in realizes + + where ``<slug_norm>`` is the component (or feature) slug with all + underscores removed and lower-cased (see + :func:`.rendering.normalize_slug`). This makes ``bit_manipulation`` + and ``bitmanipulation`` compare equal without per-component config. + +If neither an override nor a matching document is present, the cells +are empty. + +.. _mvr_coverage: + +Coverage summary (optional) +--------------------------- + +The extension reads ``<srcdir>/reporting/coverage_summary.json`` — the +output of ``tools/extract_coverage.py`` — via +:func:`.coverage.load_coverage_summary`. Its top-level keys are +component slugs; each value has at least the fields ``lines_pct``, +``functions_pct``, ``branches_pct``. + +The JSON is only used by the currently disabled Unit Test Coverage +section (see above). When that section is re-enabled, +:func:`.coverage.coverage_intro` decides between the "measured" and +the "specification-only" intro paragraph based on whether the component +slug has at least one non-null ``*_pct`` field. + +Missing / malformed JSON is not an error: the loader returns ``{}``. + +.. _mvr_architecture: + +Extension architecture +---------------------- + +Implementation is split across five modules under +``src/extensions/score_module_verification_report/`` so that each layer +can be tested independently: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Module + - Responsibility + + * - :mod:`.scanner` + - Filesystem regex scan for ``.. mod::`` / ``.. comp::``, + ``:includes:`` parsing, component whitelisting. + Registers ``scan_source_tree`` on ``env-before-read-docs``. + + * - :mod:`.coverage` + - ``coverage_summary.json`` loading and intro-paragraph selection. + + * - :mod:`.templates` + - All RST template strings, the scoped WP-table CSS, and the + default (component / feature) workproduct lists. + + * - :mod:`.rendering` + - Pure functions that expand the templates: + ``render_component``, ``render_feature``, ``render_overview``, + ``render_report``, and the shared ``workproduct_rows`` helper. + + * - :mod:`.directive` + - The ``ModuleVerificationReportDirective`` class. Loads the + YAML config, calls the scanner / renderer, and + ``nested_parse_with_titles`` the resulting RST into the + document. + + * - ``__init__.py`` + - Thin entry point exposing ``setup(app)``. + +The public surface is intentionally minimal: + +* the directive ``module-verification-report`` (added in ``setup``); +* the event handler + ``scanner.scan_source_tree`` (connected to ``env-before-read-docs`` + in ``setup``); +* the cached attribute ``env.module_verification_report_needs``. + +All other functions are considered internal and covered by unit tests +under ``tests/``. + +.. _mvr_limitations: + +Known limitations +----------------- + +* **Feature-only modules are not supported** — the extension hard-fails + if it cannot find a ``.. mod::`` need with matching ``:id:`` and at + least one resolvable component in its ``:includes:``. Repos like + Lifecycle (feature-only, no ``comp``) currently need a small patch to + the directive. +* **The scan is line-oriented** — a ``.. mod::`` / ``.. comp::`` + directive split across a line-continuation, or preceded by uncommon + indentation, may be missed. All in-tree consumers use the canonical + form documented above. +* **Nested tables render inside cells** — the ``.. needtable::`` widgets + used in the WP rows are hidden by the scoped CSS block, but their + DataTables initialisation still runs. Very large modules may see a + measurable per-cell cost. + +Testing +------- + +Unit tests live under +``src/extensions/score_module_verification_report/tests/`` and are +grouped by module: + +* ``test_scanner.py`` — every branch of ``scan_rst_needs``, + ``module_includes``, ``discover_components``. +* ``test_coverage.py`` — JSON loading edge cases and the + measured / spec-only intro decision. +* ``test_rendering.py`` — slug utilities, override vs. filter row + rendering, and end-to-end ``render_report`` assembly. + +Run them with the standard target:: + + bazel test //src/extensions/score_module_verification_report:score_module_verification_report_tests From 1d968f5e3ef66dc0075867e449691829cdf28f16 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 19 Aug 2026 14:03:19 +0000 Subject: [PATCH 05/25] feat(module-verification-report): annotate testcase back-links with result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the doctree-resolved handler that previously lived in each consumer's docs/conf.py (baselibs, etc.) into the shared extension as a new module 'testcase_annotations'. It walks doctree references whose visible text starts with 'testcase__', looks up the corresponding sphinx-needs entry via SphinxNeedsData.get_needs_view() and appends a coloured '(passed)' / '(failed)' / '(skipped)' / '(disabled)' badge in the same palette as the report pie charts. To keep the hook scoped to pages that actually render the directive (and to avoid affecting unrelated docs), the directive registers its docname in env.module_verification_report_docnames. The lifecycle is kept parallel-read-safe via three matching handlers: - env-before-read-docs -> init_docnames (create the set) - env-purge-doc -> purge_docname (drop stale entries) - env-merge-info -> merge_docnames (union worker sets) Also: - 16 unit tests covering every branch (colours, fallback, no-op paths, lifecycle handlers) — no dependency on sphinx-needs being installed. - Extension reference page updated: architecture table gains the new module, public surface list mentions the new env attribute, testing section mentions the new test file. - Version bumped 0.6 -> 0.7. --- .../docs/module_verification_report.rst | 24 +- .../__init__.py | 14 +- .../directive.py | 8 + .../testcase_annotations.py | 109 +++++++++ .../tests/test_testcase_annotations.py | 207 ++++++++++++++++++ 5 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 src/extensions/score_module_verification_report/testcase_annotations.py create mode 100644 src/extensions/score_module_verification_report/tests/test_testcase_annotations.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 14b59c594..71dec0612 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -361,7 +361,7 @@ Missing / malformed JSON is not an error: the loader returns ``{}``. Extension architecture ---------------------- -Implementation is split across five modules under +Implementation is split across six modules under ``src/extensions/score_module_verification_report/`` so that each layer can be tested independently: @@ -393,7 +393,20 @@ can be tested independently: - The ``ModuleVerificationReportDirective`` class. Loads the YAML config, calls the scanner / renderer, and ``nested_parse_with_titles`` the resulting RST into the - document. + document. Registers its docname in + ``env.module_verification_report_docnames`` so the annotation + hook knows which pages to touch. + + * - :mod:`.testcase_annotations` + - ``doctree-resolved`` handler that appends a coloured + ``(passed)`` / ``(failed)`` / ``(skipped)`` / ``(disabled)`` + badge to every ``testcase__…`` back-link on pages that rendered + the directive. Sources the status from each testcase need's + ``result`` field via ``sphinx_needs.data.SphinxNeedsData``. The + hook is a no-op on pages the directive did not touch, when + sphinx-needs is not initialised, or when a testcase need has an + empty ``result``. Colour palette matches the pie-chart palette + used by the report body. * - ``__init__.py`` - Thin entry point exposing ``setup(app)``. @@ -404,7 +417,8 @@ The public surface is intentionally minimal: * the event handler ``scanner.scan_source_tree`` (connected to ``env-before-read-docs`` in ``setup``); -* the cached attribute ``env.module_verification_report_needs``. +* the cached attributes ``env.module_verification_report_needs`` and + ``env.module_verification_report_docnames``. All other functions are considered internal and covered by unit tests under ``tests/``. @@ -441,6 +455,10 @@ grouped by module: measured / spec-only intro decision. * ``test_rendering.py`` — slug utilities, override vs. filter row rendering, and end-to-end ``render_report`` assembly. +* ``test_testcase_annotations.py`` — the ``env-before-read-docs`` / + ``env-purge-doc`` / ``env-merge-info`` lifecycle handlers plus every + branch of ``annotate_testcase_results`` (colours, unknown result, + every no-op guard). Run them with the standard target:: diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 0557c7685..285559619 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -44,6 +44,8 @@ * :mod:`.templates` — RST templates + default workproduct lists + CSS * :mod:`.rendering` — template expansion / report body assembly * :mod:`.directive` — the ``ModuleVerificationReportDirective`` class +* :mod:`.testcase_annotations` — ``doctree-resolved`` badge decoration + for ``testcase__…`` back-links on pages that render the directive """ from __future__ import annotations @@ -51,6 +53,12 @@ from .directive import ModuleVerificationReportDirective from .scanner import scan_source_tree +from .testcase_annotations import ( + annotate_testcase_results, + init_docnames, + merge_docnames, + purge_docname, +) def setup(app: Any) -> dict: @@ -58,8 +66,12 @@ def setup(app: Any) -> dict: "module-verification-report", ModuleVerificationReportDirective ) app.connect("env-before-read-docs", scan_source_tree) + app.connect("env-before-read-docs", init_docnames) + app.connect("env-purge-doc", purge_docname) + app.connect("env-merge-info", merge_docnames) + app.connect("doctree-resolved", annotate_testcase_results) return { - "version": "0.6", + "version": "0.7", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 424d2e2d6..ecfd6107f 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -137,4 +137,12 @@ def run(self) -> list[nodes.Node]: container = nodes.container() container.document = self.state.document nested_parse_with_titles(self.state, view_list, container) + + # Register this docname so the ``doctree-resolved`` hook in + # ``testcase_annotations`` knows to decorate testcase back-links + # with a coloured ``(passed)`` / ``(failed)`` badge here. + if not hasattr(self.env, "module_verification_report_docnames"): + self.env.module_verification_report_docnames = set() + self.env.module_verification_report_docnames.add(self.env.docname) + return container.children diff --git a/src/extensions/score_module_verification_report/testcase_annotations.py b/src/extensions/score_module_verification_report/testcase_annotations.py new file mode 100644 index 000000000..b3b337ea7 --- /dev/null +++ b/src/extensions/score_module_verification_report/testcase_annotations.py @@ -0,0 +1,109 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Post-processing hook that decorates ``testcase__…`` back-links inside a +rendered module verification report with a coloured +``(passed)`` / ``(failed)`` badge derived from each testcase need's +``result`` field. + +The hook is a no-op unless the directive actually ran on the current +document — the directive registers its ``docname`` in +``env.module_verification_report_docnames`` so unrelated pages are left +untouched. +""" +from __future__ import annotations + +from typing import Any + +from docutils import nodes + +# Colours match the pie-chart palette used by the report body. +RESULT_COLORS = { + "passed": "#37a12d", + "failed": "#ca2828", + "skipped": "#f0a500", + "disabled": "#888888", +} +_FALLBACK_COLOR = "#666666" + + +def _needs_view(env: Any): + """Return the sphinx-needs ``NeedsView`` for ``env`` or ``None`` if + sphinx-needs is not available / not initialised yet.""" + try: + from sphinx_needs.data import SphinxNeedsData + except ImportError: + return None + try: + return SphinxNeedsData(env).get_needs_view() + except Exception: + return None + + +def annotate_testcase_results(app, doctree, docname): + """``doctree-resolved`` handler: append a coloured ``(<result>)`` span + to every reference whose visible text starts with ``testcase__`` on + pages where the module-verification-report directive was rendered.""" + docnames = getattr(app.env, "module_verification_report_docnames", None) + if not docnames or docname not in docnames: + return + + needs = _needs_view(app.env) + if needs is None: + return + + for ref in list(doctree.findall(nodes.reference)): + if not ref.children: + continue + first = ref.children[0] + if not isinstance(first, nodes.Text): + continue + text = first.astext() + if not text.startswith("testcase__"): + continue + need = needs.get(text) + if not need: + continue + result = need.get("result") or "" + if not result: + continue + color = RESULT_COLORS.get(result, _FALLBACK_COLOR) + status_html = ( + f'<span style="color:{color};font-weight:bold">' + f" ({result})</span>" + ) + # Keep the id text, append the coloured status inline. + ref.replace(first, nodes.Text(text)) + ref.append(nodes.raw("", status_html, format="html")) + + +def init_docnames(app, env, docnames): + """``env-before-read-docs`` handler: make sure the tracking set + exists on the shared env before parallel workers fork off.""" + if not hasattr(env, "module_verification_report_docnames"): + env.module_verification_report_docnames = set() + + +def purge_docname(app, env, docname): + """``env-purge-doc`` handler: drop stale entries on incremental + rebuilds so re-reads re-register themselves.""" + docnames = getattr(env, "module_verification_report_docnames", None) + if docnames is not None: + docnames.discard(docname) + + +def merge_docnames(app, env, docnames, other): + """``env-merge-info`` handler: union the per-worker sets back into + the main env when Sphinx runs a parallel read.""" + main = getattr(env, "module_verification_report_docnames", set()) + extra = getattr(other, "module_verification_report_docnames", set()) + env.module_verification_report_docnames = main | extra diff --git a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py new file mode 100644 index 000000000..ad712bfcc --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py @@ -0,0 +1,207 @@ +# ******************************************************************************* +# 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 tests for +:mod:`score_module_verification_report.testcase_annotations`.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +from docutils import nodes + +from src.extensions.score_module_verification_report import ( + testcase_annotations as ta, +) +from src.extensions.score_module_verification_report.testcase_annotations import ( # noqa: E501 + RESULT_COLORS, + _FALLBACK_COLOR, + annotate_testcase_results, + init_docnames, + merge_docnames, + purge_docname, +) + + +class _FakeNeedsView: + def __init__(self, needs): + self._needs = needs + + def get(self, need_id): + return self._needs.get(need_id) + + +def _patch_needs(needs): + """Return a context-manager patching the module-level ``_needs_view`` + helper so tests do not depend on sphinx-needs being importable.""" + view = _FakeNeedsView(needs) if needs is not None else None + return patch.object(ta, "_needs_view", lambda env: view) + + +def _doctree_with_testcase_link(text, refid="testcase__foo"): + """Build a tiny docutils tree containing a single reference whose + visible text is ``text`` (mimicking a sphinx-needs back-link).""" + doc = nodes.document(None, None) + ref = nodes.reference("", "", nodes.Text(text), refid=refid) + doc.append(ref) + return doc, ref + + +def _app(env): + return SimpleNamespace(env=env) + + +# --------------------------------------------------------------------------- +# init_docnames / purge_docname / merge_docnames +# --------------------------------------------------------------------------- + + +def test_init_docnames_creates_empty_set_when_absent(): + env = SimpleNamespace() + init_docnames(None, env, ["doc"]) + assert env.module_verification_report_docnames == set() + + +def test_init_docnames_preserves_existing_set(): + env = SimpleNamespace(module_verification_report_docnames={"already"}) + init_docnames(None, env, ["doc"]) + assert env.module_verification_report_docnames == {"already"} + + +def test_purge_docname_removes_entry(): + env = SimpleNamespace(module_verification_report_docnames={"a", "b"}) + purge_docname(None, env, "a") + assert env.module_verification_report_docnames == {"b"} + + +def test_purge_docname_ignores_missing_entry(): + env = SimpleNamespace(module_verification_report_docnames={"a"}) + purge_docname(None, env, "does-not-exist") + assert env.module_verification_report_docnames == {"a"} + + +def test_purge_docname_noop_when_attr_missing(): + env = SimpleNamespace() + purge_docname(None, env, "a") # must not raise + assert not hasattr(env, "module_verification_report_docnames") + + +def test_merge_docnames_unions_sets(): + main = SimpleNamespace(module_verification_report_docnames={"a"}) + other = SimpleNamespace(module_verification_report_docnames={"b", "c"}) + merge_docnames(None, main, ["b", "c"], other) + assert main.module_verification_report_docnames == {"a", "b", "c"} + + +def test_merge_docnames_when_main_has_no_attr(): + main = SimpleNamespace() + other = SimpleNamespace(module_verification_report_docnames={"b"}) + merge_docnames(None, main, ["b"], other) + assert main.module_verification_report_docnames == {"b"} + + +# --------------------------------------------------------------------------- +# annotate_testcase_results — happy paths +# --------------------------------------------------------------------------- + + +def test_annotates_passed_result_in_green(): + doc, ref = _doctree_with_testcase_link("testcase__foo") + env = SimpleNamespace( + module_verification_report_docnames={"my_report"}, + ) + with _patch_needs({"testcase__foo": {"result": "passed"}}): + annotate_testcase_results(_app(env), doc, "my_report") + + # Original text preserved as first child. + assert isinstance(ref.children[0], nodes.Text) + assert ref.children[0].astext() == "testcase__foo" + # Coloured raw HTML span appended. + assert isinstance(ref.children[-1], nodes.raw) + html = ref.children[-1].astext() + assert RESULT_COLORS["passed"] in html + assert "(passed)" in html + + +def test_annotates_failed_result_in_red(): + doc, ref = _doctree_with_testcase_link("testcase__bar") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs({"testcase__bar": {"result": "failed"}}): + annotate_testcase_results(_app(env), doc, "r") + html = ref.children[-1].astext() + assert RESULT_COLORS["failed"] in html + assert "(failed)" in html + + +def test_unknown_result_uses_fallback_color(): + doc, ref = _doctree_with_testcase_link("testcase__x") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs({"testcase__x": {"result": "weird"}}): + annotate_testcase_results(_app(env), doc, "r") + html = ref.children[-1].astext() + assert _FALLBACK_COLOR in html + assert "(weird)" in html + + +# --------------------------------------------------------------------------- +# annotate_testcase_results — no-op paths +# --------------------------------------------------------------------------- + + +def test_noop_when_docname_not_registered(): + doc, ref = _doctree_with_testcase_link("testcase__foo") + env = SimpleNamespace(module_verification_report_docnames={"other"}) + with _patch_needs({"testcase__foo": {"result": "passed"}}): + annotate_testcase_results(_app(env), doc, "my_report") + # Untouched. + assert len(ref.children) == 1 + assert ref.children[0].astext() == "testcase__foo" + + +def test_noop_when_attr_absent(): + doc, ref = _doctree_with_testcase_link("testcase__foo") + env = SimpleNamespace() + with _patch_needs({"testcase__foo": {"result": "passed"}}): + annotate_testcase_results(_app(env), doc, "my_report") + assert len(ref.children) == 1 + + +def test_noop_when_text_not_testcase(): + doc, ref = _doctree_with_testcase_link("comp_req__foo") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs({"comp_req__foo": {"result": "passed"}}): + annotate_testcase_results(_app(env), doc, "r") + assert len(ref.children) == 1 + + +def test_noop_when_need_missing(): + doc, ref = _doctree_with_testcase_link("testcase__missing") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs({}): # empty + annotate_testcase_results(_app(env), doc, "r") + assert len(ref.children) == 1 + + +def test_noop_when_result_empty(): + doc, ref = _doctree_with_testcase_link("testcase__x") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs({"testcase__x": {"result": ""}}): + annotate_testcase_results(_app(env), doc, "r") + assert len(ref.children) == 1 + + +def test_noop_when_needs_view_unavailable(): + doc, ref = _doctree_with_testcase_link("testcase__x") + env = SimpleNamespace(module_verification_report_docnames={"r"}) + with _patch_needs(None): # sphinx_needs not importable / not ready + annotate_testcase_results(_app(env), doc, "r") + assert len(ref.children) == 1 From 60378b218f7f8e4951ebaf64b9693f11460f51f3 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Thu, 20 Aug 2026 08:16:00 +0000 Subject: [PATCH 06/25] refactor(module-verification-report): add module-id/feature-id/component-prefix options Replace the mandatory :config: YAML file with direct RST directive options so the common case requires no external file at all: .. module-verification-report:: :module-id: mod__mymodule feature-id defaults to feat__<module-short> and component-prefix defaults to comp__<module-short>_ when omitted. :config: is retained as an optional escape hatch for non-default workproducts and per-component doc-id overrides; fields in the file are ignored when the corresponding directive option is set. Also: 12 unit tests in test_directive.py covering the option-vs-config precedence rules and all derivation paths; extension reference docs updated (typical usage, at-a-glance table, directive reference, config schema section retitled to 'advanced'); version unchanged. --- .../docs/module_verification_report.rst | 74 +++++--- .../directive.py | 46 +++-- .../tests/test_directive.py | 173 ++++++++++++++++++ 3 files changed, 256 insertions(+), 37 deletions(-) create mode 100644 src/extensions/score_module_verification_report/tests/test_directive.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 71dec0612..da23512cb 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -34,7 +34,10 @@ Typical use is in a module's ``verification_report/module_verification_report.rs --------------------- .. module-verification-report:: - :config: verification_report/module_report.yaml + :module-id: mod__mymodule + +A separate config file is only needed for the rare case of non-default +workproducts or per-component doc-id overrides (see ``:config:`` below). The extension is shipped as part of the :ref:`score_sphinx_bundle<extensions>`; consumers only need to add @@ -52,11 +55,11 @@ At a glance * - Directive - ``.. module-verification-report::`` — no arguments, no content; - one optional ``:config:`` option. + ``:module-id:`` is the only required option for the common case. - * - Config option - - ``:config: <path>`` — YAML file resolved relative to Sphinx's - ``srcdir`` (i.e. the directory containing ``conf.py``). + * - Key options + - ``:module-id:``, ``:feature-id:``, ``:component-prefix:`` as + direct RST options; ``:config:`` YAML for advanced overrides only. * - Reads from the source tree - Every ``.. mod::`` and ``.. comp::`` need, discovered by a @@ -82,7 +85,10 @@ Directive reference .. code-block:: rst .. module-verification-report:: - :config: <path/to/module_report.yaml> + :module-id: mod__mymodule + :feature-id: feat__mymodule # optional — derived from module-id + :component-prefix: comp__my_ # optional — derived from module-id + :config: path/to/overrides.yaml # optional — for WP overrides only **Arguments** None. @@ -99,11 +105,31 @@ Directive reference * - Option - Meaning + * - ``:module-id:`` + - The sphinx-needs id of the ``.. mod::`` need that owns this + report. Effectively required — omitting it leaves ``module_id`` + as the empty string and the ``.. mod::`` lookup will fail. + **Takes precedence** over the same field in ``:config:``. + + * - ``:feature-id:`` + - The sphinx-needs id of the ``.. feat::`` need for the feature + section. Defaults to ``feat__<module-short>`` (derived from + ``:module-id:`` by stripping the ``mod__`` prefix). + **Takes precedence** over ``:config:``. + + * - ``:component-prefix:`` + - Prefix used to strip the module slug from each component id when + generating short slugs for anchors and coverage lookup. Defaults + to ``comp__<module-short>_``. **Takes precedence** over + ``:config:``. + * - ``:config:`` - Optional path to a YAML config file, resolved relative to - ``srcdir``. When omitted, all defaults apply and ``module_id`` is - treated as the empty string — which will fail the ``.. mod::`` - lookup below (i.e. the option is effectively required). + ``srcdir``. In the common case this option is **not needed** — it + is only required for non-default workproduct lists or + per-component doc-id overrides. ``module_id`` / ``feature_id`` / + ``component_prefix`` in the file are ignored when the + corresponding directive option is set. **Errors** (fatal — the directive returns an ``error`` node): @@ -125,33 +151,26 @@ Directive reference .. _mvr_config: -Config file schema ------------------- +Config file schema (advanced) +------------------------------ -The YAML file drives every module-specific choice. Only ``module_id`` is -strictly required in practice; everything else has a sensible default. +A ``:config:`` YAML file is only needed when the default workproducts do not +match or when specific components use non-standard document-id naming. +Fields that duplicate directive options (``module_id``, ``feature_id``, +``component_prefix``) are ignored when the corresponding RST option is set. .. code-block:: yaml - # Mandatory. Must match the ``:id:`` of a ``.. mod::`` need under - # ``srcdir``. Example: ``mod__baselibs``. + # Ignored if :module-id: is set on the directive. module_id: mod__<module> - # Optional. Prefix stripped from each component id to produce its - # short "slug" (used for anchors and coverage lookup). Default: - # ``comp__<module>_`` — derived from ``module_id`` by stripping the - # ``mod__`` prefix. + # Ignored if :component-prefix: is set. Default: ``comp__<module>_``. component_prefix: comp__<module>_ - # Optional. The feature ``.. feat::`` need whose summary and - # statistics form the "Feature" section. Default: - # ``feat__<module>`` — same rule as component_prefix. + # Ignored if :feature-id: is set. Default: ``feat__<module>``. feature_id: feat__<module> - # Optional. The per-component work products checked in the - # "Verification & Safety Analysis Documents" table. - # Default: the five standard SCORE items — Requirements Inspection, - # Architecture Inspection, Implementation Inspection, DFA, FMEA. + # Optional. Override the default five standard SCORE workproducts. workproducts: - key: <stable_short_key> # only used for override lookup label: <human-readable label> # shown in the "Kind" column @@ -455,6 +474,9 @@ grouped by module: measured / spec-only intro decision. * ``test_rendering.py`` — slug utilities, override vs. filter row rendering, and end-to-end ``render_report`` assembly. +* ``test_directive.py`` — option resolution logic: ``module-id`` / + ``feature-id`` / ``component-prefix`` derivation and the precedence + of directive options over config file values. * ``test_testcase_annotations.py`` — the ``env-before-read-docs`` / ``env-purge-doc`` / ``env-merge-info`` lifecycle handlers plus every branch of ``annotate_testcase_results`` (colours, unknown result, diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index ecfd6107f..e43da2548 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -30,14 +30,28 @@ class ModuleVerificationReportDirective(SphinxDirective): """Expand to the per-module verification report body. - Discovers components dynamically from the sphinx-needs data model by - filtering all needs by ``type == "comp"`` and - ``id.startswith(component_prefix)``. + Minimal usage:: + + .. module-verification-report:: + :module-id: mod__mymodule + + The ``feature-id`` defaults to ``feat__<module-short>`` and the + ``component-prefix`` defaults to ``comp__<module-short>_``. + + An optional ``:config:`` YAML file is still supported for the rare + case of custom workproducts or per-component doc-id overrides; all + other fields in that file are ignored when ``module-id`` is given as + an option. """ required_arguments = 0 optional_arguments = 0 - option_spec = {"config": str} + option_spec = { + "module-id": str, + "feature-id": str, + "component-prefix": str, + "config": str, + } has_content = False def _load_config(self, rel_config: str | None) -> dict: @@ -57,20 +71,30 @@ def _load_config(self, rel_config: str | None) -> dict: return data def run(self) -> list[nodes.Node]: + # Directive options take precedence over config file values so that + # the common case needs no YAML file at all. config = self._load_config(self.options.get("config")) - module_id = config.get("module_id", "") - component_prefix = config.get("component_prefix") or ( - "comp__" + module_id[len("mod__"):] + "_" - if module_id.startswith("mod__") - else "comp__" - ) + module_id = self.options.get("module-id") or config.get("module_id", "") module_short = ( module_id[len("mod__"):] if module_id.startswith("mod__") else module_id ) - feature_id = config.get("feature_id") or f"feat__{module_short}" + component_prefix = ( + self.options.get("component-prefix") + or config.get("component_prefix") + or ( + "comp__" + module_short + "_" + if module_short + else "comp__" + ) + ) + feature_id = ( + self.options.get("feature-id") + or config.get("feature_id") + or f"feat__{module_short}" + ) feature_slug = ( feature_id.split("__", 1)[1] if "__" in feature_id diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py new file mode 100644 index 000000000..8f755cec2 --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_directive.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 Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Unit tests for the configuration-resolution logic in +:mod:`score_module_verification_report.directive`. + +The directive requires a full Sphinx environment to instantiate, so we test +the pure derivation rules in isolation via small helper invocations that +replicate the logic from ``run()`` without standing up Sphinx. +""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers that mirror the derivation logic in directive.py so we can test +# it without a Sphinx environment. +# --------------------------------------------------------------------------- + + +def _resolve( + *, + option_module_id: str = "", + option_feature_id: str = "", + option_component_prefix: str = "", + config: dict | None = None, +) -> dict: + """Run the same config-resolution logic as ``run()`` and return a + dict with the resolved fields.""" + if config is None: + config = {} + module_id = option_module_id or config.get("module_id", "") + module_short = ( + module_id[len("mod__"):] + if module_id.startswith("mod__") + else module_id + ) + component_prefix = ( + option_component_prefix + or config.get("component_prefix") + or ("comp__" + module_short + "_" if module_short else "comp__") + ) + feature_id = ( + option_feature_id + or config.get("feature_id") + or f"feat__{module_short}" + ) + feature_slug = ( + feature_id.split("__", 1)[1] if "__" in feature_id else feature_id + ) + return { + "module_id": module_id, + "module_short": module_short, + "component_prefix": component_prefix, + "feature_id": feature_id, + "feature_slug": feature_slug, + } + + +# --------------------------------------------------------------------------- +# Tests — option-only (no config file) +# --------------------------------------------------------------------------- + + +def test_module_id_option_derives_prefix_and_feature(): + r = _resolve(option_module_id="mod__baselibs") + assert r["module_id"] == "mod__baselibs" + assert r["module_short"] == "baselibs" + assert r["component_prefix"] == "comp__baselibs_" + assert r["feature_id"] == "feat__baselibs" + assert r["feature_slug"] == "baselibs" + + +def test_explicit_feature_id_option_overrides_derived(): + r = _resolve(option_module_id="mod__baselibs", option_feature_id="feat__bl") + assert r["feature_id"] == "feat__bl" + assert r["feature_slug"] == "bl" + + +def test_explicit_component_prefix_option_overrides_derived(): + r = _resolve(option_module_id="mod__baselibs", option_component_prefix="comp__bl_") + assert r["component_prefix"] == "comp__bl_" + + +def test_module_id_without_mod_prefix(): + r = _resolve(option_module_id="mymodule") + assert r["module_short"] == "mymodule" + assert r["component_prefix"] == "comp__mymodule_" + assert r["feature_id"] == "feat__mymodule" + + +def test_empty_module_id_gives_generic_prefix(): + r = _resolve() + assert r["module_id"] == "" + assert r["component_prefix"] == "comp__" + assert r["feature_id"] == "feat__" + + +# --------------------------------------------------------------------------- +# Tests — option takes precedence over config +# --------------------------------------------------------------------------- + + +def test_option_module_id_beats_config(): + r = _resolve( + option_module_id="mod__fromopt", + config={"module_id": "mod__fromconfig"}, + ) + assert r["module_id"] == "mod__fromopt" + + +def test_option_feature_id_beats_config(): + r = _resolve( + option_module_id="mod__baselibs", + option_feature_id="feat__opt", + config={"feature_id": "feat__cfg"}, + ) + assert r["feature_id"] == "feat__opt" + + +def test_option_component_prefix_beats_config(): + r = _resolve( + option_module_id="mod__baselibs", + option_component_prefix="comp__opt_", + config={"component_prefix": "comp__cfg_"}, + ) + assert r["component_prefix"] == "comp__opt_" + + +def test_config_used_when_no_option_given(): + r = _resolve( + config={ + "module_id": "mod__cfg", + "feature_id": "feat__cfg", + "component_prefix": "comp__cfg_", + } + ) + assert r["module_id"] == "mod__cfg" + assert r["feature_id"] == "feat__cfg" + assert r["component_prefix"] == "comp__cfg_" + + +def test_config_feature_id_used_when_no_option(): + r = _resolve( + option_module_id="mod__baselibs", + config={"feature_id": "feat__custom"}, + ) + assert r["feature_id"] == "feat__custom" + + +# --------------------------------------------------------------------------- +# Tests — feature_slug extraction +# --------------------------------------------------------------------------- + + +def test_feature_slug_splits_on_double_underscore(): + r = _resolve(option_feature_id="feat__my_module") + assert r["feature_slug"] == "my_module" + + +def test_feature_slug_falls_back_to_full_id_when_no_double_underscore(): + r = _resolve(option_feature_id="noprefix") + assert r["feature_slug"] == "noprefix" From dc84c33fa4f580bbac21e9055a4368c135b5908b Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Thu, 20 Aug 2026 08:32:12 +0000 Subject: [PATCH 07/25] refactor(module-verification-report): replace filesystem scan with :components: option Instead of scanning .rst files at env-before-read-docs time to resolve the mod's :includes: list, component ids are now declared directly on the directive: .. module-verification-report:: :module-id: mod__baselibs :feature-id: feat__baselibs :components: comp__baselibs_json, comp__baselibs_bit_manipulation, ... Changes: - scanner.py removed entirely (no filesystem walk, no regex parsing, no env.module_verification_report_needs attribute). - env-before-read-docs hook for scan_source_tree removed from setup(). - _parse_components() helper in directive.py: splits on commas, strips optional [version==N] qualifiers, derives slug by stripping component_prefix, derives title from slug (underscore -> space, title-case). - Error if :components: is empty / omitted. - Version bumped 0.7 -> 0.8. - test_scanner.py removed; test_directive.py extended with 9 tests for _parse_components (id parsing, version stripping, title derivation, whitespace handling, multi-line values, prefix mismatch). - Reference docs updated: typical usage, at-a-glance table, directive reference (:components: option), 'No filesystem scan' rationale section replaces 'Filesystem scan' section, architecture table updated to five modules, limitations updated, testing section updated. --- .../docs/module_verification_report.rst | 133 +++++------ .../__init__.py | 33 +-- .../directive.py | 85 +++---- .../scanner.py | 184 --------------- .../tests/test_directive.py | 76 ++++++- .../tests/test_scanner.py | 212 ------------------ 6 files changed, 182 insertions(+), 541 deletions(-) delete mode 100644 src/extensions/score_module_verification_report/scanner.py delete mode 100644 src/extensions/score_module_verification_report/tests/test_scanner.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index da23512cb..c6506c4c0 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -35,9 +35,10 @@ Typical use is in a module's ``verification_report/module_verification_report.rs .. module-verification-report:: :module-id: mod__mymodule + :feature-id: feat__mymodule + :components: comp__mymodule_a, comp__mymodule_b -A separate config file is only needed for the rare case of non-default -workproducts or per-component doc-id overrides (see ``:config:`` below). +No external config file is required for the common case. The extension is shipped as part of the :ref:`score_sphinx_bundle<extensions>`; consumers only need to add @@ -55,27 +56,26 @@ At a glance * - Directive - ``.. module-verification-report::`` — no arguments, no content; - ``:module-id:`` is the only required option for the common case. + ``:module-id:`` and ``:components:`` cover the common case. * - Key options - - ``:module-id:``, ``:feature-id:``, ``:component-prefix:`` as - direct RST options; ``:config:`` YAML for advanced overrides only. + - ``:module-id:``, ``:feature-id:``, ``:component-prefix:``, + ``:components:`` (comma-separated id list); + ``:config:`` YAML for non-default workproducts / overrides only. - * - Reads from the source tree - - Every ``.. mod::`` and ``.. comp::`` need, discovered by a - shallow regex scan at ``env-before-read-docs`` time. + * - No filesystem scan + - Component ids are provided directly via ``:components:`` — + the extension does not scan ``.rst`` files at build time. * - Reads from JSON - - ``<srcdir>/reporting/coverage_summary.json`` (optional; produced - by ``tools/extract_coverage.py``). + - ``<srcdir>/reporting/coverage_summary.json`` (optional). * - Delegates to sphinx-needs - Status, safety, security, requirement / architecture element tables, pie charts, "Realized by" cells. * - Parallel-read safe - - Yes (``parallel_read_safe = True``). The filesystem scan is - reproducible in every worker. + - Yes (``parallel_read_safe = True``). .. _mvr_directive: @@ -88,7 +88,9 @@ Directive reference :module-id: mod__mymodule :feature-id: feat__mymodule # optional — derived from module-id :component-prefix: comp__my_ # optional — derived from module-id - :config: path/to/overrides.yaml # optional — for WP overrides only + :components: comp__mymodule_a, + comp__mymodule_b + :config: path/to/overrides.yaml # optional — WP overrides / custom WPs **Arguments** None. @@ -107,9 +109,8 @@ Directive reference * - ``:module-id:`` - The sphinx-needs id of the ``.. mod::`` need that owns this - report. Effectively required — omitting it leaves ``module_id`` - as the empty string and the ``.. mod::`` lookup will fail. - **Takes precedence** over the same field in ``:config:``. + report. Used to derive ``feature-id`` and ``component-prefix`` + defaults. **Takes precedence** over the same field in ``:config:``. * - ``:feature-id:`` - The sphinx-needs id of the ``.. feat::`` need for the feature @@ -118,33 +119,31 @@ Directive reference **Takes precedence** over ``:config:``. * - ``:component-prefix:`` - - Prefix used to strip the module slug from each component id when - generating short slugs for anchors and coverage lookup. Defaults + - Prefix stripped from each component id to produce its short slug + (used for anchors and document-id substring matching). Defaults to ``comp__<module-short>_``. **Takes precedence** over ``:config:``. + * - ``:components:`` + - Comma-separated list of ``.. comp::`` need ids to include in the + report. Optional ``[version==N]`` qualifiers are stripped + silently. Multi-line values work (continuation lines are joined + by docutils with a space before splitting on commas). This option + replaces the old filesystem scan entirely. + * - ``:config:`` - Optional path to a YAML config file, resolved relative to - ``srcdir``. In the common case this option is **not needed** — it - is only required for non-default workproduct lists or + ``srcdir``. Only needed for non-default workproduct lists or per-component doc-id overrides. ``module_id`` / ``feature_id`` / ``component_prefix`` in the file are ignored when the corresponding directive option is set. **Errors** (fatal — the directive returns an ``error`` node): -* ``no '.. mod::' need with id '<module_id>' found in the source tree`` - — the config's ``module_id`` does not match any ``.. mod::`` directive - visible under ``srcdir``. -* ``'<module_id>' has no resolvable components in ':includes:'`` — the - ``.. mod::`` need was found but no whitelisted component id matched a - ``.. comp::`` directive. +* ``no components specified`` — ``:components:`` was omitted or empty. **Warnings** (non-fatal): -* ``'<module_id>' includes '<id>' but no matching '.. comp::' need was - found`` — one entry of ``:includes:`` is dangling; the report still - renders for the remaining components. * ``config not found: <abs path>`` — the ``:config:`` path does not exist; the report falls back to defaults and will almost certainly fail the ``mod`` lookup. @@ -260,27 +259,22 @@ link uses the field the sphinx-needs data model already defines. .. _mvr_scan: -Filesystem scan (what the extension actually reads) ---------------------------------------------------- - -At ``env-before-read-docs`` the extension performs one recursive -``os.walk`` of ``env.srcdir`` and stores the result on -``env.module_verification_report_needs`` (see -:mod:`.scanner`). Only ``.rst`` files are considered; unreadable files -are silently skipped. +No filesystem scan +------------------ -The scan captures every directive whose header matches -``^\.\. (mod|comp):: <title>`` and reads its ``:id:``, -``:includes:`` (mod only) and ``:version:`` (comp only) option lines. -**All other option lines are dropped** — safety, security, status, -tags, satisfies, etc. are resolved by sphinx-needs at render time from -the same source RST, so the scanner does not need to interpret them. +Component ids are supplied directly via ``:components:`` — the extension +does **not** scan ``.rst`` files at build time. The component titles shown +in section headings and pie-chart labels are derived from the id slug +(``comp__mymod_bit_manipulation`` → slug ``bit_manipulation`` → title +``Bit Manipulation``). -Rationale: querying ``SphinxNeedsData`` at directive-run time would -force ``parallel_read_safe = False`` and produce a "doing serial read" -warning that is fatal under ``-W``. A shallow regex scan is cheap, -reproducible in every worker of a parallel build, and only needs to -enumerate ids and titles. +The previous design used a shallow regex scan of ``srcdir`` at +``env-before-read-docs`` time to resolve the ``:includes:`` list of the +``.. mod::`` need. Querying ``SphinxNeedsData`` was impossible at that +point (it forces ``parallel_read_safe = False``), so a regex scan was used +instead. With ids provided directly, neither scan nor +``SphinxNeedsData`` queries are needed, and ``parallel_read_safe = True`` +is trivially guaranteed. .. _mvr_output: @@ -380,7 +374,7 @@ Missing / malformed JSON is not an error: the loader returns ``{}``. Extension architecture ---------------------- -Implementation is split across six modules under +Implementation is split across five modules under ``src/extensions/score_module_verification_report/`` so that each layer can be tested independently: @@ -391,11 +385,6 @@ can be tested independently: * - Module - Responsibility - * - :mod:`.scanner` - - Filesystem regex scan for ``.. mod::`` / ``.. comp::``, - ``:includes:`` parsing, component whitelisting. - Registers ``scan_source_tree`` on ``env-before-read-docs``. - * - :mod:`.coverage` - ``coverage_summary.json`` loading and intro-paragraph selection. @@ -409,8 +398,9 @@ can be tested independently: ``render_report``, and the shared ``workproduct_rows`` helper. * - :mod:`.directive` - - The ``ModuleVerificationReportDirective`` class. Loads the - YAML config, calls the scanner / renderer, and + - The ``ModuleVerificationReportDirective`` class. Parses the + ``:components:`` option via ``_parse_components``, loads the + optional YAML config, calls the renderer, and ``nested_parse_with_titles`` the resulting RST into the document. Registers its docname in ``env.module_verification_report_docnames`` so the annotation @@ -433,11 +423,7 @@ can be tested independently: The public surface is intentionally minimal: * the directive ``module-verification-report`` (added in ``setup``); -* the event handler - ``scanner.scan_source_tree`` (connected to ``env-before-read-docs`` - in ``setup``); -* the cached attributes ``env.module_verification_report_needs`` and - ``env.module_verification_report_docnames``. +* the cached attribute ``env.module_verification_report_docnames``. All other functions are considered internal and covered by unit tests under ``tests/``. @@ -447,15 +433,14 @@ under ``tests/``. Known limitations ----------------- -* **Feature-only modules are not supported** — the extension hard-fails - if it cannot find a ``.. mod::`` need with matching ``:id:`` and at - least one resolvable component in its ``:includes:``. Repos like - Lifecycle (feature-only, no ``comp``) currently need a small patch to - the directive. -* **The scan is line-oriented** — a ``.. mod::`` / ``.. comp::`` - directive split across a line-continuation, or preceded by uncommon - indentation, may be missed. All in-tree consumers use the canonical - form documented above. +* **Feature-only modules** — the feature section is always rendered; if + the ``feat__<module>`` need does not exist, sphinx-needs will produce + an empty table rather than an error. +* **Component title derivation** — section headings and pie-chart labels + are derived from the slug (underscores → spaces, title-case). For + acronym-heavy names like ``safecpp`` the result is ``Safecpp`` rather + than ``SafeCpp``; use ``:component-prefix:`` to control slug length + if needed. * **Nested tables render inside cells** — the ``.. needtable::`` widgets used in the WP rows are hidden by the scoped CSS block, but their DataTables initialisation still runs. Very large modules may see a @@ -468,15 +453,13 @@ Unit tests live under ``src/extensions/score_module_verification_report/tests/`` and are grouped by module: -* ``test_scanner.py`` — every branch of ``scan_rst_needs``, - ``module_includes``, ``discover_components``. +* ``test_directive.py`` — ``_parse_components`` (id parsing, version + stripping, title derivation, empty/whitespace input) and option / + config precedence rules. * ``test_coverage.py`` — JSON loading edge cases and the measured / spec-only intro decision. * ``test_rendering.py`` — slug utilities, override vs. filter row rendering, and end-to-end ``render_report`` assembly. -* ``test_directive.py`` — option resolution logic: ``module-id`` / - ``feature-id`` / ``component-prefix`` derivation and the precedence - of directive options over config file values. * ``test_testcase_annotations.py`` — the ``env-before-read-docs`` / ``env-purge-doc`` / ``env-merge-info`` lifecycle handlers plus every branch of ``annotate_testcase_results`` (colours, unknown result, diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 285559619..2da031631 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -15,31 +15,18 @@ Usage in RST:: .. module-verification-report:: - :config: reporting/module_verification_report.yaml # optional + :module-id: mod__baselibs + :feature-id: feat__baselibs + :components: comp__baselibs_json, + comp__baselibs_bit_manipulation, + comp__baselibs_containers -The config file has the shape:: - - module_id: mod__baselibs - # optional; derived from module_id if omitted - component_prefix: comp__baselibs_ - # optional; standard workproducts checked per component - workproducts: - - key: requirements_inspect - label: Requirements Inspection - wp_id: wp__requirements_inspect - - ... - # optional; per-component overrides for irregular cases (documents - # whose id does not contain the component slug, e.g. - # ``comp__baselibs_nlohman_json`` -> ``doc__json_*``) - overrides: - comp__baselibs_some_component: - workproducts: - requirements_inspect: doc__some_component_req_inspection - ... +An optional ``:config:`` YAML file can supply non-default workproducts or +per-component doc-id overrides for the rare case where component documents +do not follow the standard naming convention. Implementation is split across: -* :mod:`.scanner` — filesystem scan for ``.. mod::`` / ``.. comp::`` * :mod:`.coverage` — ``coverage_summary.json`` loading * :mod:`.templates` — RST templates + default workproduct lists + CSS * :mod:`.rendering` — template expansion / report body assembly @@ -52,7 +39,6 @@ from typing import Any from .directive import ModuleVerificationReportDirective -from .scanner import scan_source_tree from .testcase_annotations import ( annotate_testcase_results, init_docnames, @@ -65,13 +51,12 @@ def setup(app: Any) -> dict: app.add_directive( "module-verification-report", ModuleVerificationReportDirective ) - app.connect("env-before-read-docs", scan_source_tree) app.connect("env-before-read-docs", init_docnames) app.connect("env-purge-doc", purge_docname) app.connect("env-merge-info", merge_docnames) app.connect("doctree-resolved", annotate_testcase_results) return { - "version": "0.7", + "version": "0.8", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index e43da2548..16b8ba32d 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import re import yaml from docutils import nodes @@ -23,25 +24,49 @@ from .coverage import load_coverage_summary from .rendering import render_report -from .scanner import discover_components, module_includes from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS +# Strip an optional ``[version==N]`` qualifier from a component id. +_VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") + + +def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: + """Parse a comma-separated list of component ids into component dicts. + + Each entry may carry an optional ``[version==N]`` qualifier which is + stripped silently — the rendered report does not filter by version. + + The short slug is the component id with ``component_prefix`` removed + (or the full id if the prefix is absent). The human-readable title is + derived from the slug: underscores replaced with spaces, title-cased. + """ + result = [] + for raw in ids_str.split(","): + comp_id = _VERSION_QUALIFIER_RE.sub("", raw.strip()) + if not comp_id: + continue + slug = ( + comp_id[len(component_prefix):] + if component_prefix and comp_id.startswith(component_prefix) + else comp_id + ) + title = slug.replace("_", " ").title() + result.append({"id": comp_id, "slug": slug, "title": title}) + return result + class ModuleVerificationReportDirective(SphinxDirective): """Expand to the per-module verification report body. - Minimal usage:: + Minimal usage — no external config file required:: .. module-verification-report:: :module-id: mod__mymodule + :components: comp__mymodule_a, comp__mymodule_b - The ``feature-id`` defaults to ``feat__<module-short>`` and the - ``component-prefix`` defaults to ``comp__<module-short>_``. - - An optional ``:config:`` YAML file is still supported for the rare - case of custom workproducts or per-component doc-id overrides; all - other fields in that file are ignored when ``module-id`` is given as - an option. + ``feature-id`` and ``component-prefix`` are optional and derived from + ``module-id`` when omitted. ``config`` is only needed for non-default + workproducts or per-component doc-id overrides. """ required_arguments = 0 @@ -50,6 +75,7 @@ class ModuleVerificationReportDirective(SphinxDirective): "module-id": str, "feature-id": str, "component-prefix": str, + "components": str, "config": str, } has_content = False @@ -84,11 +110,7 @@ def run(self) -> list[nodes.Node]: component_prefix = ( self.options.get("component-prefix") or config.get("component_prefix") - or ( - "comp__" + module_short + "_" - if module_short - else "comp__" - ) + or ("comp__" + module_short + "_" if module_short else "comp__") ) feature_id = ( self.options.get("feature-id") @@ -106,33 +128,12 @@ def run(self) -> list[nodes.Node]: ) overrides_by_id: dict[str, dict] = config.get("overrides") or {} - all_needs = getattr(self.env, "module_verification_report_needs", []) - include_ids = module_includes(all_needs, module_id) - if include_ids is None: - error = self.state_machine.reporter.error( - f"module-verification-report: no '.. mod::' need with " - f"id '{module_id}' found in the source tree " - f"(is 'module_id' set correctly in the config?)", - line=self.lineno, - ) - return [error] - - components = discover_components( - self.env, component_prefix, include_ids - ) - missing = set(include_ids) - {c["id"] for c in components} - for m in sorted(missing): - required = include_ids[m] - hint = f" (version=={required})" if required else "" - self.state_machine.reporter.warning( - f"module-verification-report: '{module_id}' includes " - f"'{m}'{hint} but no matching '.. comp::' need was found", - line=self.lineno, - ) + components_str = self.options.get("components", "") + components = _parse_components(components_str, component_prefix) if not components: error = self.state_machine.reporter.error( - f"module-verification-report: '{module_id}' has no " - f"resolvable components in ':includes:'", + "module-verification-report: no components specified — " + "add ':components: comp__<id>, ...' to the directive", line=self.lineno, ) return [error] @@ -160,13 +161,13 @@ def run(self) -> list[nodes.Node]: # ``Feature Requirements Statistics``. container = nodes.container() container.document = self.state.document - nested_parse_with_titles(self.state, view_list, container) + nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] # Register this docname so the ``doctree-resolved`` hook in # ``testcase_annotations`` knows to decorate testcase back-links # with a coloured ``(passed)`` / ``(failed)`` badge here. if not hasattr(self.env, "module_verification_report_docnames"): - self.env.module_verification_report_docnames = set() - self.env.module_verification_report_docnames.add(self.env.docname) + self.env.module_verification_report_docnames = set() # type: ignore[attr-defined] + self.env.module_verification_report_docnames.add(self.env.docname) # type: ignore[attr-defined] return container.children diff --git a/src/extensions/score_module_verification_report/scanner.py b/src/extensions/score_module_verification_report/scanner.py deleted file mode 100644 index 848fa2db4..000000000 --- a/src/extensions/score_module_verification_report/scanner.py +++ /dev/null @@ -1,184 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Filesystem scanner for ``.. mod::`` / ``.. comp::`` needs. - -We deliberately do **not** query ``SphinxNeedsData`` here: doing so would -require reading the report source strictly after every source registering -a ``.. comp::`` / ``.. document::`` need, which forces -``parallel_read_safe = False`` on the extension and produces two -Sphinx-level warnings per build (``the score_module_verification_report -extension is not safe for parallel reading`` / ``doing serial read``). -Those warnings are fatal under ``-W``. - -The RST directive syntax used across baselibs is stable:: - - .. comp:: <title> - :id: comp__baselibs_<slug> - :safety: ASIL_B - :security: NO - :status: valid - ... - - .. document:: <title> - :id: doc__<slug>_<suffix> - :realizes: wp__<key>[version==<N>] - ... - -Documents are matched to a work product entirely on the sphinx-needs -side, at render time. This scan only needs to enumerate components and -their titles. - -A shallow regex scan of the source tree at ``env-before-read-docs`` -gives us everything the directive needs, and works in every process of -a parallel build. -""" -from __future__ import annotations - -import os -import re -from typing import Any - - -_DIRECTIVE_HEADER_RE = re.compile( - r"^\.\.[ \t]+(?P<name>[a-z_-]+)::[ \t]*(?P<title>.*?)\s*$" -) -_OPTION_LINE_RE = re.compile(r"^[ \t]+:(?P<key>[^:]+):[ \t]*(?P<value>.*?)\s*$") - -# Options captured from ``:key: value`` lines. Everything else -# (safety, security, status, realizes, tags, ...) is intentionally -# dropped: the report delegates all attribute and link resolution to -# sphinx-needs at render time (``.. needtable::`` / ``.. needlist::``). -# ``includes`` is captured only for ``.. mod::`` needs (whitelist of -# components; see :func:`module_includes`); ``version`` is captured -# on ``.. comp::`` needs to honour ``[version==N]`` filters coming from -# that whitelist. -_SCANNED_OPTIONS = frozenset({"id", "includes", "version"}) - -_INCLUDE_ENTRY_RE = re.compile( - r"^(?P<id>[^\[\s]+)(?:\[version==(?P<version>[^\]]+)\])?\s*$" -) - - -def scan_rst_needs(srcdir: str, directives: set[str]) -> list[dict]: - """Return every need declared by one of *directives* under *srcdir*. - - Each result carries ``directive`` (e.g. ``comp``), ``id`` and - ``title``. Silently skips unreadable files. - """ - results: list[dict] = [] - for root, _dirs, files in os.walk(srcdir): - for fname in files: - if not fname.endswith(".rst"): - continue - path = os.path.join(root, fname) - try: - with open(path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - except (OSError, UnicodeDecodeError): - continue - i = 0 - while i < len(lines): - m = _DIRECTIVE_HEADER_RE.match(lines[i]) - if not m or m.group("name") not in directives: - i += 1 - continue - entry: dict[str, Any] = { - "directive": m.group("name"), - "title": m.group("title").strip(), - } - j = i + 1 - while j < len(lines): - opt = _OPTION_LINE_RE.match(lines[j]) - if not opt: - break - key = opt.group("key").strip() - if key in _SCANNED_OPTIONS: - entry[key] = opt.group("value").strip() - j += 1 - if "id" in entry: - results.append(entry) - i = j if j > i else i + 1 - return results - - -def module_includes( - needs: list[dict], module_id: str -) -> dict[str, str | None] | None: - """Return the component ids listed in ``:includes:`` on the - ``.. mod::`` need whose id equals *module_id*, mapped to their - required version (or ``None`` if no ``[version==N]`` filter was set). - - Entries in ``:includes:`` have the form ``<id>[version==<N>]`` and - are comma-separated. Returns ``None`` if no matching mod need is - found in *needs* (spec error → caller renders an ``error`` node). - """ - for entry in needs: - if entry.get("directive") != "mod" or entry.get("id") != module_id: - continue - raw = entry.get("includes", "") - result: dict[str, str | None] = {} - for part in raw.split(","): - m = _INCLUDE_ENTRY_RE.match(part.strip()) - if m: - result[m.group("id")] = m.group("version") - return result - return None - - -def discover_components( - env, component_prefix: str, whitelist: dict[str, str | None] -) -> list[dict]: - """Return every ``.. comp::`` need whose id (and, when a - ``[version==N]`` filter was declared, whose ``:version:``) matches - an entry in *whitelist*. - - Sourced from the filesystem scan cached on ``env`` at - ``env-before-read-docs``. Components are returned sorted by id for a - stable display order. Whitelist entries with no matching - ``.. comp::`` scan result are silently ignored (caller may want to - warn). - """ - result: list[dict] = [] - for entry in getattr(env, "module_verification_report_needs", []): - if entry.get("directive") != "comp": - continue - need_id = entry.get("id", "") - if need_id not in whitelist: - continue - required_version = whitelist[need_id] - if required_version is not None and entry.get("version") != required_version: - continue - slug = ( - need_id[len(component_prefix):] - if need_id.startswith(component_prefix) - else need_id - ) - result.append( - { - "id": need_id, - "slug": slug, - "title": entry.get("title") or need_id, - } - ) - result.sort(key=lambda c: c["id"]) - return result - - -def scan_source_tree(app, env, docnames): - """Cache a filesystem scan of all ``.. mod::`` and ``.. comp::`` - needs on ``env`` so the directive can enumerate its components in - every process of a parallel build. - """ - env.module_verification_report_needs = scan_rst_needs( - env.srcdir, directives={"mod", "comp"} - ) diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 8f755cec2..33b0339b8 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -10,17 +10,20 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Unit tests for the configuration-resolution logic in -:mod:`score_module_verification_report.directive`. +"""Unit tests for the configuration-resolution and component-parsing logic +in :mod:`score_module_verification_report.directive`. The directive requires a full Sphinx environment to instantiate, so we test -the pure derivation rules in isolation via small helper invocations that -replicate the logic from ``run()`` without standing up Sphinx. +the pure derivation rules and the ``_parse_components`` helper in isolation. """ from __future__ import annotations import pytest +from src.extensions.score_module_verification_report.directive import ( + _parse_components, +) + # --------------------------------------------------------------------------- # Helpers that mirror the derivation logic in directive.py so we can test @@ -67,6 +70,71 @@ def _resolve( } +# --------------------------------------------------------------------------- +# _parse_components +# --------------------------------------------------------------------------- + + +def test_parse_single_id(): + result = _parse_components("comp__mymod_json", "comp__mymod_") + assert len(result) == 1 + assert result[0]["id"] == "comp__mymod_json" + assert result[0]["slug"] == "json" + assert result[0]["title"] == "Json" + + +def test_parse_multiple_ids(): + result = _parse_components( + "comp__mymod_json, comp__mymod_bit_manipulation", "comp__mymod_" + ) + assert len(result) == 2 + assert result[0]["slug"] == "json" + assert result[1]["slug"] == "bit_manipulation" + assert result[1]["title"] == "Bit Manipulation" + + +def test_parse_strips_version_qualifier(): + result = _parse_components( + "comp__mymod_json[version==1], comp__mymod_result[version==2]", + "comp__mymod_", + ) + assert result[0]["id"] == "comp__mymod_json" + assert result[1]["id"] == "comp__mymod_result" + + +def test_parse_empty_string_returns_empty(): + assert _parse_components("", "comp__mymod_") == [] + + +def test_parse_whitespace_only_entries_skipped(): + result = _parse_components("comp__mymod_json, , ", "comp__mymod_") + assert len(result) == 1 + + +def test_parse_without_matching_prefix_uses_full_id_as_slug(): + result = _parse_components("comp__other_json", "comp__mymod_") + assert result[0]["slug"] == "comp__other_json" + assert result[0]["title"] == "Comp Other Json" + + +def test_parse_no_prefix_uses_full_id(): + result = _parse_components("comp__mymod_json", "") + assert result[0]["slug"] == "comp__mymod_json" + + +def test_parse_title_uses_titlecase(): + result = _parse_components("comp__m_memory_shared", "comp__m_") + assert result[0]["title"] == "Memory Shared" + + +def test_parse_multiline_string(): + """Continuation lines (as docutils joins them with whitespace) work.""" + result = _parse_components( + "comp__m_json,\n comp__m_result\n", "comp__m_" + ) + assert len(result) == 2 + + # --------------------------------------------------------------------------- # Tests — option-only (no config file) # --------------------------------------------------------------------------- diff --git a/src/extensions/score_module_verification_report/tests/test_scanner.py b/src/extensions/score_module_verification_report/tests/test_scanner.py deleted file mode 100644 index f5afde7ac..000000000 --- a/src/extensions/score_module_verification_report/tests/test_scanner.py +++ /dev/null @@ -1,212 +0,0 @@ -# ******************************************************************************* -# 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 tests for :mod:`score_module_verification_report.scanner`.""" -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -from src.extensions.score_module_verification_report.scanner import ( - discover_components, - module_includes, - scan_rst_needs, - scan_source_tree, -) - - -def _write(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# scan_rst_needs -# --------------------------------------------------------------------------- - - -def test_scan_rst_needs_captures_mod_and_comp(tmp_path: Path) -> None: - _write( - tmp_path / "a.rst", - ".. mod:: My Module\n" - " :id: mod__demo\n" - " :includes: comp__demo_x, comp__demo_y[version==2]\n" - "\n" - ".. comp:: X\n" - " :id: comp__demo_x\n" - " :safety: ASIL_B\n" - "\n" - ".. comp:: Y\n" - " :id: comp__demo_y\n" - " :version: 2\n", - ) - needs = scan_rst_needs(str(tmp_path), directives={"mod", "comp"}) - assert len(needs) == 3 - mod = next(n for n in needs if n["directive"] == "mod") - assert mod["id"] == "mod__demo" - assert "comp__demo_x" in mod["includes"] - comp_y = next(n for n in needs if n["id"] == "comp__demo_y") - assert comp_y["version"] == "2" - assert comp_y["title"] == "Y" - - -def test_scan_rst_needs_ignores_other_directives(tmp_path: Path) -> None: - _write( - tmp_path / "a.rst", - ".. comp:: Kept\n" - " :id: comp__kept\n" - "\n" - ".. document:: Skipped\n" - " :id: doc__skipped\n", - ) - needs = scan_rst_needs(str(tmp_path), directives={"mod", "comp"}) - assert [n["id"] for n in needs] == ["comp__kept"] - - -def test_scan_rst_needs_skips_entry_without_id(tmp_path: Path) -> None: - _write( - tmp_path / "a.rst", - ".. comp:: Anonymous\n" - " :safety: QM\n", - ) - assert scan_rst_needs(str(tmp_path), directives={"comp"}) == [] - - -def test_scan_rst_needs_skips_non_rst_and_bad_encoding(tmp_path: Path) -> None: - _write(tmp_path / "a.md", ".. comp:: not rst\n :id: comp__x\n") - (tmp_path / "b.rst").write_bytes(b"\xff\xfe not utf-8") - assert scan_rst_needs(str(tmp_path), directives={"comp"}) == [] - - -def test_scan_rst_needs_walks_recursively(tmp_path: Path) -> None: - _write(tmp_path / "a.rst", ".. comp:: A\n :id: comp__a\n") - _write(tmp_path / "sub" / "b.rst", ".. comp:: B\n :id: comp__b\n") - ids = {n["id"] for n in scan_rst_needs(str(tmp_path), directives={"comp"})} - assert ids == {"comp__a", "comp__b"} - - -# --------------------------------------------------------------------------- -# module_includes -# --------------------------------------------------------------------------- - - -def test_module_includes_parses_versions() -> None: - needs = [ - { - "directive": "mod", - "id": "mod__demo", - "includes": "comp__a, comp__b[version==3]", - }, - ] - result = module_includes(needs, "mod__demo") - assert result == {"comp__a": None, "comp__b": "3"} - - -def test_module_includes_returns_none_when_mod_not_found() -> None: - assert module_includes([], "mod__missing") is None - - -def test_module_includes_ignores_other_mod_needs() -> None: - needs = [ - {"directive": "mod", "id": "mod__other", "includes": "comp__x"}, - {"directive": "comp", "id": "mod__demo"}, - ] - assert module_includes(needs, "mod__demo") is None - - -def test_module_includes_empty_includes() -> None: - needs = [{"directive": "mod", "id": "mod__demo", "includes": ""}] - assert module_includes(needs, "mod__demo") == {} - - -# --------------------------------------------------------------------------- -# discover_components -# --------------------------------------------------------------------------- - - -def _env(needs: list[dict]) -> SimpleNamespace: - return SimpleNamespace(module_verification_report_needs=needs) - - -def test_discover_components_filters_by_whitelist_and_version() -> None: - env = _env( - [ - {"directive": "comp", "id": "comp__demo_a", "title": "A"}, - {"directive": "comp", "id": "comp__demo_b", "title": "B", - "version": "1"}, - {"directive": "comp", "id": "comp__demo_c", "title": "C"}, - ] - ) - result = discover_components( - env, - component_prefix="comp__demo_", - whitelist={"comp__demo_a": None, "comp__demo_b": "2"}, - ) - assert [c["id"] for c in result] == ["comp__demo_a"] - assert result[0]["slug"] == "a" - assert result[0]["title"] == "A" - - -def test_discover_components_sorted_by_id() -> None: - env = _env( - [ - {"directive": "comp", "id": "comp__demo_b", "title": "B"}, - {"directive": "comp", "id": "comp__demo_a", "title": "A"}, - ] - ) - result = discover_components( - env, - component_prefix="comp__demo_", - whitelist={"comp__demo_a": None, "comp__demo_b": None}, - ) - assert [c["id"] for c in result] == ["comp__demo_a", "comp__demo_b"] - - -def test_discover_components_prefix_mismatch_keeps_full_id_as_slug() -> None: - env = _env( - [{"directive": "comp", "id": "custom__x", "title": "X"}], - ) - result = discover_components( - env, - component_prefix="comp__demo_", - whitelist={"custom__x": None}, - ) - assert result[0]["slug"] == "custom__x" - - -def test_discover_components_falls_back_to_id_when_title_missing() -> None: - env = _env([{"directive": "comp", "id": "comp__demo_a", "title": ""}]) - result = discover_components( - env, - component_prefix="comp__demo_", - whitelist={"comp__demo_a": None}, - ) - assert result[0]["title"] == "comp__demo_a" - - -def test_discover_components_missing_attr_on_env() -> None: - # Env may not have the attr yet if the read-hook did not run. - assert discover_components(SimpleNamespace(), "comp__x_", {"a": None}) == [] - - -# --------------------------------------------------------------------------- -# scan_source_tree -# --------------------------------------------------------------------------- - - -def test_scan_source_tree_populates_env(tmp_path: Path) -> None: - _write(tmp_path / "a.rst", ".. mod:: M\n :id: mod__demo\n") - env = SimpleNamespace(srcdir=str(tmp_path)) - scan_source_tree(app=None, env=env, docnames=None) - assert [n["id"] for n in env.module_verification_report_needs] == [ - "mod__demo" - ] From f297ba49eaa5609c799eeb799a56f31fa2dbfdb3 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Thu, 20 Aug 2026 09:04:37 +0000 Subject: [PATCH 08/25] feat(module-verification-report): validate component links at build-finished Add consistency_checks.py with a build-finished hook that warns when: - a component listed in :components: is not in the module need's :includes: - the feature ID is not in the component need's :belongs_to: The check is entirely passive (Sphinx warnings, no build failure) and requires no configuration in consumer repos. The registry is populated by the directive during the read phase and is parallel-read safe via init_registry / purge_registry / merge_registry lifecycle hooks. 17 new unit tests (75 total). Version bumped to 0.9. --- .../docs/module_verification_report.rst | 460 ++---------------- .../__init__.py | 19 +- .../consistency_checks.py | 155 ++++++ .../coverage.py | 11 +- .../directive.py | 71 +-- .../rendering.py | 12 +- .../templates.py | 43 +- .../testcase_annotations.py | 6 +- .../tests/test_consistency_checks.py | 295 +++++++++++ .../tests/test_coverage.py | 5 +- .../tests/test_directive.py | 90 +--- .../tests/test_rendering.py | 4 +- .../tests/test_testcase_annotations.py | 5 +- 13 files changed, 575 insertions(+), 601 deletions(-) create mode 100644 src/extensions/score_module_verification_report/consistency_checks.py create mode 100644 src/extensions/score_module_verification_report/tests/test_consistency_checks.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index c6506c4c0..16f4ec22c 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -17,454 +17,54 @@ Module Verification Report extension ==================================== -``score_module_verification_report`` provides a single Sphinx directive, -``.. module-verification-report::``, that expands into the standard -per-module verification report body: one feature-level section, one -component overview table, and one detailed section per component. The -extension does **not** re-implement any traceability logic — every -attribute, link and status shown in the report is resolved by -sphinx-needs at render time from ``.. needtable::`` / ``.. needpie::`` -widgets that the directive emits. +``score_module_verification_report`` provides the +``.. module-verification-report::`` directive, which expands into the +standard per-module verification report: a feature summary, a component +overview table, and one detailed section per component. Traceability is +resolved by sphinx-needs at render time — the directive only emits +``.. needtable::`` / ``.. needpie::`` widgets with the right filters. + +The extension is part of the :ref:`score_sphinx_bundle<extensions>`. +No external config file is required for the common case. -Typical use is in a module's ``verification_report/module_verification_report.rst``: +Typical usage (``verification_report/module_verification_report.rst``): .. code-block:: rst - Auto-generated Report - --------------------- - .. module-verification-report:: :module-id: mod__mymodule - :feature-id: feat__mymodule :components: comp__mymodule_a, comp__mymodule_b -No external config file is required for the common case. - -The extension is shipped as part of the -:ref:`score_sphinx_bundle<extensions>`; consumers only need to add -``score_docs_as_code`` and reference the directive. - -At a glance ------------ - -.. list-table:: - :header-rows: 1 - :widths: 20 80 - - * - Aspect - - Contract - - * - Directive - - ``.. module-verification-report::`` — no arguments, no content; - ``:module-id:`` and ``:components:`` cover the common case. - - * - Key options - - ``:module-id:``, ``:feature-id:``, ``:component-prefix:``, - ``:components:`` (comma-separated id list); - ``:config:`` YAML for non-default workproducts / overrides only. - - * - No filesystem scan - - Component ids are provided directly via ``:components:`` — - the extension does not scan ``.rst`` files at build time. - - * - Reads from JSON - - ``<srcdir>/reporting/coverage_summary.json`` (optional). - - * - Delegates to sphinx-needs - - Status, safety, security, requirement / architecture element - tables, pie charts, "Realized by" cells. - - * - Parallel-read safe - - Yes (``parallel_read_safe = True``). - .. _mvr_directive: -Directive reference -------------------- - -.. code-block:: rst - - .. module-verification-report:: - :module-id: mod__mymodule - :feature-id: feat__mymodule # optional — derived from module-id - :component-prefix: comp__my_ # optional — derived from module-id - :components: comp__mymodule_a, - comp__mymodule_b - :config: path/to/overrides.yaml # optional — WP overrides / custom WPs - -**Arguments** - None. - -**Content** - None (``has_content = False``). - -**Options** +Options +------- .. list-table:: :header-rows: 1 - :widths: 20 80 + :widths: 22 12 66 * - Option - - Meaning + - Required + - Description * - ``:module-id:`` - - The sphinx-needs id of the ``.. mod::`` need that owns this - report. Used to derive ``feature-id`` and ``component-prefix`` - defaults. **Takes precedence** over the same field in ``:config:``. - - * - ``:feature-id:`` - - The sphinx-needs id of the ``.. feat::`` need for the feature - section. Defaults to ``feat__<module-short>`` (derived from - ``:module-id:`` by stripping the ``mod__`` prefix). - **Takes precedence** over ``:config:``. - - * - ``:component-prefix:`` - - Prefix stripped from each component id to produce its short slug - (used for anchors and document-id substring matching). Defaults - to ``comp__<module-short>_``. **Takes precedence** over - ``:config:``. + - yes + - sphinx-needs id of the ``.. mod::`` need (e.g. ``mod__mymodule``). + Drives defaults for ``:feature-id:`` and ``:component-prefix:``. * - ``:components:`` - - Comma-separated list of ``.. comp::`` need ids to include in the - report. Optional ``[version==N]`` qualifiers are stripped - silently. Multi-line values work (continuation lines are joined - by docutils with a space before splitting on commas). This option - replaces the old filesystem scan entirely. - - * - ``:config:`` - - Optional path to a YAML config file, resolved relative to - ``srcdir``. Only needed for non-default workproduct lists or - per-component doc-id overrides. ``module_id`` / ``feature_id`` / - ``component_prefix`` in the file are ignored when the - corresponding directive option is set. - -**Errors** (fatal — the directive returns an ``error`` node): - -* ``no components specified`` — ``:components:`` was omitted or empty. - -**Warnings** (non-fatal): - -* ``config not found: <abs path>`` — the ``:config:`` path does not - exist; the report falls back to defaults and will almost certainly - fail the ``mod`` lookup. - -.. _mvr_config: - -Config file schema (advanced) ------------------------------- - -A ``:config:`` YAML file is only needed when the default workproducts do not -match or when specific components use non-standard document-id naming. -Fields that duplicate directive options (``module_id``, ``feature_id``, -``component_prefix``) are ignored when the corresponding RST option is set. - -.. code-block:: yaml - - # Ignored if :module-id: is set on the directive. - module_id: mod__<module> - - # Ignored if :component-prefix: is set. Default: ``comp__<module>_``. - component_prefix: comp__<module>_ - - # Ignored if :feature-id: is set. Default: ``feat__<module>``. - feature_id: feat__<module> - - # Optional. Override the default five standard SCORE workproducts. - workproducts: - - key: <stable_short_key> # only used for override lookup - label: <human-readable label> # shown in the "Kind" column - wp_id: wp__<work_product> # sphinx-needs id - - # Optional. Feature-level work products. Default: Requirements - # Inspection + Architecture Inspection only. - feature_workproducts: - - key: ... - label: ... - wp_id: ... - - # Optional. Per-need overrides for the "Realized by" / "Status" - # cells. Keyed by the ``.. comp::`` need id, or by the ``feat__`` - # need id for feature-level overrides. - overrides: - comp__<module>_<slug>: - workproducts: - # For each row you want to pin explicitly, map the WP ``key`` - # (see above) to the concrete document need id. - requirements_inspect: doc__<something>_req_inspection - sw_arch_verification: doc__<something>_arc_inspection - feat__<module>: - workproducts: - requirements_inspect: doc__<feature>_req_inspection - -.. _mvr_data_model: - -Data model expectations ------------------------ - -The extension looks up its subject through a chain of sphinx-needs -directives. Every id follows the ``<kind>__<slug>`` convention and every -link uses the field the sphinx-needs data model already defines. - -.. list-table:: Needs the extension reads or filters by - :header-rows: 1 - :widths: 15 20 65 - - * - Directive - - Where it lives - - How the extension uses it - - * - ``.. mod::`` - - ``docs/module/index.rst`` (or wherever the module chooses) - - Filesystem scan; ``:id:`` must equal ``module_id`` from the - config. ``:includes:`` is a comma-separated list of the form - ``comp__<slug>`` or ``comp__<slug>[version==<N>]`` — the - whitelist of components rendered by the report. - - * - ``.. comp::`` - - Under any component's ``docs/`` folder - - Filesystem scan; must appear in ``:includes:``. Its ``:id:`` - drives all component-scoped ``.. needtable::`` / - ``.. needpie::`` filters (``"<comp_id>" in satisfied_by``, ``"<comp_id>" in belongs_to``). - If ``[version==N]`` was requested in ``:includes:``, the - ``.. comp::``'s ``:version:`` must match. - - * - ``.. feat::`` - - The module's feature documentation - - Not scanned; resolved at render time by sphinx-needs. The - "Feature" summary uses ``id == "<feature_id>"``; feature - statistics filter ``feat_req`` / ``feat_arc_*`` by - ``"<feature_id>" in belongs_to``. - - * - ``.. wp::`` - - Process repo (external) - - Never scanned; the ``:need:\`wp__...\``` links assume the - ``wp__*`` needs are registered on the external needs source. - - * - ``.. document::`` - - Anywhere; typically the ``verification_report/`` folders - - Not scanned; resolved by sphinx-needs. Each "Realized by" cell - is a ``.. needtable::`` filtered by - ``type == "document" and "<slug_norm>" in id.replace("_", "") - and "<wp_id>" in realizes``. - - * - ``.. comp_req::``, ``.. comp_arc_sta::``, ``.. comp_arc_dyn::`` - - Under the component - - Not scanned; sphinx-needs handles the "Requirements Statistics" - and "Architecture Statistics" pies and tables via filters on - ``satisfied_by`` / ``belongs_to``. + - yes + - Comma-separated list of ``.. comp::`` need ids. Multi-line values + are supported. Optional ``[version==N]`` qualifiers are stripped. - * - ``.. feat_req::``, ``.. feat_arc_sta::``, ``.. feat_arc_dyn::`` - - Under the feature - - Same as above at the feature level. - -.. _mvr_scan: - -No filesystem scan ------------------- - -Component ids are supplied directly via ``:components:`` — the extension -does **not** scan ``.rst`` files at build time. The component titles shown -in section headings and pie-chart labels are derived from the id slug -(``comp__mymod_bit_manipulation`` → slug ``bit_manipulation`` → title -``Bit Manipulation``). - -The previous design used a shallow regex scan of ``srcdir`` at -``env-before-read-docs`` time to resolve the ``:includes:`` list of the -``.. mod::`` need. Querying ``SphinxNeedsData`` was impossible at that -point (it forces ``parallel_read_safe = False``), so a regex scan was used -instead. With ids provided directly, neither scan nor -``SphinxNeedsData`` queries are needed, and ``parallel_read_safe = True`` -is trivially guaranteed. - -.. _mvr_output: - -What the report renders ------------------------ - -Given a valid config the directive emits, in order: - -1. **CSS block** — inline ``<style>`` scoped to the ``wp-doc-table`` - class, hiding the internal chrome (headers, datatables toolbar) of - the nested ``.. needtable::`` widgets used in the WP tables. - -2. **Feature** section — ``.. needtable::`` filtered by - ``id == "<feature_id>"``; then Requirements Statistics - (``needpie`` for status + verification coverage, plus a - ``needtable`` in a dropdown), Architecture Statistics (status + - inspection pie, plus a ``needtable``), and Inspection Statistics (a - work-product presence table). - -3. **Components** section — an ``H2`` heading followed by: - - a. **Component Overview** — ``.. needtable::`` filtered by - ``id in [ ... ]`` over the whitelisted component ids, showing - safety / security / status columns. - - b. One **per-component section** with, in order: - - * Requirements Statistics (pies + traceability table); - * Architecture Statistics (pies + inspection table); - * a **Verification & Safety Analysis Documents** work-product - table. - -The Unit Test Coverage section (`_COMPONENT_COVERAGE_SECTION_DISABLED` -in :mod:`.templates`) is currently disabled at the template level. -Re-enable by appending the fragment to ``COMPONENT_TEMPLATE`` and -passing ``coverage_intro=coverage_intro(comp, coverage_data)`` from -:func:`.rendering.render_component`. - -.. _mvr_wp_rows: - -Work-product row rendering --------------------------- - -Each row of a "Verification & Safety Analysis Documents" (or feature -"Inspection Statistics") table has four columns: **Work Product**, -**Kind**, **Realized by**, **Status**. The first two are plain text -(``:need:`` link and the WP label from the config). The last two are -resolved either by an explicit override or by sphinx-needs: - -* **Override** — when the config sets - ``overrides[<need_id>].workproducts[<wp_key>] = <doc_id>``, the - row renders - - .. code-block:: rst - - - :need:`<doc_id>` - - :ndf:`copy('status', need_id='<doc_id>')` - -* **Filter** (default) — both cells emit an identical inner - ``.. needtable::`` with the filter - - .. code-block:: text - - type == "document" - and "<slug_norm>" in id.replace("_", "") - and "<wp_id>" in realizes - - where ``<slug_norm>`` is the component (or feature) slug with all - underscores removed and lower-cased (see - :func:`.rendering.normalize_slug`). This makes ``bit_manipulation`` - and ``bitmanipulation`` compare equal without per-component config. - -If neither an override nor a matching document is present, the cells -are empty. - -.. _mvr_coverage: - -Coverage summary (optional) ---------------------------- - -The extension reads ``<srcdir>/reporting/coverage_summary.json`` — the -output of ``tools/extract_coverage.py`` — via -:func:`.coverage.load_coverage_summary`. Its top-level keys are -component slugs; each value has at least the fields ``lines_pct``, -``functions_pct``, ``branches_pct``. - -The JSON is only used by the currently disabled Unit Test Coverage -section (see above). When that section is re-enabled, -:func:`.coverage.coverage_intro` decides between the "measured" and -the "specification-only" intro paragraph based on whether the component -slug has at least one non-null ``*_pct`` field. - -Missing / malformed JSON is not an error: the loader returns ``{}``. - -.. _mvr_architecture: - -Extension architecture ----------------------- - -Implementation is split across five modules under -``src/extensions/score_module_verification_report/`` so that each layer -can be tested independently: - -.. list-table:: - :header-rows: 1 - :widths: 20 80 - - * - Module - - Responsibility - - * - :mod:`.coverage` - - ``coverage_summary.json`` loading and intro-paragraph selection. - - * - :mod:`.templates` - - All RST template strings, the scoped WP-table CSS, and the - default (component / feature) workproduct lists. - - * - :mod:`.rendering` - - Pure functions that expand the templates: - ``render_component``, ``render_feature``, ``render_overview``, - ``render_report``, and the shared ``workproduct_rows`` helper. - - * - :mod:`.directive` - - The ``ModuleVerificationReportDirective`` class. Parses the - ``:components:`` option via ``_parse_components``, loads the - optional YAML config, calls the renderer, and - ``nested_parse_with_titles`` the resulting RST into the - document. Registers its docname in - ``env.module_verification_report_docnames`` so the annotation - hook knows which pages to touch. - - * - :mod:`.testcase_annotations` - - ``doctree-resolved`` handler that appends a coloured - ``(passed)`` / ``(failed)`` / ``(skipped)`` / ``(disabled)`` - badge to every ``testcase__…`` back-link on pages that rendered - the directive. Sources the status from each testcase need's - ``result`` field via ``sphinx_needs.data.SphinxNeedsData``. The - hook is a no-op on pages the directive did not touch, when - sphinx-needs is not initialised, or when a testcase need has an - empty ``result``. Colour palette matches the pie-chart palette - used by the report body. - - * - ``__init__.py`` - - Thin entry point exposing ``setup(app)``. - -The public surface is intentionally minimal: - -* the directive ``module-verification-report`` (added in ``setup``); -* the cached attribute ``env.module_verification_report_docnames``. - -All other functions are considered internal and covered by unit tests -under ``tests/``. - -.. _mvr_limitations: - -Known limitations ------------------ - -* **Feature-only modules** — the feature section is always rendered; if - the ``feat__<module>`` need does not exist, sphinx-needs will produce - an empty table rather than an error. -* **Component title derivation** — section headings and pie-chart labels - are derived from the slug (underscores → spaces, title-case). For - acronym-heavy names like ``safecpp`` the result is ``Safecpp`` rather - than ``SafeCpp``; use ``:component-prefix:`` to control slug length - if needed. -* **Nested tables render inside cells** — the ``.. needtable::`` widgets - used in the WP rows are hidden by the scoped CSS block, but their - DataTables initialisation still runs. Very large modules may see a - measurable per-cell cost. - -Testing -------- - -Unit tests live under -``src/extensions/score_module_verification_report/tests/`` and are -grouped by module: - -* ``test_directive.py`` — ``_parse_components`` (id parsing, version - stripping, title derivation, empty/whitespace input) and option / - config precedence rules. -* ``test_coverage.py`` — JSON loading edge cases and the - measured / spec-only intro decision. -* ``test_rendering.py`` — slug utilities, override vs. filter row - rendering, and end-to-end ``render_report`` assembly. -* ``test_testcase_annotations.py`` — the ``env-before-read-docs`` / - ``env-purge-doc`` / ``env-merge-info`` lifecycle handlers plus every - branch of ``annotate_testcase_results`` (colours, unknown result, - every no-op guard). - -Run them with the standard target:: + * - ``:feature-id:`` + - no + - sphinx-needs id of the ``.. feat::`` need. Default: + ``feat__<module-short>`` (derived from ``:module-id:``). - bazel test //src/extensions/score_module_verification_report:score_module_verification_report_tests + * - ``:component-prefix:`` + - no + - Prefix stripped from each component id to derive its slug (used + for section headings and document-id matching). Default: + ``comp__<module-short>_``. diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 2da031631..41f8aa59a 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -33,11 +33,20 @@ * :mod:`.directive` — the ``ModuleVerificationReportDirective`` class * :mod:`.testcase_annotations` — ``doctree-resolved`` badge decoration for ``testcase__…`` back-links on pages that render the directive +* :mod:`.consistency_checks` — ``build-finished`` validation that every + component is properly linked in the needs graph """ + from __future__ import annotations from typing import Any +from .consistency_checks import ( + check_consistency, + init_registry, + merge_registry, + purge_registry, +) from .directive import ModuleVerificationReportDirective from .testcase_annotations import ( annotate_testcase_results, @@ -48,15 +57,17 @@ def setup(app: Any) -> dict: - app.add_directive( - "module-verification-report", ModuleVerificationReportDirective - ) + app.add_directive("module-verification-report", ModuleVerificationReportDirective) app.connect("env-before-read-docs", init_docnames) + app.connect("env-before-read-docs", init_registry) app.connect("env-purge-doc", purge_docname) + app.connect("env-purge-doc", purge_registry) app.connect("env-merge-info", merge_docnames) + app.connect("env-merge-info", merge_registry) app.connect("doctree-resolved", annotate_testcase_results) + app.connect("build-finished", check_consistency) return { - "version": "0.8", + "version": "0.9", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/src/extensions/score_module_verification_report/consistency_checks.py b/src/extensions/score_module_verification_report/consistency_checks.py new file mode 100644 index 000000000..535284320 --- /dev/null +++ b/src/extensions/score_module_verification_report/consistency_checks.py @@ -0,0 +1,155 @@ +# ******************************************************************************* +# 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-finished check: validate that each component listed in +``:components:`` is properly linked in the sphinx-needs graph. + +Two rules are enforced for every ``.. module-verification-report::`` +directive instance: + +1. ``comp_id ∈ mod_need["includes"]`` + — the module need must explicitly include the component. +2. ``feature_id ∈ comp_need["belongs_to"]`` + — the component need must declare that it belongs to the feature. + +Violations are reported as Sphinx warnings so they surface in CI logs +without stopping the build. + +The registry (``env.module_verification_report_registry``) is populated by +:class:`~.directive.ModuleVerificationReportDirective` during the read phase +and is parallel-read safe via ``init_registry`` / ``purge_registry`` / +``merge_registry`` lifecycle hooks. +""" + +from __future__ import annotations + +from typing import Any + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +def _needs_view(env: Any) -> Any | None: + """Return sphinx-needs view or *None* when sphinx-needs is not loaded.""" + try: + from sphinx_needs.data import SphinxNeedsData # type: ignore[import-untyped] + + return SphinxNeedsData(env).get_needs_view() + except Exception: # pragma: no cover — only absent in test env + return None + + +# --------------------------------------------------------------------------- +# Lifecycle hooks (parallel-read safe) +# --------------------------------------------------------------------------- + + +def init_registry(app: Any, env: Any, docnames: Any) -> None: + """Create the registry dict on the env if it does not exist yet.""" + if not hasattr(env, "module_verification_report_registry"): + env.module_verification_report_registry = {} # type: ignore[attr-defined] + + +def purge_registry(app: Any, env: Any, docname: str) -> None: + """Remove registry entries that were produced by *docname*.""" + registry: dict = getattr(env, "module_verification_report_registry", {}) + stale = [k for k, v in registry.items() if v.get("docname") == docname] + for k in stale: + del registry[k] + + +def merge_registry(app: Any, env: Any, docnames: Any, other: Any) -> None: + """Merge the sub-build registry from *other* into *env*.""" + if not hasattr(env, "module_verification_report_registry"): + env.module_verification_report_registry = {} # type: ignore[attr-defined] + other_registry: dict = getattr(other, "module_verification_report_registry", {}) + env_reg = env.module_verification_report_registry # type: ignore[attr-defined] + env_reg.update(other_registry) + + +# --------------------------------------------------------------------------- +# Build-finished consistency check +# --------------------------------------------------------------------------- + + +def _check_module(app: Any, needs: Any, module_id: str, info: dict) -> None: + """Check one module's component links and emit warnings for violations.""" + feature_id: str = info["feature_id"] + comp_ids: list[str] = info["comp_ids"] + docname: str = info.get("docname", "?") + mod_need = needs.get(module_id) + + if mod_need is None: + logger.warning( + "[module-verification-report] %s: " + "%s (:module-id:) not found in sphinx-needs — " + "check the id", + docname, + module_id, + ) + + for comp_id in comp_ids: + comp_need = needs.get(comp_id) + + if comp_need is None: + logger.warning( + "[module-verification-report] %s: " + "%s (listed in :components:) not found in " + "sphinx-needs — check the id", + docname, + comp_id, + ) + continue + + # Rule 1: component must be in the module's :includes: + if mod_need is not None: # noqa: SIM102 + if comp_id not in mod_need.get("includes", []): + logger.warning( + "[module-verification-report] %s: " + "%s is listed in :components: but not in " + "%s :includes:", + docname, + comp_id, + module_id, + ) + + # Rule 2: feature must be in the component's :belongs_to: + if feature_id not in comp_need.get("belongs_to", []): + logger.warning( + "[module-verification-report] %s: " + "%s is not in %s :belongs_to: " + "(component listed via :components: of %s)", + docname, + feature_id, + comp_id, + module_id, + ) + + +def check_consistency(app: Any, exception: Any) -> None: + """Emit warnings for components that are missing required need links. + + Skipped entirely when the build already failed (*exception* is not None) + or when sphinx-needs is unavailable (e.g. unit-test environment). + """ + if exception: + return + registry: dict = getattr(app.env, "module_verification_report_registry", {}) + if not registry: + return + needs = _needs_view(app.env) + if needs is None: + return + + for module_id, info in registry.items(): + _check_module(app, needs, module_id, info) diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py index 830b5ea2a..e0fbba096 100644 --- a/src/extensions/score_module_verification_report/coverage.py +++ b/src/extensions/score_module_verification_report/coverage.py @@ -18,16 +18,16 @@ with real metric values marks that component as *measured*; absence marks it as *specification-only*. """ + from __future__ import annotations import json import os - COVERAGE_INTRO_MEASURED = ( "Aggregated from ``bazel coverage``. Regenerate via\n" - "``python3 tools/extract_coverage.py \"$(bazel info output_path)" - "/_coverage/_coverage_report.dat\" docs/reporting/coverage_summary.json``.\n" + '``python3 tools/extract_coverage.py "$(bazel info output_path)' + '/_coverage/_coverage_report.dat" docs/reporting/coverage_summary.json``.\n' ) COVERAGE_INTRO_SPEC_ONLY = ( "This component is specification-only and has no dedicated unit\n" @@ -41,7 +41,7 @@ def load_coverage_summary(env) -> dict: """Return the parsed ``coverage_summary.json`` (empty dict on failure).""" path = os.path.join(env.srcdir, COVERAGE_SUMMARY_REL_PATH) try: - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): return {} @@ -61,7 +61,6 @@ def coverage_intro(comp: dict, coverage_data: dict) -> str: """ entry = coverage_data.get(comp["slug"]) or {} measured = any( - entry.get(f"{m}_pct") is not None - for m in ("lines", "functions", "branches") + entry.get(f"{m}_pct") is not None for m in ("lines", "functions", "branches") ) return (COVERAGE_INTRO_MEASURED if measured else COVERAGE_INTRO_SPEC_ONLY) + "\n" diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 16b8ba32d..7e2fd5286 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -11,12 +11,11 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """The ``.. module-verification-report::`` Sphinx directive.""" + from __future__ import annotations -import os import re -import yaml from docutils import nodes from docutils.statemachine import ViewList from sphinx.util.docutils import SphinxDirective @@ -46,7 +45,7 @@ def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: if not comp_id: continue slug = ( - comp_id[len(component_prefix):] + comp_id[len(component_prefix) :] if component_prefix and comp_id.startswith(component_prefix) else comp_id ) @@ -58,15 +57,14 @@ def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: class ModuleVerificationReportDirective(SphinxDirective): """Expand to the per-module verification report body. - Minimal usage — no external config file required:: + Minimal usage:: .. module-verification-report:: :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b ``feature-id`` and ``component-prefix`` are optional and derived from - ``module-id`` when omitted. ``config`` is only needed for non-default - workproducts or per-component doc-id overrides. + ``module-id`` when omitted. """ required_arguments = 0 @@ -76,57 +74,24 @@ class ModuleVerificationReportDirective(SphinxDirective): "feature-id": str, "component-prefix": str, "components": str, - "config": str, } has_content = False - def _load_config(self, rel_config: str | None) -> dict: - if not rel_config: - return {} - srcdir = self.env.srcdir - config_path = os.path.join(srcdir, rel_config) - if not os.path.isfile(config_path): - self.state_machine.reporter.warning( - f"module-verification-report: config not found: {config_path}", - line=self.lineno, - ) - return {} - with open(config_path, "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) or {} - self.env.note_dependency(config_path) - return data - def run(self) -> list[nodes.Node]: - # Directive options take precedence over config file values so that - # the common case needs no YAML file at all. - config = self._load_config(self.options.get("config")) - - module_id = self.options.get("module-id") or config.get("module_id", "") + module_id = self.options.get("module-id", "") module_short = ( - module_id[len("mod__"):] - if module_id.startswith("mod__") - else module_id - ) - component_prefix = ( - self.options.get("component-prefix") - or config.get("component_prefix") - or ("comp__" + module_short + "_" if module_short else "comp__") + module_id[len("mod__") :] if module_id.startswith("mod__") else module_id ) - feature_id = ( - self.options.get("feature-id") - or config.get("feature_id") - or f"feat__{module_short}" + component_prefix = self.options.get("component-prefix") or ( + "comp__" + module_short + "_" if module_short else "comp__" ) + feature_id = self.options.get("feature-id") or f"feat__{module_short}" feature_slug = ( - feature_id.split("__", 1)[1] - if "__" in feature_id - else feature_id + feature_id.split("__", 1)[1] if "__" in feature_id else feature_id ) - workproducts = config.get("workproducts") or DEFAULT_WORKPRODUCTS - feature_workproducts = ( - config.get("feature_workproducts") or DEFAULT_FEATURE_WORKPRODUCTS - ) - overrides_by_id: dict[str, dict] = config.get("overrides") or {} + workproducts = DEFAULT_WORKPRODUCTS + feature_workproducts = DEFAULT_FEATURE_WORKPRODUCTS + overrides_by_id: dict[str, dict] = {} components_str = self.options.get("components", "") components = _parse_components(components_str, component_prefix) @@ -170,4 +135,14 @@ def run(self) -> list[nodes.Node]: self.env.module_verification_report_docnames = set() # type: ignore[attr-defined] self.env.module_verification_report_docnames.add(self.env.docname) # type: ignore[attr-defined] + # Register module/feature/component metadata so the build-finished + # consistency check can validate need links without a pre-scan. + if not hasattr(self.env, "module_verification_report_registry"): + self.env.module_verification_report_registry = {} # type: ignore[attr-defined] + self.env.module_verification_report_registry[module_id] = { # type: ignore[attr-defined] + "docname": self.env.docname, + "feature_id": feature_id, + "comp_ids": [c["id"] for c in components], + } + return container.children diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 355343767..c96604e95 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """Rendering functions that expand :mod:`.templates` for the report body.""" + from __future__ import annotations import re @@ -69,15 +70,12 @@ def workproduct_rows( lines.append(f" - {wp['label']}") if override_doc: lines.append(f" - :need:`{override_doc}`") - lines.append( - f" - :ndf:`copy('status', " - f"need_id='{override_doc}')`" - ) + lines.append(f" - :ndf:`copy('status', need_id='{override_doc}')`") else: filter_expr = ( - f"type == \"document\" and " - f"\"{slug_norm}\" in id.replace(\"_\", \"\") and " - f"\"{wp['wp_id']}\" in realizes" + f'type == "document" and ' + f'"{slug_norm}" in id.replace("_", "") and ' + f'"{wp["wp_id"]}" in realizes' ) lines.append(" - .. needtable::") lines.append(f" :filter: {filter_expr}") diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index 210c3be91..cf680b2da 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -11,8 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """RST templates for the module verification report.""" -from __future__ import annotations +from __future__ import annotations COMPONENT_TEMPLATE = """ .. _{ref}: @@ -315,22 +315,35 @@ DEFAULT_WORKPRODUCTS = [ - {"key": "requirements_inspect", "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect"}, - {"key": "sw_arch_verification", "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification"}, - {"key": "sw_implementation_inspection", "label": "Implementation Inspection", - "wp_id": "wp__sw_implementation_inspection"}, - {"key": "sw_component_dfa", "label": "DFA", - "wp_id": "wp__sw_component_dfa"}, - {"key": "sw_component_fmea", "label": "FMEA", - "wp_id": "wp__sw_component_fmea"}, + { + "key": "requirements_inspect", + "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect", + }, + { + "key": "sw_arch_verification", + "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification", + }, + { + "key": "sw_implementation_inspection", + "label": "Implementation Inspection", + "wp_id": "wp__sw_implementation_inspection", + }, + {"key": "sw_component_dfa", "label": "DFA", "wp_id": "wp__sw_component_dfa"}, + {"key": "sw_component_fmea", "label": "FMEA", "wp_id": "wp__sw_component_fmea"}, ] DEFAULT_FEATURE_WORKPRODUCTS = [ - {"key": "requirements_inspect", "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect"}, - {"key": "sw_arch_verification", "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification"}, + { + "key": "requirements_inspect", + "label": "Requirements Inspection", + "wp_id": "wp__requirements_inspect", + }, + { + "key": "sw_arch_verification", + "label": "Architecture Inspection", + "wp_id": "wp__sw_arch_verification", + }, ] diff --git a/src/extensions/score_module_verification_report/testcase_annotations.py b/src/extensions/score_module_verification_report/testcase_annotations.py index b3b337ea7..5839f8a41 100644 --- a/src/extensions/score_module_verification_report/testcase_annotations.py +++ b/src/extensions/score_module_verification_report/testcase_annotations.py @@ -20,6 +20,7 @@ ``env.module_verification_report_docnames`` so unrelated pages are left untouched. """ + from __future__ import annotations from typing import Any @@ -77,10 +78,7 @@ def annotate_testcase_results(app, doctree, docname): if not result: continue color = RESULT_COLORS.get(result, _FALLBACK_COLOR) - status_html = ( - f'<span style="color:{color};font-weight:bold">' - f" ({result})</span>" - ) + status_html = f'<span style="color:{color};font-weight:bold"> ({result})</span>' # Keep the id text, append the coloured status inline. ref.replace(first, nodes.Text(text)) ref.append(nodes.raw("", status_html, format="html")) diff --git a/src/extensions/score_module_verification_report/tests/test_consistency_checks.py b/src/extensions/score_module_verification_report/tests/test_consistency_checks.py new file mode 100644 index 000000000..d425c7b54 --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_consistency_checks.py @@ -0,0 +1,295 @@ +# ******************************************************************************* +# 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 tests for :mod:`score_module_verification_report.consistency_checks`.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from src.extensions.score_module_verification_report.consistency_checks import ( + check_consistency, + init_registry, + merge_registry, + purge_registry, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_env(registry: dict | None = None) -> MagicMock: + env = MagicMock() + if registry is not None: + env.module_verification_report_registry = registry + else: + del env.module_verification_report_registry + # hasattr returns False for deleted attributes on MagicMock + type(env).__contains__ = MagicMock(return_value=False) + return env + + +def _make_app(registry: dict | None = None) -> MagicMock: + app = MagicMock() + app.env = MagicMock() + if registry is not None: + app.env.module_verification_report_registry = registry + else: + # Remove attribute so getattr returns default + if hasattr(app.env, "module_verification_report_registry"): + del app.env.module_verification_report_registry + return app + + +def _registry_entry( + module_id: str = "mod__m", + feature_id: str = "feat__m", + comp_ids: list[str] | None = None, + docname: str = "reporting/index", +) -> dict: + return { + "docname": docname, + "feature_id": feature_id, + "comp_ids": comp_ids or ["comp__m_a"], + } + + +# --------------------------------------------------------------------------- +# Lifecycle: init_registry +# --------------------------------------------------------------------------- + + +def test_init_registry_creates_dict_when_absent() -> None: + env = MagicMock(spec=[]) # no attributes at all + init_registry(None, env, []) + assert env.module_verification_report_registry == {} + + +def test_init_registry_keeps_existing_dict() -> None: + env = MagicMock(spec=["module_verification_report_registry"]) + env.module_verification_report_registry = {"mod__m": {"docname": "x"}} + init_registry(None, env, []) + assert "mod__m" in env.module_verification_report_registry + + +# --------------------------------------------------------------------------- +# Lifecycle: purge_registry +# --------------------------------------------------------------------------- + + +def test_purge_registry_removes_matching_docname() -> None: + env = MagicMock() + env.module_verification_report_registry = { + "mod__m": _registry_entry(docname="docs/report"), + "mod__other": _registry_entry(module_id="mod__other", docname="other/report"), + } + purge_registry(None, env, "docs/report") + assert "mod__m" not in env.module_verification_report_registry + assert "mod__other" in env.module_verification_report_registry + + +def test_purge_registry_noop_when_registry_missing() -> None: + env = MagicMock(spec=[]) + purge_registry(None, env, "any/docname") # must not raise + + +def test_purge_registry_noop_when_docname_unknown() -> None: + env = MagicMock() + env.module_verification_report_registry = {"mod__m": _registry_entry(docname="x")} + purge_registry(None, env, "not_there") + assert "mod__m" in env.module_verification_report_registry + + +# --------------------------------------------------------------------------- +# Lifecycle: merge_registry +# --------------------------------------------------------------------------- + + +def test_merge_registry_copies_other_entries() -> None: + env = MagicMock(spec=["module_verification_report_registry"]) + env.module_verification_report_registry = {} + other = MagicMock() + other.module_verification_report_registry = {"mod__m": _registry_entry()} + merge_registry(None, env, [], other) + assert "mod__m" in env.module_verification_report_registry + + +def test_merge_registry_creates_dict_when_env_missing() -> None: + env = MagicMock(spec=[]) + other = MagicMock(spec=[]) # other also has no registry + merge_registry(None, env, [], other) + assert env.module_verification_report_registry == {} + + +# --------------------------------------------------------------------------- +# check_consistency — early-exit paths +# --------------------------------------------------------------------------- + + +def test_noop_when_exception_set() -> None: + app = MagicMock() + with patch( + "src.extensions.score_module_verification_report.consistency_checks.logger" + ) as mock_logger: + check_consistency(app, exception=RuntimeError("boom")) + mock_logger.warning.assert_not_called() + + +def test_noop_when_registry_empty() -> None: + app = MagicMock() + app.env.module_verification_report_registry = {} + with patch( + "src.extensions.score_module_verification_report.consistency_checks.logger" + ) as mock_logger: + check_consistency(app, exception=None) + mock_logger.warning.assert_not_called() + + +def test_noop_when_registry_missing() -> None: + app = MagicMock() + app.env = MagicMock(spec=[]) # no registry attribute + with patch( + "src.extensions.score_module_verification_report.consistency_checks.logger" + ) as mock_logger: + check_consistency(app, exception=None) + mock_logger.warning.assert_not_called() + + +def test_noop_when_needs_unavailable() -> None: + app = MagicMock() + app.env.module_verification_report_registry = {"mod__m": _registry_entry()} + with ( + patch( + "src.extensions.score_module_verification_report.consistency_checks._needs_view", + return_value=None, + ), + patch( + "src.extensions.score_module_verification_report.consistency_checks.logger" + ) as mock_logger, + ): + check_consistency(app, exception=None) + mock_logger.warning.assert_not_called() + + +# --------------------------------------------------------------------------- +# check_consistency — warning cases +# --------------------------------------------------------------------------- + + +def _run_check( + registry: dict, + needs: dict, +) -> list[str]: + """Run check_consistency and return list of warning messages.""" + app = MagicMock() + app.env.module_verification_report_registry = registry + warnings: list[str] = [] + + def _capture(*args: object) -> None: + # logger.warning(fmt, *args) — interpolate for easy assertion + fmt = str(args[0]) if args else "" + warnings.append(fmt % args[1:] if len(args) > 1 else fmt) + + with ( + patch( + "src.extensions.score_module_verification_report.consistency_checks._needs_view", + return_value=needs, + ), + patch( + "src.extensions.score_module_verification_report.consistency_checks.logger" + ) as mock_logger, + ): + mock_logger.warning.side_effect = _capture + check_consistency(app, exception=None) + return warnings + + +def test_warns_when_comp_missing_from_module_includes() -> None: + registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a"])} + needs = { + "mod__m": {"includes": []}, # comp__m_a NOT listed + "comp__m_a": {"belongs_to": ["feat__m"]}, + } + warnings = _run_check(registry, needs) + assert any("comp__m_a" in w and "mod__m" in w and "includes" in w for w in warnings) + + +def test_warns_when_feature_missing_from_comp_belongs_to() -> None: + registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a"])} + needs = { + "mod__m": {"includes": ["comp__m_a"]}, + "comp__m_a": {"belongs_to": []}, # feat__m NOT listed + } + warnings = _run_check(registry, needs) + assert any( + "feat__m" in w and "comp__m_a" in w and "belongs_to" in w for w in warnings + ) + + +def test_no_warning_when_all_links_correct() -> None: + registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a", "comp__m_b"])} + needs = { + "mod__m": {"includes": ["comp__m_a", "comp__m_b"]}, + "comp__m_a": {"belongs_to": ["feat__m"]}, + "comp__m_b": {"belongs_to": ["feat__m"]}, + } + warnings = _run_check(registry, needs) + assert warnings == [] + + +def test_skips_module_check_when_mod_need_not_found() -> None: + registry = { + "mod__missing": _registry_entry( + module_id="mod__missing", comp_ids=["comp__m_a"] + ) + } + needs = { + # mod__missing is absent + "comp__m_a": {"belongs_to": ["feat__m"]}, + } + warnings = _run_check(registry, needs) + # warns that module id was not found + assert any("mod__missing" in w and "not found" in w for w in warnings) + # belongs_to is correct — no second warning + assert not any("belongs_to" in w for w in warnings) + + +def test_warns_when_comp_need_not_found() -> None: + registry = {"mod__m": _registry_entry(comp_ids=["comp__missing"])} + needs = { + "mod__m": {"includes": ["comp__missing"]}, + # comp__missing absent from needs + } + warnings = _run_check(registry, needs) + assert any("comp__missing" in w and "not found" in w for w in warnings) + + +def test_warning_includes_docname() -> None: + registry = { + "mod__m": _registry_entry(comp_ids=["comp__m_a"], docname="docs/report") + } + needs = { + "mod__m": {"includes": []}, + "comp__m_a": {"belongs_to": ["feat__m"]}, + } + warnings = _run_check(registry, needs) + assert any("docs/report" in w for w in warnings) + + registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a", "comp__m_b"])} + needs = { + "mod__m": {"includes": []}, # neither comp listed + "comp__m_a": {"belongs_to": []}, # feature missing + "comp__m_b": {"belongs_to": []}, # feature missing + } + warnings = _run_check(registry, needs) + assert len(warnings) == 4 # 2× includes + 2× belongs_to diff --git a/src/extensions/score_module_verification_report/tests/test_coverage.py b/src/extensions/score_module_verification_report/tests/test_coverage.py index 92523ede9..a02eb52dc 100644 --- a/src/extensions/score_module_verification_report/tests/test_coverage.py +++ b/src/extensions/score_module_verification_report/tests/test_coverage.py @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """Unit tests for :mod:`score_module_verification_report.coverage`.""" + from __future__ import annotations import json @@ -87,9 +88,7 @@ def test_coverage_intro_measured_when_pct_present() -> None: def test_coverage_intro_spec_only_when_slug_missing() -> None: - assert coverage_intro({"slug": "kvs"}, {}).startswith( - COVERAGE_INTRO_SPEC_ONLY[:32] - ) + assert coverage_intro({"slug": "kvs"}, {}).startswith(COVERAGE_INTRO_SPEC_ONLY[:32]) def test_coverage_intro_spec_only_when_all_metrics_none() -> None: diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 33b0339b8..1dde06078 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -10,21 +10,19 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Unit tests for the configuration-resolution and component-parsing logic +"""Unit tests for the component-parsing and option-derivation logic in :mod:`score_module_verification_report.directive`. The directive requires a full Sphinx environment to instantiate, so we test the pure derivation rules and the ``_parse_components`` helper in isolation. """ -from __future__ import annotations -import pytest +from __future__ import annotations from src.extensions.score_module_verification_report.directive import ( _parse_components, ) - # --------------------------------------------------------------------------- # Helpers that mirror the derivation logic in directive.py so we can test # it without a Sphinx environment. @@ -36,31 +34,17 @@ def _resolve( option_module_id: str = "", option_feature_id: str = "", option_component_prefix: str = "", - config: dict | None = None, ) -> dict: - """Run the same config-resolution logic as ``run()`` and return a - dict with the resolved fields.""" - if config is None: - config = {} - module_id = option_module_id or config.get("module_id", "") + """Mirror the derivation logic from ``run()`` and return resolved fields.""" + module_id = option_module_id module_short = ( - module_id[len("mod__"):] - if module_id.startswith("mod__") - else module_id - ) - component_prefix = ( - option_component_prefix - or config.get("component_prefix") - or ("comp__" + module_short + "_" if module_short else "comp__") + module_id[len("mod__") :] if module_id.startswith("mod__") else module_id ) - feature_id = ( - option_feature_id - or config.get("feature_id") - or f"feat__{module_short}" - ) - feature_slug = ( - feature_id.split("__", 1)[1] if "__" in feature_id else feature_id + component_prefix = option_component_prefix or ( + "comp__" + module_short + "_" if module_short else "comp__" ) + feature_id = option_feature_id or f"feat__{module_short}" + feature_slug = feature_id.split("__", 1)[1] if "__" in feature_id else feature_id return { "module_id": module_id, "module_short": module_short, @@ -129,9 +113,7 @@ def test_parse_title_uses_titlecase(): def test_parse_multiline_string(): """Continuation lines (as docutils joins them with whitespace) work.""" - result = _parse_components( - "comp__m_json,\n comp__m_result\n", "comp__m_" - ) + result = _parse_components("comp__m_json,\n comp__m_result\n", "comp__m_") assert len(result) == 2 @@ -174,58 +156,6 @@ def test_empty_module_id_gives_generic_prefix(): assert r["feature_id"] == "feat__" -# --------------------------------------------------------------------------- -# Tests — option takes precedence over config -# --------------------------------------------------------------------------- - - -def test_option_module_id_beats_config(): - r = _resolve( - option_module_id="mod__fromopt", - config={"module_id": "mod__fromconfig"}, - ) - assert r["module_id"] == "mod__fromopt" - - -def test_option_feature_id_beats_config(): - r = _resolve( - option_module_id="mod__baselibs", - option_feature_id="feat__opt", - config={"feature_id": "feat__cfg"}, - ) - assert r["feature_id"] == "feat__opt" - - -def test_option_component_prefix_beats_config(): - r = _resolve( - option_module_id="mod__baselibs", - option_component_prefix="comp__opt_", - config={"component_prefix": "comp__cfg_"}, - ) - assert r["component_prefix"] == "comp__opt_" - - -def test_config_used_when_no_option_given(): - r = _resolve( - config={ - "module_id": "mod__cfg", - "feature_id": "feat__cfg", - "component_prefix": "comp__cfg_", - } - ) - assert r["module_id"] == "mod__cfg" - assert r["feature_id"] == "feat__cfg" - assert r["component_prefix"] == "comp__cfg_" - - -def test_config_feature_id_used_when_no_option(): - r = _resolve( - option_module_id="mod__baselibs", - config={"feature_id": "feat__custom"}, - ) - assert r["feature_id"] == "feat__custom" - - # --------------------------------------------------------------------------- # Tests — feature_slug extraction # --------------------------------------------------------------------------- diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index 4f6ffbb40..500ebaee6 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -11,6 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """Unit tests for :mod:`score_module_verification_report.rendering`.""" + from __future__ import annotations from src.extensions.score_module_verification_report.rendering import ( @@ -23,7 +24,6 @@ workproduct_rows, ) - _WP = [ {"key": "req", "label": "Requirements Inspection", "wp_id": "wp__req"}, {"key": "arc", "label": "Architecture Inspection", "wp_id": "wp__arc"}, @@ -90,7 +90,7 @@ def test_render_overview_builds_id_list_literal() -> None: def test_render_overview_empty_components() -> None: - assert 'id in []' in render_overview([]) + assert "id in []" in render_overview([]) # --------------------------------------------------------------------------- diff --git a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py index ad712bfcc..abea99006 100644 --- a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py +++ b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py @@ -12,6 +12,7 @@ # ******************************************************************************* """Unit tests for :mod:`score_module_verification_report.testcase_annotations`.""" + from __future__ import annotations from types import SimpleNamespace @@ -23,8 +24,8 @@ testcase_annotations as ta, ) from src.extensions.score_module_verification_report.testcase_annotations import ( # noqa: E501 - RESULT_COLORS, _FALLBACK_COLOR, + RESULT_COLORS, annotate_testcase_results, init_docnames, merge_docnames, @@ -50,7 +51,7 @@ def _patch_needs(needs): def _doctree_with_testcase_link(text, refid="testcase__foo"): """Build a tiny docutils tree containing a single reference whose visible text is ``text`` (mimicking a sphinx-needs back-link).""" - doc = nodes.document(None, None) + doc = nodes.document(None, None) # type: ignore[arg-type] ref = nodes.reference("", "", nodes.Text(text), refid=refid) doc.append(ref) return doc, ref From f52bcde3b69dbcb93f06864a76d022c464e60859 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Thu, 20 Aug 2026 13:32:09 +0000 Subject: [PATCH 09/25] =?UTF-8?q?fix:=20remove=20feature-id=20name=20guess?= =?UTF-8?q?ing=20=E2=80=94=20feature=20section=20is=20skipped=20when=20omi?= =?UTF-8?q?tted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directive previously derived feature_id as 'feat__{module_short}' when :feature-id: was not provided. This was pure name convention guessing; the metamodel has no mod->feat link (mod only links to comp via :includes:, comp links to feat via :belongs_to:). New behaviour: - :feature-id: is truly optional with no fallback - When omitted, feature_id is None and the entire feature section (needtable, statistics) is skipped from the rendered report - The belongs_to consistency check (Rule 2) is also skipped when feature_id is None Update _resolve helper in test_directive.py to mirror the new logic and add a regression test in test_consistency_checks.py. --- .../consistency_checks.py | 4 ++-- .../directive.py | 8 +++++--- .../rendering.py | 19 +++++++++++-------- .../tests/test_consistency_checks.py | 13 +++++++++++++ .../tests/test_directive.py | 19 ++++++++++++------- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/extensions/score_module_verification_report/consistency_checks.py b/src/extensions/score_module_verification_report/consistency_checks.py index 535284320..215b5334e 100644 --- a/src/extensions/score_module_verification_report/consistency_checks.py +++ b/src/extensions/score_module_verification_report/consistency_checks.py @@ -84,7 +84,7 @@ def merge_registry(app: Any, env: Any, docnames: Any, other: Any) -> None: def _check_module(app: Any, needs: Any, module_id: str, info: dict) -> None: """Check one module's component links and emit warnings for violations.""" - feature_id: str = info["feature_id"] + feature_id: str | None = info["feature_id"] comp_ids: list[str] = info["comp_ids"] docname: str = info.get("docname", "?") mod_need = needs.get(module_id) @@ -124,7 +124,7 @@ def _check_module(app: Any, needs: Any, module_id: str, info: dict) -> None: ) # Rule 2: feature must be in the component's :belongs_to: - if feature_id not in comp_need.get("belongs_to", []): + if feature_id is not None and feature_id not in comp_need.get("belongs_to", []): logger.warning( "[module-verification-report] %s: " "%s is not in %s :belongs_to: " diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 7e2fd5286..6f93b59c4 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -85,9 +85,11 @@ def run(self) -> list[nodes.Node]: component_prefix = self.options.get("component-prefix") or ( "comp__" + module_short + "_" if module_short else "comp__" ) - feature_id = self.options.get("feature-id") or f"feat__{module_short}" + feature_id: str | None = self.options.get("feature-id") or None feature_slug = ( - feature_id.split("__", 1)[1] if "__" in feature_id else feature_id + (feature_id.split("__", 1)[1] if "__" in feature_id else feature_id) + if feature_id is not None + else None ) workproducts = DEFAULT_WORKPRODUCTS feature_workproducts = DEFAULT_FEATURE_WORKPRODUCTS @@ -108,7 +110,7 @@ def run(self) -> list[nodes.Node]: rst_text = render_report( components, feature_id, - feature_slug, + feature_slug, # type: ignore[arg-type] overrides_by_id, workproducts, feature_workproducts, diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index c96604e95..c9e8edce5 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -158,19 +158,22 @@ def render_overview(components: list[dict]) -> str: def render_report( components: list[dict], - feature_id: str, - feature_slug: str, + feature_id: str | None, + feature_slug: str | None, overrides_by_id: dict[str, dict], workproducts: list[dict], feature_workproducts: list[dict], coverage_data: dict, ) -> str: - feature_overrides = overrides_by_id.get(feature_id, {}) - parts = [ - WP_TABLE_CSS, - render_feature( - feature_id, feature_slug, feature_overrides, feature_workproducts - ), + parts = [WP_TABLE_CSS] + if feature_id is not None and feature_slug is not None: + feature_overrides = overrides_by_id.get(feature_id, {}) + parts.append( + render_feature( + feature_id, feature_slug, feature_overrides, feature_workproducts + ) + ) + parts += [ COMPONENTS_HEADER, render_overview(components), ] diff --git a/src/extensions/score_module_verification_report/tests/test_consistency_checks.py b/src/extensions/score_module_verification_report/tests/test_consistency_checks.py index d425c7b54..05a562f6f 100644 --- a/src/extensions/score_module_verification_report/tests/test_consistency_checks.py +++ b/src/extensions/score_module_verification_report/tests/test_consistency_checks.py @@ -236,6 +236,19 @@ def test_warns_when_feature_missing_from_comp_belongs_to() -> None: ) +def test_no_belongs_to_check_when_feature_id_is_none() -> None: + """When feature_id is None (no :feature-id: option), the belongs_to rule is skipped.""" + registry = { + "mod__m": _registry_entry(feature_id=None, comp_ids=["comp__m_a"]) # type: ignore[arg-type] + } + needs = { + "mod__m": {"includes": ["comp__m_a"]}, + "comp__m_a": {"belongs_to": []}, # would fail if feature_id were set + } + warnings = _run_check(registry, needs) + assert not any("belongs_to" in w for w in warnings) + + def test_no_warning_when_all_links_correct() -> None: registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a", "comp__m_b"])} needs = { diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 1dde06078..87f154080 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -43,8 +43,12 @@ def _resolve( component_prefix = option_component_prefix or ( "comp__" + module_short + "_" if module_short else "comp__" ) - feature_id = option_feature_id or f"feat__{module_short}" - feature_slug = feature_id.split("__", 1)[1] if "__" in feature_id else feature_id + feature_id: str | None = option_feature_id or None + feature_slug = ( + (feature_id.split("__", 1)[1] if "__" in feature_id else feature_id) + if feature_id is not None + else None + ) return { "module_id": module_id, "module_short": module_short, @@ -122,13 +126,14 @@ def test_parse_multiline_string(): # --------------------------------------------------------------------------- -def test_module_id_option_derives_prefix_and_feature(): +def test_module_id_option_derives_prefix_only_not_feature(): + """Without :feature-id:, feature_id is None — no name guessing.""" r = _resolve(option_module_id="mod__baselibs") assert r["module_id"] == "mod__baselibs" assert r["module_short"] == "baselibs" assert r["component_prefix"] == "comp__baselibs_" - assert r["feature_id"] == "feat__baselibs" - assert r["feature_slug"] == "baselibs" + assert r["feature_id"] is None + assert r["feature_slug"] is None def test_explicit_feature_id_option_overrides_derived(): @@ -146,14 +151,14 @@ def test_module_id_without_mod_prefix(): r = _resolve(option_module_id="mymodule") assert r["module_short"] == "mymodule" assert r["component_prefix"] == "comp__mymodule_" - assert r["feature_id"] == "feat__mymodule" + assert r["feature_id"] is None def test_empty_module_id_gives_generic_prefix(): r = _resolve() assert r["module_id"] == "" assert r["component_prefix"] == "comp__" - assert r["feature_id"] == "feat__" + assert r["feature_id"] is None # --------------------------------------------------------------------------- From fbba964aa38c4b9072c23d807b7dbfd982527b8b Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Thu, 20 Aug 2026 14:49:23 +0000 Subject: [PATCH 10/25] refactor: remove unit test coverage (LCOV) and work-product overrides from extension - Delete coverage.py and tests/test_coverage.py (LCOV-based code coverage loading) - Remove COMPONENT_COVERAGE_SECTION_DISABLED template (lines/functions/branches section) - Remove overrides_by_id mechanism from render_report, render_component, render_feature, and workproduct_rows - Simplify directive: drop coverage_data and overrides_by_id locals - Requirements coverage needpie charts and fully_verifies_back columns are unchanged --- .../__init__.py | 1 - .../coverage.py | 66 ----------- .../directive.py | 6 - .../rendering.py | 77 ++++--------- .../templates.py | 25 ----- .../tests/test_coverage.py | 105 ------------------ .../tests/test_rendering.py | 46 +------- 7 files changed, 23 insertions(+), 303 deletions(-) delete mode 100644 src/extensions/score_module_verification_report/coverage.py delete mode 100644 src/extensions/score_module_verification_report/tests/test_coverage.py diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 41f8aa59a..615871334 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -27,7 +27,6 @@ Implementation is split across: -* :mod:`.coverage` — ``coverage_summary.json`` loading * :mod:`.templates` — RST templates + default workproduct lists + CSS * :mod:`.rendering` — template expansion / report body assembly * :mod:`.directive` — the ``ModuleVerificationReportDirective`` class diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py deleted file mode 100644 index e0fbba096..000000000 --- a/src/extensions/score_module_verification_report/coverage.py +++ /dev/null @@ -1,66 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Coverage summary loading and intro-paragraph selection. - -The JSON is produced by ``tools/extract_coverage.py`` from an LCOV -report. Its top-level keys are component slugs (matching -``comp__<module>_<slug>`` in the sphinx-needs data). Presence of a slug -with real metric values marks that component as *measured*; absence -marks it as *specification-only*. -""" - -from __future__ import annotations - -import json -import os - -COVERAGE_INTRO_MEASURED = ( - "Aggregated from ``bazel coverage``. Regenerate via\n" - '``python3 tools/extract_coverage.py "$(bazel info output_path)' - '/_coverage/_coverage_report.dat" docs/reporting/coverage_summary.json``.\n' -) -COVERAGE_INTRO_SPEC_ONLY = ( - "This component is specification-only and has no dedicated unit\n" - "test binary in ``//score/\u2026``.\n" -) - -COVERAGE_SUMMARY_REL_PATH = os.path.join("reporting", "coverage_summary.json") - - -def load_coverage_summary(env) -> dict: - """Return the parsed ``coverage_summary.json`` (empty dict on failure).""" - path = os.path.join(env.srcdir, COVERAGE_SUMMARY_REL_PATH) - try: - with open(path, encoding="utf-8") as fh: - data = json.load(fh) - except (OSError, ValueError): - return {} - if os.path.isfile(path): - env.note_dependency(path) - return data or {} - - -def coverage_intro(comp: dict, coverage_data: dict) -> str: - """Choose the intro paragraph based on ``coverage_summary.json``. - - A component counts as *measured* iff its slug appears in the JSON - with at least one non-null metric percentage. Otherwise it is - treated as specification-only. This mirrors how the - ``|coverage_<slug>_*|`` substitutions in ``conf.py`` decide between - numeric output and ``"not measured"``. - """ - entry = coverage_data.get(comp["slug"]) or {} - measured = any( - entry.get(f"{m}_pct") is not None for m in ("lines", "functions", "branches") - ) - return (COVERAGE_INTRO_MEASURED if measured else COVERAGE_INTRO_SPEC_ONLY) + "\n" diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 6f93b59c4..8e23d46b3 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -21,7 +21,6 @@ from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles -from .coverage import load_coverage_summary from .rendering import render_report from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS @@ -93,7 +92,6 @@ def run(self) -> list[nodes.Node]: ) workproducts = DEFAULT_WORKPRODUCTS feature_workproducts = DEFAULT_FEATURE_WORKPRODUCTS - overrides_by_id: dict[str, dict] = {} components_str = self.options.get("components", "") components = _parse_components(components_str, component_prefix) @@ -105,16 +103,12 @@ def run(self) -> list[nodes.Node]: ) return [error] - coverage_data = load_coverage_summary(self.env) - rst_text = render_report( components, feature_id, feature_slug, # type: ignore[arg-type] - overrides_by_id, workproducts, feature_workproducts, - coverage_data, ) view_list = ViewList() source = "<module-verification-report>" diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index c9e8edce5..6cd35bc35 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -42,57 +42,40 @@ def slugify(text: str) -> str: def workproduct_rows( slug_norm: str, - overrides: dict, workproducts: list[dict], ) -> str: """Render the work-product rows for one component or the feature. Each row has four cells: the work-product ``:need:`` link, its label, the realising document, and its status. The "Realized by" - and "Status" cells are populated by sphinx-needs so their content - stays in sync with the actual sphinx-needs data model: - - 1. **Overrides** — when ``overrides['workproducts'][wp_key]`` names - an explicit doc id, the row renders a direct ``:need:`` link - and a ``:ndf:`copy('status', ...)``` call that pulls the doc's - status field verbatim. - 2. **Filter** — otherwise, both cells render a ``.. needtable::`` - with the same filter (``type == "document"``, normalised-slug - substring match on the doc id, ``realizes`` link containing - ``wp['wp_id']``) but different ``:columns:``. If nothing matches, - both cells are empty. + and "Status" cells are both rendered as ``.. needtable::`` with the + same filter (``type == "document"``, normalised-slug substring match + on the doc id, ``realizes`` link containing ``wp['wp_id']``) but + different ``:columns:``. If nothing matches, both cells are empty. """ - explicit = overrides.get("workproducts") or {} lines: list[str] = [] for wp in workproducts: - override_doc = explicit.get(wp["key"]) lines.append(f" * - :need:`{wp['wp_id']}`") lines.append(f" - {wp['label']}") - if override_doc: - lines.append(f" - :need:`{override_doc}`") - lines.append(f" - :ndf:`copy('status', need_id='{override_doc}')`") - else: - filter_expr = ( - f'type == "document" and ' - f'"{slug_norm}" in id.replace("_", "") and ' - f'"{wp["wp_id"]}" in realizes' - ) - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: id") - lines.append(" :style: table") - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: status") - lines.append(" :style: table") + filter_expr = ( + f'type == "document" and ' + f'"{slug_norm}" in id.replace("_", "") and ' + f'"{wp["wp_id"]}" in realizes' + ) + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: id") + lines.append(" :style: table") + lines.append(" - .. needtable::") + lines.append(f" :filter: {filter_expr}") + lines.append(" :columns: status") + lines.append(" :style: table") return "\n".join(lines) def render_component( comp: dict, - overrides: dict, workproducts: list[dict], - coverage_data: dict, ) -> str: title = comp["title"] slug = comp["slug"] @@ -103,19 +86,13 @@ def render_component( title_underline="~" * len(title), comp_id=comp["id"], slug=slug, - workproduct_rows=workproduct_rows( - normalize_slug(slug), overrides, workproducts - ), - # Unit Test Coverage section disabled — see - # ``templates.COMPONENT_COVERAGE_SECTION_DISABLED``. Restore by - # passing ``coverage_intro=coverage_intro(comp, coverage_data)``. + workproduct_rows=workproduct_rows(normalize_slug(slug), workproducts), ) def render_feature( feature_id: str, feature_slug: str, - feature_overrides: dict, feature_workproducts: list[dict], ) -> str: """Render the ``Feature`` section (Requirements / Architecture / @@ -136,7 +113,6 @@ def render_feature( feature_slug=feature_slug, feature_workproduct_rows=workproduct_rows( normalize_slug(feature_slug), - feature_overrides, feature_workproducts, ), ) @@ -160,31 +136,18 @@ def render_report( components: list[dict], feature_id: str | None, feature_slug: str | None, - overrides_by_id: dict[str, dict], workproducts: list[dict], feature_workproducts: list[dict], - coverage_data: dict, ) -> str: parts = [WP_TABLE_CSS] if feature_id is not None and feature_slug is not None: - feature_overrides = overrides_by_id.get(feature_id, {}) parts.append( - render_feature( - feature_id, feature_slug, feature_overrides, feature_workproducts - ) + render_feature(feature_id, feature_slug, feature_workproducts) ) parts += [ COMPONENTS_HEADER, render_overview(components), ] for comp in components: - overrides = overrides_by_id.get(comp["id"], {}) - parts.append( - render_component( - comp, - overrides, - workproducts, - coverage_data, - ) - ) + parts.append(render_component(comp, workproducts)) return "\n".join(parts) diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index cf680b2da..5fa45c0e6 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -133,31 +133,6 @@ """ -# Kept for later re-activation. To re-enable the Unit Test Coverage -# section, append this fragment to ``COMPONENT_TEMPLATE`` and restore -# the ``coverage_intro=coverage_intro(comp, coverage_data)`` kwarg in -# ``render_component``. -COMPONENT_COVERAGE_SECTION_DISABLED = """\ - -Unit Test Coverage -^^^^^^^^^^^^^^^^^^ - -{coverage_intro} -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Metric - - Coverage - * - Lines - - |coverage_{slug}_lines| - * - Functions - - |coverage_{slug}_functions| - * - Branches - - |coverage_{slug}_branches| -""" - - FEATURE_TEMPLATE = """\ Feature ------- diff --git a/src/extensions/score_module_verification_report/tests/test_coverage.py b/src/extensions/score_module_verification_report/tests/test_coverage.py deleted file mode 100644 index a02eb52dc..000000000 --- a/src/extensions/score_module_verification_report/tests/test_coverage.py +++ /dev/null @@ -1,105 +0,0 @@ -# ******************************************************************************* -# 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 tests for :mod:`score_module_verification_report.coverage`.""" - -from __future__ import annotations - -import json -from pathlib import Path -from types import SimpleNamespace - -from src.extensions.score_module_verification_report.coverage import ( - COVERAGE_INTRO_MEASURED, - COVERAGE_INTRO_SPEC_ONLY, - COVERAGE_SUMMARY_REL_PATH, - coverage_intro, - load_coverage_summary, -) - - -def _env(srcdir: Path) -> SimpleNamespace: - env = SimpleNamespace(srcdir=str(srcdir), _deps=[]) - env.note_dependency = env._deps.append # type: ignore[attr-defined] - return env - - -def _write_summary(srcdir: Path, payload: object) -> Path: - path = srcdir / COVERAGE_SUMMARY_REL_PATH - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload), encoding="utf-8") - return path - - -# --------------------------------------------------------------------------- -# load_coverage_summary -# --------------------------------------------------------------------------- - - -def test_load_coverage_summary_reads_json_and_notes_dependency(tmp_path: Path) -> None: - payload = {"comp_a": {"lines_pct": 87.5}} - path = _write_summary(tmp_path, payload) - env = _env(tmp_path) - - data = load_coverage_summary(env) - - assert data == payload - assert env._deps == [str(path)] - - -def test_load_coverage_summary_missing_file_returns_empty(tmp_path: Path) -> None: - env = _env(tmp_path) - assert load_coverage_summary(env) == {} - assert env._deps == [] - - -def test_load_coverage_summary_invalid_json_returns_empty(tmp_path: Path) -> None: - path = tmp_path / COVERAGE_SUMMARY_REL_PATH - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{not json", encoding="utf-8") - env = _env(tmp_path) - assert load_coverage_summary(env) == {} - - -def test_load_coverage_summary_null_content_returns_empty(tmp_path: Path) -> None: - _write_summary(tmp_path, None) - env = _env(tmp_path) - assert load_coverage_summary(env) == {} - - -# --------------------------------------------------------------------------- -# coverage_intro -# --------------------------------------------------------------------------- - - -def test_coverage_intro_measured_when_pct_present() -> None: - comp = {"slug": "kvs"} - data = {"kvs": {"lines_pct": 90.0, "functions_pct": None, "branches_pct": None}} - assert coverage_intro(comp, data).startswith(COVERAGE_INTRO_MEASURED[:32]) - - -def test_coverage_intro_spec_only_when_slug_missing() -> None: - assert coverage_intro({"slug": "kvs"}, {}).startswith(COVERAGE_INTRO_SPEC_ONLY[:32]) - - -def test_coverage_intro_spec_only_when_all_metrics_none() -> None: - data = { - "kvs": {"lines_pct": None, "functions_pct": None, "branches_pct": None}, - } - assert coverage_intro({"slug": "kvs"}, data).startswith( - COVERAGE_INTRO_SPEC_ONLY[:32] - ) - - -def test_coverage_intro_terminates_with_blank_line() -> None: - result = coverage_intro({"slug": "kvs"}, {}) - assert result.endswith("\n\n") diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index 500ebaee6..e3eeb9eab 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -53,7 +53,7 @@ def test_slugify_strips_leading_trailing_dashes() -> None: def test_workproduct_rows_uses_needtable_by_default() -> None: - out = workproduct_rows("kvs", overrides={}, workproducts=_WP) + out = workproduct_rows("kvs", workproducts=_WP) assert ":need:`wp__req`" in out assert "Requirements Inspection" in out # Both id and status cells rendered as needtables with matching filter. @@ -63,18 +63,8 @@ def test_workproduct_rows_uses_needtable_by_default() -> None: assert '"wp__req" in realizes' in out -def test_workproduct_rows_override_uses_direct_need_and_ndf_copy() -> None: - overrides = {"workproducts": {"req": "doc__custom_req"}} - out = workproduct_rows("kvs", overrides, _WP) - # Overridden row: no needtable, direct :need: + :ndf: copy on status. - assert ":need:`doc__custom_req`" in out - assert "copy('status', need_id='doc__custom_req')" in out - # Non-overridden row (arc) still uses needtable. - assert '"wp__arc" in realizes' in out - - def test_workproduct_rows_no_rows_when_workproducts_empty() -> None: - assert workproduct_rows("kvs", {}, []) == "" + assert workproduct_rows("kvs", []) == "" # --------------------------------------------------------------------------- @@ -100,7 +90,7 @@ def test_render_overview_empty_components() -> None: def test_render_component_contains_component_specific_filters() -> None: comp = {"id": "comp__demo_kvs", "slug": "kvs", "title": "Key-Value Store"} - out = render_component(comp, overrides={}, workproducts=_WP, coverage_data={}) + out = render_component(comp, workproducts=_WP) # Title underline (~ * len(title)). assert "~" * len("Key-Value Store") in out # comp_id substituted into all filter expressions. @@ -116,7 +106,6 @@ def test_render_feature_substitutes_feature_id_and_slug() -> None: out = render_feature( feature_id="feat__demo", feature_slug="demo", - feature_overrides={}, feature_workproducts=_WP, ) assert 'id == "feat__demo"' in out @@ -139,10 +128,8 @@ def test_render_report_assembles_all_sections() -> None: components=components, feature_id="feat__demo", feature_slug="demo", - overrides_by_id={}, workproducts=_WP, feature_workproducts=_WP, - coverage_data={}, ) # CSS block for wp-doc-table styling. assert ".wp-doc-table" in out @@ -154,31 +141,4 @@ def test_render_report_assembles_all_sections() -> None: assert '"comp__demo_b" in satisfied_by' in out -def test_render_report_applies_feature_overrides() -> None: - out = render_report( - components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], - feature_id="feat__demo", - feature_slug="demo", - overrides_by_id={ - "feat__demo": {"workproducts": {"req": "doc__feat_req"}}, - }, - workproducts=_WP, - feature_workproducts=_WP, - coverage_data={}, - ) - assert ":need:`doc__feat_req`" in out - -def test_render_report_applies_component_overrides() -> None: - out = render_report( - components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], - feature_id="feat__demo", - feature_slug="demo", - overrides_by_id={ - "comp__demo_a": {"workproducts": {"req": "doc__comp_a_req"}}, - }, - workproducts=_WP, - feature_workproducts=_WP, - coverage_data={}, - ) - assert ":need:`doc__comp_a_req`" in out From 30ec2e608bac704d5ad6b8aa68f1c8fd75cab560 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Fri, 21 Aug 2026 06:00:55 +0000 Subject: [PATCH 11/25] style: apply ruff-format and end-of-file-fixer --- src/extensions/score_module_verification_report/rendering.py | 4 +--- .../score_module_verification_report/tests/test_rendering.py | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 6cd35bc35..8a6895bb7 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -141,9 +141,7 @@ def render_report( ) -> str: parts = [WP_TABLE_CSS] if feature_id is not None and feature_slug is not None: - parts.append( - render_feature(feature_id, feature_slug, feature_workproducts) - ) + parts.append(render_feature(feature_id, feature_slug, feature_workproducts)) parts += [ COMPONENTS_HEADER, render_overview(components), diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index e3eeb9eab..e3a70fac1 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -139,6 +139,3 @@ def test_render_report_assembles_all_sections() -> None: assert 'id in ["comp__demo_a", "comp__demo_b"]' in out assert '"comp__demo_a" in satisfied_by' in out assert '"comp__demo_b" in satisfied_by' in out - - - From bbecf475d56af12b24943a091ec99bda2250e6f9 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Fri, 21 Aug 2026 14:27:04 +0000 Subject: [PATCH 12/25] feat(docs_and_test): add Bazel macro chaining tests/coverage with docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs_and_test macro generates two py_binary targets that run 'bazel test' (or 'bazel coverage --combined_report=lcov' when coverage=True) followed by 'bazel run //:docs' resp. '//:live_preview' in a single command: load('@score_docs_as_code//:bzl/docs_and_test.bzl', 'docs_and_test') docs_and_test( name = 'module_verification_report', test_targets = ['//score/...'], ) Consumers get //:<name> and //:<name>_preview without any local Python driver. Extra Bazel flags forward via '--test-flag=…'. Ctrl+C in the driver propagates cleanly as exit 130. Ships the driver via exports_files so external py_binary(srcs=…) can reference it. --- BUILD | 1 + bzl/docs_and_test.bzl | 124 +++++++++++++++++++++++++++++++++++++++ bzl/run_docs_and_test.py | 118 +++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 bzl/docs_and_test.bzl create mode 100644 bzl/run_docs_and_test.py diff --git a/BUILD b/BUILD index 4d7ca80fc..38174f0ba 100644 --- a/BUILD +++ b/BUILD @@ -17,6 +17,7 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "default_conf.py.tpl", "pyproject.toml", + "bzl/run_docs_and_test.py", ]) docs( diff --git a/bzl/docs_and_test.bzl b/bzl/docs_and_test.bzl new file mode 100644 index 000000000..3a1cef3e9 --- /dev/null +++ b/bzl/docs_and_test.bzl @@ -0,0 +1,124 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Bazel macro that chains ``bazel test`` (or ``bazel coverage``) with a +docs target in one command. + +Usage in a consumer ``BUILD``:: + + load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") + + docs_and_test( + name = "docs_full", + test_targets = ["//score/..."], + ) + +Generates two ``py_binary`` targets:: + + bazel run //:docs_full # tests/coverage, then //:docs + bazel run //:docs_full_preview # tests/coverage, then //:live_preview + +Bazel itself has no mechanism to make a build target depend on the +execution of a test, so the orchestration lives outside the dependency +graph in a small Python driver shipped with this module. +""" + +load("@rules_python//python:defs.bzl", "py_binary") + +_DEFAULT_DRIVER = Label("@score_docs_as_code//:bzl/run_docs_and_test.py") + +def _pipeline_binary(name, driver, test_targets, coverage, run_target, help_text): + py_binary( + name = name, + srcs = [driver], + main = driver, + args = [ + "--tests", + ",".join(test_targets), + "--coverage" if coverage else "--no-coverage", + "--docs", + run_target, + ], + tags = ["cli_help=%s:\nbazel run //:%s" % (help_text, name)], + ) + +def docs_and_test( + name, + test_targets, + coverage = True, + docs_target = "//:docs", + preview_target = "//:live_preview", + driver = None): + """Create ``py_binary`` targets that run tests, then a docs command. + + Two targets are generated: + + * ``<name>`` — runs tests, then ``docs_target``. + * ``<name>_preview`` — runs tests, then ``preview_target`` + (typically ``//:live_preview``). + + Coverage is on by default. When ``coverage = True`` the pipeline uses + ``bazel coverage --combined_report=lcov`` on ``test_targets`` instead of + plain ``bazel test``; that runs the same tests with LLVM/GCC coverage + instrumentation and produces the aggregated LCOV at + ``bazel-out/_coverage/_coverage_report.dat`` in a single rebuild. + Targets without unit tests simply contribute no coverage data. Set + ``coverage = False`` to fall back to plain ``bazel test`` (faster on + first run, no LCOV). + + Extra Bazel CLI flags (e.g. ``--config=bl-x86_64-linux``) are not + hard-coded in the ``BUILD`` file. Pass them on the command line after + ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + + ``--test-flag`` is repeatable and forwarded to whichever underlying + Bazel command runs (``test`` or ``coverage``). + + Args: + name: Base target name; invoke with ``bazel run //:<name>`` or + ``bazel run //:<name>_preview``. + test_targets: Bazel labels/patterns for the test/coverage step + (e.g. ``["//score/..."]``). Pass ``[]`` to skip. + coverage: If ``True`` (default), replace ``bazel test`` with + ``bazel coverage --combined_report=lcov`` so the docs build can + pick up per-source-file LCOV data. If ``False``, run plain + ``bazel test`` and produce no LCOV. + docs_target: Label of the docs binary to invoke via ``bazel run``. + Defaults to ``//:docs``. + preview_target: Label of the live-preview binary to invoke via + ``bazel run``. Defaults to ``//:live_preview``. Pass ``None`` to + skip generating the preview target. + driver: Label of the Python driver script. Defaults to the driver + shipped with ``score_docs_as_code``; only override when you want + to inject a custom driver. + """ + driver = driver or _DEFAULT_DRIVER + _pipeline_binary( + name = name, + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = docs_target, + help_text = "Run tests, then build documentation", + ) + + if preview_target: + _pipeline_binary( + name = name + "_preview", + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = preview_target, + help_text = "Run tests, then start the docs live preview", + ) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py new file mode 100644 index 000000000..4e543dd21 --- /dev/null +++ b/bzl/run_docs_and_test.py @@ -0,0 +1,118 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Driver for the :bzl:`docs_and_test` macro. + +Runs either ``bazel test`` or ``bazel coverage`` on the configured targets, +then ``bazel run`` on the docs target. Aborts the pipeline on the first +non-zero exit code so a failing step does not silently ship stale docs. + +Extra Bazel CLI flags (typically ``--config=…``) are **not** baked into +the ``BUILD`` file; pass them at ``bazel run`` time after ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + +``--test-flag`` is repeatable and forwarded to whichever underlying Bazel +command runs (``test`` or ``coverage``). + +The script must be invoked from the workspace root — ``bazel run`` sets +``BUILD_WORKSPACE_DIRECTORY`` accordingly, so we chdir there before +invoking any nested Bazel commands. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + + +def _split(csv: str) -> list[str]: + return [x for x in csv.split(",") if x] + + +def _run(cmd: list[str]) -> None: + print(f">>> {' '.join(cmd)}", flush=True) + try: + result = subprocess.run(cmd, check=False) + except KeyboardInterrupt: + # Ctrl+C hits both us and the child via the process group. The child + # already exited with 130; propagate the same status without dumping + # a Python traceback so `docs_full_preview` behaves like a bare + # `bazel run //:live_preview`. + sys.exit(130) + if result.returncode != 0: + print( + f"!!! step failed with exit code {result.returncode}: {' '.join(cmd)}", + file=sys.stderr, + ) + sys.exit(result.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--tests", + default="", + help="Comma-separated Bazel labels/patterns for the test step. " + "Empty string skips the step entirely.", + ) + parser.add_argument( + "--coverage", + dest="coverage", + action=argparse.BooleanOptionalAction, + default=True, + help="Run 'bazel coverage --combined_report=lcov' instead of " + "'bazel test' on --tests. Default: True.", + ) + parser.add_argument( + "--docs", + required=True, + help="Bazel label of the docs binary to invoke via 'bazel run'.", + ) + parser.add_argument( + "--test-flag", + action="append", + default=[], + help="Extra CLI flag forwarded to the test/coverage step " + "(repeatable). Typically '--config=…'.", + ) + args = parser.parse_args() + + # `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the workspace root; nested + # bazel invocations must run from there so they see MODULE.bazel etc. + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if workspace: + os.chdir(workspace) + + test_targets = _split(args.tests) + + if test_targets: + if args.coverage: + _run( + [ + "bazel", + "coverage", + "--combined_report=lcov", + *args.test_flag, + *test_targets, + ] + ) + else: + _run(["bazel", "test", *args.test_flag, *test_targets]) + _run(["bazel", "run", args.docs]) + + +if __name__ == "__main__": + main() From cfd775a12ab9f61921e0bc9fb9ddea6f1ebf2409 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Fri, 21 Aug 2026 14:27:20 +0000 Subject: [PATCH 13/25] feat(module-verification-report): per-component coverage dropdown Adds an optional 'Test Coverage' section to each component in the verification report. When an LCOV file is available (produced by 'bazel coverage'), the extension aggregates per-source-file line and branch coverage and renders it as a collapsible list-table matching the existing requirements/architecture dropdowns. When no LCOV file is found or the component has no matching records, a 'No coverage data available' note is shown instead so the section is consistently present across components. New module coverage.py: * parse_lcov(): SF/LF/LH/BRF/BRH records -> FileCoverage list. * load_coverage(): resolves workspace-relative LCOV paths via helper_lib.find_ws_root with a Path.cwd() fallback for pytest. * records_for_slug(): filters records by normalised component slug using the same underscore-strip lower-case match as workproduct_rows(). * coverage_rows(): renders 7-column table body + Total row. Config: * New app.add_config_value('mvr_coverage_lcov', ...) with default 'bazel-out/_coverage/_coverage_report.dat'. Templates: * COMPONENT_TEMPLATE gains a {coverage_block} slot between the requirements dropdown and Architectural Elements heading. * COMPONENT_COVERAGE_TEMPLATE wraps the block; body is either COVERAGE_TABLE_HEADER + rows or COVERAGE_EMPTY_BODY. Rendering / directive: * render_component / render_report accept coverage_records=None. * directive.py loads LCOV via load_coverage(config.mvr_coverage_lcov) and passes the result through. --- .../__init__.py | 5 + .../coverage.py | 181 ++++++++++++++++++ .../directive.py | 4 + .../rendering.py | 18 +- .../templates.py | 39 +++- 5 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/extensions/score_module_verification_report/coverage.py diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 615871334..ef072860a 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -57,6 +57,11 @@ def setup(app: Any) -> dict: app.add_directive("module-verification-report", ModuleVerificationReportDirective) + app.add_config_value( + "mvr_coverage_lcov", + "bazel-out/_coverage/_coverage_report.dat", + "env", + ) app.connect("env-before-read-docs", init_docnames) app.connect("env-before-read-docs", init_registry) app.connect("env-purge-doc", purge_docname) diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py new file mode 100644 index 000000000..ed7414cd9 --- /dev/null +++ b/src/extensions/score_module_verification_report/coverage.py @@ -0,0 +1,181 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""LCOV parsing and per-component aggregation for the coverage dropdown. + +The report renderer calls :func:`load_coverage` once per build; the result +is a list of :class:`FileCoverage` records that :func:`records_for_slug` +filters per component using the same normalised-slug substring match that +:func:`.rendering.workproduct_rows` uses for work-product zuordnung. + +If the LCOV file cannot be found (no ``bazel coverage`` run yet), the +functions return empty lists so the coverage dropdown is silently +omitted rather than breaking the docs build. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _workspace_root() -> Path | None: + """Return the workspace root or ``None`` if it cannot be determined. + + ``helper_lib.find_ws_root`` is only importable at Bazel run-time; in + plain pytest runs it is absent, in which case we fall back to walking + up from the current working directory looking for ``MODULE.bazel`` + or ``WORKSPACE``. + """ + try: + from helper_lib import find_ws_root # type: ignore[import-not-found] + + root = find_ws_root() + if root is not None: + return root + except ImportError: + pass + cwd = Path.cwd() + for candidate in (cwd, *cwd.parents): + if (candidate / "MODULE.bazel").exists() or (candidate / "WORKSPACE").exists(): + return candidate + return None + + +@dataclass +class FileCoverage: + """Aggregated coverage for a single source file (one ``SF:`` record).""" + + source: str + lines_found: int = 0 + lines_hit: int = 0 + branches_found: int = 0 + branches_hit: int = 0 + + @property + def line_pct(self) -> float: + return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 0.0 + + @property + def branch_pct(self) -> float: + return ( + 100.0 * self.branches_hit / self.branches_found + if self.branches_found + else 0.0 + ) + + +def parse_lcov(path: Path) -> list[FileCoverage]: + """Parse an LCOV ``*.dat`` file into per-source coverage records.""" + records: list[FileCoverage] = [] + current: FileCoverage | None = None + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw.strip() + if line.startswith("SF:"): + current = FileCoverage(source=line[3:]) + records.append(current) + elif current is None: + continue + elif line.startswith("LF:"): + current.lines_found = int(line[3:] or 0) + elif line.startswith("LH:"): + current.lines_hit = int(line[3:] or 0) + elif line.startswith("BRF:"): + current.branches_found = int(line[4:] or 0) + elif line.startswith("BRH:"): + current.branches_hit = int(line[4:] or 0) + elif line == "end_of_record": + current = None + return records + + +def load_coverage(config_path: str) -> list[FileCoverage]: + """Locate and parse the LCOV report. + + ``config_path`` may be absolute or relative. Relative paths are + resolved against the workspace root (same discovery helper used by + the source-code linker for ``bazel-testlogs``). If the file is + missing, an empty list is returned and a single log line is + emitted — the missing file must not break the docs build. + """ + if not config_path: + return [] + path = Path(config_path) + if not path.is_absolute(): + ws_root = _workspace_root() + if ws_root is None: + logger.info( + "mvr coverage: workspace root not found; skipping LCOV load" + ) + return [] + path = ws_root / path + if not path.is_file(): + logger.info( + "mvr coverage: LCOV file not found at %s; skipping coverage dropdown", + path, + ) + return [] + logger.info("mvr coverage: parsing LCOV %s", path) + return parse_lcov(path) + + +def records_for_slug( + records: list[FileCoverage], + slug_norm: str, +) -> list[FileCoverage]: + """Filter *records* to those whose source path contains *slug_norm*. + + ``slug_norm`` must already be normalised (underscores stripped, + lower-cased) — matching :func:`.rendering.normalize_slug`. + """ + if not slug_norm: + return [] + return [ + r + for r in records + if slug_norm in r.source.replace("_", "").replace("/", "").lower() + ] + + +def coverage_rows(records: list[FileCoverage]) -> str: + """Render *records* as ``list-table`` rows (no header row). + + Returns an empty string when *records* is empty so callers can decide + to omit the whole dropdown block. + """ + if not records: + return "" + lines: list[str] = [] + total = FileCoverage(source="**Total**") + for r in sorted(records, key=lambda x: x.source): + lines.append(f" * - ``{r.source}``") + lines.append(f" - {r.lines_found}") + lines.append(f" - {r.lines_hit}") + lines.append(f" - {r.line_pct:.1f}") + lines.append(f" - {r.branches_found}") + lines.append(f" - {r.branches_hit}") + lines.append(f" - {r.branch_pct:.1f}") + total.lines_found += r.lines_found + total.lines_hit += r.lines_hit + total.branches_found += r.branches_found + total.branches_hit += r.branches_hit + lines.append(f" * - {total.source}") + lines.append(f" - {total.lines_found}") + lines.append(f" - {total.lines_hit}") + lines.append(f" - {total.line_pct:.1f}") + lines.append(f" - {total.branches_found}") + lines.append(f" - {total.branches_hit}") + lines.append(f" - {total.branch_pct:.1f}") + return "\n".join(lines) diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 8e23d46b3..a63628aaa 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -23,6 +23,7 @@ from .rendering import render_report from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS +from .coverage import load_coverage # Strip an optional ``[version==N]`` qualifier from a component id. _VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") @@ -109,6 +110,9 @@ def run(self) -> list[nodes.Node]: feature_slug, # type: ignore[arg-type] workproducts, feature_workproducts, + coverage_records=load_coverage( + getattr(self.config, "mvr_coverage_lcov", "") + ), ) view_list = ViewList() source = "<module-verification-report>" diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 8a6895bb7..742a44e95 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -17,12 +17,16 @@ import re from .templates import ( + COMPONENT_COVERAGE_TEMPLATE, COMPONENT_TEMPLATE, COMPONENTS_HEADER, + COVERAGE_EMPTY_BODY, + COVERAGE_TABLE_HEADER, FEATURE_TEMPLATE, OVERVIEW_TEMPLATE, WP_TABLE_CSS, ) +from .coverage import FileCoverage, coverage_rows, records_for_slug def normalize_slug(text: str) -> str: @@ -76,17 +80,26 @@ def workproduct_rows( def render_component( comp: dict, workproducts: list[dict], + coverage_records: list[FileCoverage] | None = None, ) -> str: title = comp["title"] slug = comp["slug"] ref = "comp-" + slugify(title) + slug_norm = normalize_slug(slug) + matched = records_for_slug(coverage_records or [], slug_norm) + if matched: + coverage_body = COVERAGE_TABLE_HEADER + coverage_rows(matched) + else: + coverage_body = COVERAGE_EMPTY_BODY + coverage_block = COMPONENT_COVERAGE_TEMPLATE.format(coverage_body=coverage_body) return COMPONENT_TEMPLATE.format( ref=ref, title=title, title_underline="~" * len(title), comp_id=comp["id"], slug=slug, - workproduct_rows=workproduct_rows(normalize_slug(slug), workproducts), + workproduct_rows=workproduct_rows(slug_norm, workproducts), + coverage_block=coverage_block, ) @@ -138,6 +151,7 @@ def render_report( feature_slug: str | None, workproducts: list[dict], feature_workproducts: list[dict], + coverage_records: list[FileCoverage] | None = None, ) -> str: parts = [WP_TABLE_CSS] if feature_id is not None and feature_slug is not None: @@ -147,5 +161,5 @@ def render_report( render_overview(components), ] for comp in components: - parts.append(render_component(comp, workproducts)) + parts.append(render_component(comp, workproducts, coverage_records)) return "\n".join(parts) diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index 5fa45c0e6..c00e5bf00 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -92,7 +92,7 @@ :columns: id;title;safety;status;fully_verifies_back;partially_verifies_back :colwidths: 13,22,8,10,23,24 :sort: id - +{coverage_block} Architectural Elements ^^^^^^^^^^^^^^^^^^^^^^ @@ -133,6 +133,43 @@ """ +COMPONENT_COVERAGE_TEMPLATE = """ +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 +{coverage_body} +""" + + +COVERAGE_TABLE_HEADER = """ + .. list-table:: + :header-rows: 1 + :widths: 45 10 10 10 10 10 10 + + * - Source + - Lines found + - Lines hit + - Line % + - Branches found + - Branches hit + - Branch % +""" + + +COVERAGE_EMPTY_BODY = """ + .. note:: + + No coverage data available for this component. Run ``bazel coverage`` + with the corresponding targets and rebuild the docs to populate this + table. +""" + + FEATURE_TEMPLATE = """\ Feature ------- From e8451d78583f9847c708ffa6d6d6f9e77fff631e Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Mon, 24 Aug 2026 12:35:31 +0000 Subject: [PATCH 14/25] refactor(module-verification-report): drop redundant docnames tracking The env.module_verification_report_docnames set duplicated information already present in env.module_verification_report_registry (every entry has a 'docname' field). Removed the separate set and its three lifecycle hooks (init_docnames/purge_docname/merge_docnames); annotate_testcase_results now checks membership via the registry directly. Reduces app.connect() calls from 8 to 5. Also removes a stale ':config:' docstring paragraph in __init__.py referring to a YAML-config option that no longer exists. --- .../__init__.py | 14 +--- .../directive.py | 12 +-- .../testcase_annotations.py | 33 ++------ .../tests/test_testcase_annotations.py | 82 +++++-------------- 4 files changed, 33 insertions(+), 108 deletions(-) diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index ef072860a..b7988e5aa 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -21,10 +21,6 @@ comp__baselibs_bit_manipulation, comp__baselibs_containers -An optional ``:config:`` YAML file can supply non-default workproducts or -per-component doc-id overrides for the rare case where component documents -do not follow the standard naming convention. - Implementation is split across: * :mod:`.templates` — RST templates + default workproduct lists + CSS @@ -47,12 +43,7 @@ purge_registry, ) from .directive import ModuleVerificationReportDirective -from .testcase_annotations import ( - annotate_testcase_results, - init_docnames, - merge_docnames, - purge_docname, -) +from .testcase_annotations import annotate_testcase_results def setup(app: Any) -> dict: @@ -62,11 +53,8 @@ def setup(app: Any) -> dict: "bazel-out/_coverage/_coverage_report.dat", "env", ) - app.connect("env-before-read-docs", init_docnames) app.connect("env-before-read-docs", init_registry) - app.connect("env-purge-doc", purge_docname) app.connect("env-purge-doc", purge_registry) - app.connect("env-merge-info", merge_docnames) app.connect("env-merge-info", merge_registry) app.connect("doctree-resolved", annotate_testcase_results) app.connect("build-finished", check_consistency) diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index a63628aaa..6c1672653 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -128,15 +128,11 @@ def run(self) -> list[nodes.Node]: container.document = self.state.document nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] - # Register this docname so the ``doctree-resolved`` hook in - # ``testcase_annotations`` knows to decorate testcase back-links - # with a coloured ``(passed)`` / ``(failed)`` badge here. - if not hasattr(self.env, "module_verification_report_docnames"): - self.env.module_verification_report_docnames = set() # type: ignore[attr-defined] - self.env.module_verification_report_docnames.add(self.env.docname) # type: ignore[attr-defined] - # Register module/feature/component metadata so the build-finished - # consistency check can validate need links without a pre-scan. + # consistency check can validate need links without a pre-scan, + # and so the ``doctree-resolved`` hook in ``testcase_annotations`` + # knows to decorate testcase back-links with a coloured + # ``(passed)`` / ``(failed)`` badge here. if not hasattr(self.env, "module_verification_report_registry"): self.env.module_verification_report_registry = {} # type: ignore[attr-defined] self.env.module_verification_report_registry[module_id] = { # type: ignore[attr-defined] diff --git a/src/extensions/score_module_verification_report/testcase_annotations.py b/src/extensions/score_module_verification_report/testcase_annotations.py index 5839f8a41..e9c30dce7 100644 --- a/src/extensions/score_module_verification_report/testcase_annotations.py +++ b/src/extensions/score_module_verification_report/testcase_annotations.py @@ -16,8 +16,8 @@ ``result`` field. The hook is a no-op unless the directive actually ran on the current -document — the directive registers its ``docname`` in -``env.module_verification_report_docnames`` so unrelated pages are left +document — checked by looking for a matching ``docname`` in +``env.module_verification_report_registry`` so unrelated pages are left untouched. """ @@ -54,8 +54,10 @@ def annotate_testcase_results(app, doctree, docname): """``doctree-resolved`` handler: append a coloured ``(<result>)`` span to every reference whose visible text starts with ``testcase__`` on pages where the module-verification-report directive was rendered.""" - docnames = getattr(app.env, "module_verification_report_docnames", None) - if not docnames or docname not in docnames: + registry = getattr(app.env, "module_verification_report_registry", None) + if not registry or not any( + info["docname"] == docname for info in registry.values() + ): return needs = _needs_view(app.env) @@ -82,26 +84,3 @@ def annotate_testcase_results(app, doctree, docname): # Keep the id text, append the coloured status inline. ref.replace(first, nodes.Text(text)) ref.append(nodes.raw("", status_html, format="html")) - - -def init_docnames(app, env, docnames): - """``env-before-read-docs`` handler: make sure the tracking set - exists on the shared env before parallel workers fork off.""" - if not hasattr(env, "module_verification_report_docnames"): - env.module_verification_report_docnames = set() - - -def purge_docname(app, env, docname): - """``env-purge-doc`` handler: drop stale entries on incremental - rebuilds so re-reads re-register themselves.""" - docnames = getattr(env, "module_verification_report_docnames", None) - if docnames is not None: - docnames.discard(docname) - - -def merge_docnames(app, env, docnames, other): - """``env-merge-info`` handler: union the per-worker sets back into - the main env when Sphinx runs a parallel read.""" - main = getattr(env, "module_verification_report_docnames", set()) - extra = getattr(other, "module_verification_report_docnames", set()) - env.module_verification_report_docnames = main | extra diff --git a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py index abea99006..7d77cde29 100644 --- a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py +++ b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py @@ -27,9 +27,6 @@ _FALLBACK_COLOR, RESULT_COLORS, annotate_testcase_results, - init_docnames, - merge_docnames, - purge_docname, ) @@ -61,55 +58,6 @@ def _app(env): return SimpleNamespace(env=env) -# --------------------------------------------------------------------------- -# init_docnames / purge_docname / merge_docnames -# --------------------------------------------------------------------------- - - -def test_init_docnames_creates_empty_set_when_absent(): - env = SimpleNamespace() - init_docnames(None, env, ["doc"]) - assert env.module_verification_report_docnames == set() - - -def test_init_docnames_preserves_existing_set(): - env = SimpleNamespace(module_verification_report_docnames={"already"}) - init_docnames(None, env, ["doc"]) - assert env.module_verification_report_docnames == {"already"} - - -def test_purge_docname_removes_entry(): - env = SimpleNamespace(module_verification_report_docnames={"a", "b"}) - purge_docname(None, env, "a") - assert env.module_verification_report_docnames == {"b"} - - -def test_purge_docname_ignores_missing_entry(): - env = SimpleNamespace(module_verification_report_docnames={"a"}) - purge_docname(None, env, "does-not-exist") - assert env.module_verification_report_docnames == {"a"} - - -def test_purge_docname_noop_when_attr_missing(): - env = SimpleNamespace() - purge_docname(None, env, "a") # must not raise - assert not hasattr(env, "module_verification_report_docnames") - - -def test_merge_docnames_unions_sets(): - main = SimpleNamespace(module_verification_report_docnames={"a"}) - other = SimpleNamespace(module_verification_report_docnames={"b", "c"}) - merge_docnames(None, main, ["b", "c"], other) - assert main.module_verification_report_docnames == {"a", "b", "c"} - - -def test_merge_docnames_when_main_has_no_attr(): - main = SimpleNamespace() - other = SimpleNamespace(module_verification_report_docnames={"b"}) - merge_docnames(None, main, ["b"], other) - assert main.module_verification_report_docnames == {"b"} - - # --------------------------------------------------------------------------- # annotate_testcase_results — happy paths # --------------------------------------------------------------------------- @@ -118,7 +66,7 @@ def test_merge_docnames_when_main_has_no_attr(): def test_annotates_passed_result_in_green(): doc, ref = _doctree_with_testcase_link("testcase__foo") env = SimpleNamespace( - module_verification_report_docnames={"my_report"}, + module_verification_report_registry={"mod__foo": {"docname": "my_report"}}, ) with _patch_needs({"testcase__foo": {"result": "passed"}}): annotate_testcase_results(_app(env), doc, "my_report") @@ -135,7 +83,9 @@ def test_annotates_passed_result_in_green(): def test_annotates_failed_result_in_red(): doc, ref = _doctree_with_testcase_link("testcase__bar") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs({"testcase__bar": {"result": "failed"}}): annotate_testcase_results(_app(env), doc, "r") html = ref.children[-1].astext() @@ -145,7 +95,9 @@ def test_annotates_failed_result_in_red(): def test_unknown_result_uses_fallback_color(): doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs({"testcase__x": {"result": "weird"}}): annotate_testcase_results(_app(env), doc, "r") html = ref.children[-1].astext() @@ -160,7 +112,9 @@ def test_unknown_result_uses_fallback_color(): def test_noop_when_docname_not_registered(): doc, ref = _doctree_with_testcase_link("testcase__foo") - env = SimpleNamespace(module_verification_report_docnames={"other"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__other": {"docname": "other"}} + ) with _patch_needs({"testcase__foo": {"result": "passed"}}): annotate_testcase_results(_app(env), doc, "my_report") # Untouched. @@ -178,7 +132,9 @@ def test_noop_when_attr_absent(): def test_noop_when_text_not_testcase(): doc, ref = _doctree_with_testcase_link("comp_req__foo") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs({"comp_req__foo": {"result": "passed"}}): annotate_testcase_results(_app(env), doc, "r") assert len(ref.children) == 1 @@ -186,7 +142,9 @@ def test_noop_when_text_not_testcase(): def test_noop_when_need_missing(): doc, ref = _doctree_with_testcase_link("testcase__missing") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs({}): # empty annotate_testcase_results(_app(env), doc, "r") assert len(ref.children) == 1 @@ -194,7 +152,9 @@ def test_noop_when_need_missing(): def test_noop_when_result_empty(): doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs({"testcase__x": {"result": ""}}): annotate_testcase_results(_app(env), doc, "r") assert len(ref.children) == 1 @@ -202,7 +162,9 @@ def test_noop_when_result_empty(): def test_noop_when_needs_view_unavailable(): doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace(module_verification_report_docnames={"r"}) + env = SimpleNamespace( + module_verification_report_registry={"mod__r": {"docname": "r"}} + ) with _patch_needs(None): # sphinx_needs not importable / not ready annotate_testcase_results(_app(env), doc, "r") assert len(ref.children) == 1 From ffaa07ab0391fd28498d060b9c57767167b54358 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Mon, 24 Aug 2026 13:51:08 +0000 Subject: [PATCH 15/25] Add mod_ver_report generation to module-verification-report directive Extend the directive with mandatory-only mod_ver_report generation, relying on score_metamodel for consistency checking: - templates.py: new MOD_VER_REPORT_TEMPLATE emitting only the 4 mandatory options (safety, security, status, verification_method) plus the mandatory belongs_to link. - rendering.py: new render_mod_ver_report(); render_report() gains an optional mod_ver_report parameter (existing callers unaffected). - directive.py: four new mandatory option_spec entries (safety, security, status, verification-method) with RST-error validation analogous to the existing :components: check; new _mod_ver_report_id_and_title() helper. - __init__.py: docstring updated with the new options. - Tests added for rendering and directive helpers. --- .../__init__.py | 11 ++++ .../directive.py | 58 ++++++++++++++++- .../rendering.py | 29 ++++++++- .../templates.py | 19 ++++++ .../tests/test_directive.py | 18 ++++++ .../tests/test_rendering.py | 64 +++++++++++++++++++ 6 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index b7988e5aa..12db2aadf 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -17,10 +17,21 @@ .. module-verification-report:: :module-id: mod__baselibs :feature-id: feat__baselibs + :safety: ASIL_B + :security: YES + :status: valid + :verification-method: test_and_inspection :components: comp__baselibs_json, comp__baselibs_bit_manipulation, comp__baselibs_containers +``safety``/``security``/``status``/``verification-method`` are the +mandatory options of the sphinx-needs ``mod_ver_report`` need type (see +metamodel.yaml). The directive emits one such need +(``belongs_to: module-id``) so the report is machine-readable and its +links are validated by score_metamodel's generic need-link checks — +not just rendered RST. + Implementation is split across: * :mod:`.templates` — RST templates + default workproduct lists + CSS diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 6c1672653..c1ae0dad9 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -21,9 +21,9 @@ from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles +from .coverage import load_coverage from .rendering import render_report from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS -from .coverage import load_coverage # Strip an optional ``[version==N]`` qualifier from a component id. _VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") @@ -54,6 +54,24 @@ def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: return result +# Mandatory options every ``mod_ver_report`` need requires (see +# metamodel.yaml) that this directive cannot derive on its own. +_MOD_VER_REPORT_OPTIONS = ("safety", "security", "status", "verification-method") + + +def _mod_ver_report_id_and_title(module_short: str) -> tuple[str, str]: + """Derive the ``mod_vrep__...`` need id and its human-readable title + from the module slug. + + The id follows the ``<Req Type>__<Abbreviations>__<Architectural + Element>`` scheme mandated for 3-part need types (``mod_ver_report`` + declares ``parts: 3`` in metamodel.yaml), so exactly two ``__`` + separators are required: ``mod_vrep__<module_short>__report``. + """ + title_case = module_short.replace("_", " ").title() + return f"mod_vrep__{module_short}__report", f"{title_case} Verification Report" + + class ModuleVerificationReportDirective(SphinxDirective): """Expand to the per-module verification report body. @@ -62,9 +80,17 @@ class ModuleVerificationReportDirective(SphinxDirective): .. module-verification-report:: :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b + :safety: QM + :security: YES + :status: valid + :verification-method: test_and_inspection ``feature-id`` and ``component-prefix`` are optional and derived from - ``module-id`` when omitted. + ``module-id`` when omitted. ``safety``/``security``/``status``/ + ``verification-method`` are the mandatory options of the sphinx-needs + ``mod_ver_report`` need type (see metamodel.yaml) — this directive + emits one such need (``belongs_to: module-id``) so the report is + machine-readable, not just a rendered page. """ required_arguments = 0 @@ -74,6 +100,11 @@ class ModuleVerificationReportDirective(SphinxDirective): "feature-id": str, "component-prefix": str, "components": str, + "safety": str, + "security": str, + "status": str, + "verification-method": str, + "version": str, } has_content = False @@ -104,6 +135,28 @@ def run(self) -> list[nodes.Node]: ) return [error] + missing = [opt for opt in _MOD_VER_REPORT_OPTIONS if not self.options.get(opt)] + if missing: + error = self.state_machine.reporter.error( + "module-verification-report: missing mandatory option(s) " + f"{', '.join(':' + m + ':' for m in missing)} required to " + "generate the mod_ver_report need", + line=self.lineno, + ) + return [error] + + report_id, report_title = _mod_ver_report_id_and_title(module_short) + mod_ver_report = { + "module_id": module_id, + "report_id": report_id, + "title": report_title, + "safety": self.options["safety"], + "security": self.options["security"], + "status": self.options["status"], + "verification_method": self.options["verification-method"], + "version": self.options.get("version", "1"), + } + rst_text = render_report( components, feature_id, @@ -113,6 +166,7 @@ def run(self) -> list[nodes.Node]: coverage_records=load_coverage( getattr(self.config, "mvr_coverage_lcov", "") ), + mod_ver_report=mod_ver_report, ) view_list = ViewList() source = "<module-verification-report>" diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 742a44e95..840992847 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -16,6 +16,7 @@ import re +from .coverage import FileCoverage, coverage_rows, records_for_slug from .templates import ( COMPONENT_COVERAGE_TEMPLATE, COMPONENT_TEMPLATE, @@ -23,10 +24,10 @@ COVERAGE_EMPTY_BODY, COVERAGE_TABLE_HEADER, FEATURE_TEMPLATE, + MOD_VER_REPORT_TEMPLATE, OVERVIEW_TEMPLATE, WP_TABLE_CSS, ) -from .coverage import FileCoverage, coverage_rows, records_for_slug def normalize_slug(text: str) -> str: @@ -145,6 +146,29 @@ def render_overview(components: list[dict]) -> str: return OVERVIEW_TEMPLATE.format(ids_literal=ids_literal) +def render_mod_ver_report( + module_id: str, + report_id: str, + title: str, + safety: str, + security: str, + status: str, + verification_method: str, + version: str = "1", +) -> str: + """Render the ``.. mod_ver_report::`` need declaration for *module_id*.""" + return MOD_VER_REPORT_TEMPLATE.format( + title=title, + report_id=report_id, + version=version, + safety=safety, + security=security, + status=status, + verification_method=verification_method, + module_id=module_id, + ) + + def render_report( components: list[dict], feature_id: str | None, @@ -152,8 +176,11 @@ def render_report( workproducts: list[dict], feature_workproducts: list[dict], coverage_records: list[FileCoverage] | None = None, + mod_ver_report: dict | None = None, ) -> str: parts = [WP_TABLE_CSS] + if mod_ver_report is not None: + parts.append(render_mod_ver_report(**mod_ver_report)) if feature_id is not None and feature_slug is not None: parts.append(render_feature(feature_id, feature_slug, feature_workproducts)) parts += [ diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index c00e5bf00..c3fbba5b4 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -170,6 +170,25 @@ """ +# Emits an actual sphinx-needs ``mod_ver_report`` need (see metamodel.yaml) +# so the module verification report is machine-readable, not just a rendered +# RST page. Only the four type-specific *mandatory* options + the globally +# mandatory ``version`` + the mandatory ``belongs_to`` link are set here — +# score_metamodel's generic need-link/option validation (the same mechanism +# used for every other need type) takes over from there. +MOD_VER_REPORT_TEMPLATE = """\ +.. mod_ver_report:: {title} + :id: {report_id} + :version: {version} + :safety: {safety} + :security: {security} + :status: {status} + :verification_method: {verification_method} + :belongs_to: {module_id} + +""" + + FEATURE_TEMPLATE = """\ Feature ------- diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 87f154080..7d7b37644 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -20,6 +20,7 @@ from __future__ import annotations from src.extensions.score_module_verification_report.directive import ( + _mod_ver_report_id_and_title, _parse_components, ) @@ -174,3 +175,20 @@ def test_feature_slug_splits_on_double_underscore(): def test_feature_slug_falls_back_to_full_id_when_no_double_underscore(): r = _resolve(option_feature_id="noprefix") assert r["feature_slug"] == "noprefix" + + +# --------------------------------------------------------------------------- +# Tests — _mod_ver_report_id_and_title +# --------------------------------------------------------------------------- + + +def test_mod_ver_report_id_and_title_basic(): + report_id, title = _mod_ver_report_id_and_title("baselibs") + assert report_id == "mod_vrep__baselibs__report" + assert title == "Baselibs Verification Report" + + +def test_mod_ver_report_id_and_title_underscored_slug(): + report_id, title = _mod_ver_report_id_and_title("bit_manipulation") + assert report_id == "mod_vrep__bit_manipulation__report" + assert title == "Bit Manipulation Verification Report" diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index e3a70fac1..d873d8459 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -18,6 +18,7 @@ normalize_slug, render_component, render_feature, + render_mod_ver_report, render_overview, render_report, slugify, @@ -114,6 +115,35 @@ def test_render_feature_substitutes_feature_id_and_slug() -> None: assert "Feature Inspection Statistics" in out +# --------------------------------------------------------------------------- +# render_mod_ver_report +# --------------------------------------------------------------------------- + + +def test_render_mod_ver_report_contains_only_mandatory_fields() -> None: + out = render_mod_ver_report( + module_id="mod__demo", + report_id="mod_vrep__demo__report", + title="Demo Verification Report", + safety="QM", + security="YES", + status="valid", + verification_method="test_and_inspection", + ) + assert ".. mod_ver_report:: Demo Verification Report" in out + assert ":id: mod_vrep__demo__report" in out + assert ":version: 1" in out + assert ":safety: QM" in out + assert ":security: YES" in out + assert ":status: valid" in out + assert ":verification_method: test_and_inspection" in out + assert ":belongs_to: mod__demo" in out + # Only mandatory fields — no optional coverage/percent/realizes options. + assert "coverage_percent" not in out + assert ":realizes:" not in out + assert ":covers:" not in out + + # --------------------------------------------------------------------------- # render_report # --------------------------------------------------------------------------- @@ -139,3 +169,37 @@ def test_render_report_assembles_all_sections() -> None: assert 'id in ["comp__demo_a", "comp__demo_b"]' in out assert '"comp__demo_a" in satisfied_by' in out assert '"comp__demo_b" in satisfied_by' in out + + +def test_render_report_includes_mod_ver_report_when_given() -> None: + components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] + out = render_report( + components=components, + feature_id=None, + feature_slug=None, + workproducts=_WP, + feature_workproducts=_WP, + mod_ver_report={ + "module_id": "mod__demo", + "report_id": "mod_vrep__demo__report", + "title": "Demo Verification Report", + "safety": "QM", + "security": "YES", + "status": "valid", + "verification_method": "test_and_inspection", + }, + ) + assert ".. mod_ver_report:: Demo Verification Report" in out + assert ":belongs_to: mod__demo" in out + + +def test_render_report_omits_mod_ver_report_when_absent() -> None: + components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] + out = render_report( + components=components, + feature_id=None, + feature_slug=None, + workproducts=_WP, + feature_workproducts=_WP, + ) + assert ".. mod_ver_report::" not in out From 8b75c1a1d4738a66d355ceb15fd1d3ec2fa7a4f4 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Tue, 25 Aug 2026 06:17:01 +0000 Subject: [PATCH 16/25] fix: collapse single-line logger.info call (ruff-format) --- src/extensions/score_module_verification_report/coverage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py index ed7414cd9..9fdd1d00b 100644 --- a/src/extensions/score_module_verification_report/coverage.py +++ b/src/extensions/score_module_verification_report/coverage.py @@ -116,9 +116,7 @@ def load_coverage(config_path: str) -> list[FileCoverage]: if not path.is_absolute(): ws_root = _workspace_root() if ws_root is None: - logger.info( - "mvr coverage: workspace root not found; skipping LCOV load" - ) + logger.info("mvr coverage: workspace root not found; skipping LCOV load") return [] path = ws_root / path if not path.is_file(): From dc97b6369278f6d96000498939db3085df3fc682 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Tue, 25 Aug 2026 09:55:23 +0000 Subject: [PATCH 17/25] docs(module-verification-report): document docs_and_test macro --- .../docs/module_verification_report.rst | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 16f4ec22c..fa2d873d3 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -68,3 +68,107 @@ Options - Prefix stripped from each component id to derive its slug (used for section headings and document-id matching). Default: ``comp__<module-short>_``. + + * - ``:safety:`` + - yes + - ASIL classification of the module. One of ``QM`` or ``ASIL_B``. + + * - ``:security:`` + - yes + - Whether the module is security-relevant. One of ``YES`` or ``NO``. + + * - ``:status:`` + - yes + - Review status of the report. One of ``valid`` or ``invalid``. + + * - ``:verification-method:`` + - yes + - Free-text description of how the module was verified, e.g. + ``test_and_inspection``. + + * - ``:version:`` + - no + - Version of the emitted ``mod_ver_report`` need. Default: ``1``. + +Metamodel validation +-------------------- + +``:safety:``, ``:security:``, ``:status:``, ``:verification-method:`` and +``:version:`` are not just directive options — the directive uses them to +emit a single sphinx-needs ``mod_ver_report`` need (id +``mod_vrep__<module-short>__report``, linked ``belongs_to`` the module's +``.. mod::`` need). This need type, its id format and the allowed values +for each option are declared in ``score_metamodel``'s ``metamodel.yaml`` +(``mod_ver_report`` entry). + +Every generated need is checked against that definition by the +``score_metamodel`` Sphinx extension as part of the regular build. If any +value does not match the expected pattern (e.g. ``:safety: ASIL_D``, which +is not one of ``QM``/``ASIL_B``), a mandatory option is missing, or the id +does not follow the required ``<prefix>__<abbreviation>__<element>`` +scheme, ``score_metamodel`` reports a warning. Since the documentation +build runs Sphinx with ``-W`` (warnings treated as errors), any such +mismatch aborts the build instead of silently producing an inconsistent +report. + +Running tests and docs together: ``docs_and_test`` +---------------------------------------------------- + +A verification report is only meaningful if it reflects a fresh test run. +Bazel itself has no way to make a build target depend on a test's +*execution*, so ``score_docs_as_code`` ships a small macro, +``docs_and_test`` (``@score_docs_as_code//:bzl/docs_and_test.bzl``), that +chains the two steps outside the dependency graph: + +.. code-block:: python + + load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") + + docs_and_test( + name = "module_verification_report", + test_targets = ["//score/..."], + docs_target = "//:docs", + ) + +Calling the macro declares two ``py_binary`` targets (declaration only — +neither runs anything at ``BUILD``-load time): + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Target + - What ``bazel run`` does + + * - ``<name>`` + - Runs ``bazel coverage`` (or ``bazel test``) on ``test_targets``, + then ``bazel run <docs_target>`` (default ``//:docs``) — builds + static HTML. + + * - ``<name>_preview`` + - Same test/coverage step, then ``bazel run <preview_target>`` + (default ``//:live_preview``) — starts the live-reloading preview + server instead of just building HTML. Omitted if + ``preview_target = None`` is passed to the macro. + +Each target is fully independent: running one does not run the other, and +running either does not build/execute both. The driver aborts the whole +pipeline on the first non-zero exit code, so a failing test (or coverage +run) prevents a stale or incomplete report from being built. + +By default (``coverage = True``) the test step is +``bazel coverage --combined_report=lcov``, whose aggregated LCOV file is +what the ``.. module-verification-report::`` directive reads (via the +``mvr_coverage_lcov`` Sphinx config value) to render the coverage +statistics in the report. Set ``coverage = False`` to fall back to plain +``bazel test`` — faster, but without coverage numbers in the report. + +Extra Bazel flags for the nested test/coverage invocation (e.g. a +``--config``) are not hard-coded in the macro; pass them after ``--`` on +the command line:: + + bazel run //:module_verification_report -- \ + --test-flag=--config=bl-aarch64-linux + +``--test-flag`` is repeatable and forwarded as-is to the underlying +``bazel test``/``bazel coverage`` call. From 05856fae4d0c563c2446492d70420512f8f4bedf Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Tue, 25 Aug 2026 13:22:40 +0000 Subject: [PATCH 18/25] fix(module-verification-report): omit zero-coverage rows; add -- separator for negative bazel targets - coverage.py: blank percentage and skip table rows entirely when a file has no line/branch data at all (e.g. headers never instrumented), instead of misleadingly showing 0%. - run_docs_and_test.py: insert '--' before test targets so negative Bazel target patterns (e.g. '-//foo:bar') are parsed correctly instead of being mistaken for flags. --- bzl/run_docs_and_test.py | 3 +- .../coverage.py | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py index 4e543dd21..ad59380cc 100644 --- a/bzl/run_docs_and_test.py +++ b/bzl/run_docs_and_test.py @@ -106,11 +106,12 @@ def main() -> None: "coverage", "--combined_report=lcov", *args.test_flag, + "--", *test_targets, ] ) else: - _run(["bazel", "test", *args.test_flag, *test_targets]) + _run(["bazel", "test", *args.test_flag, "--", *test_targets]) _run(["bazel", "run", args.docs]) diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py index 9fdd1d00b..ec1c2e844 100644 --- a/src/extensions/score_module_verification_report/coverage.py +++ b/src/extensions/score_module_verification_report/coverage.py @@ -147,24 +147,42 @@ def records_for_slug( ] +def _pct_str(hit: int, found: int) -> str: + """Format a hit/found ratio as a percentage string. + + Returns an empty string when *found* is 0 — there is nothing to + cover, so showing ``0.0`` would misleadingly read as "0% covered". + """ + return f"{100.0 * hit / found:.1f}" if found else "" + + def coverage_rows(records: list[FileCoverage]) -> str: """Render *records* as ``list-table`` rows (no header row). + Files with neither line nor branch data (``lines_found == 0`` and + ``branches_found == 0``, e.g. headers never instrumented by the + coverage run) are skipped — a row of all zeroes carries no + information and reads as "0% covered" even though nothing was + measured at all. + Returns an empty string when *records* is empty so callers can decide to omit the whole dropdown block. """ if not records: return "" + visible = [r for r in records if r.lines_found or r.branches_found] + if not visible: + return "" lines: list[str] = [] total = FileCoverage(source="**Total**") - for r in sorted(records, key=lambda x: x.source): + for r in sorted(visible, key=lambda x: x.source): lines.append(f" * - ``{r.source}``") lines.append(f" - {r.lines_found}") lines.append(f" - {r.lines_hit}") - lines.append(f" - {r.line_pct:.1f}") + lines.append(f" - {_pct_str(r.lines_hit, r.lines_found)}") lines.append(f" - {r.branches_found}") lines.append(f" - {r.branches_hit}") - lines.append(f" - {r.branch_pct:.1f}") + lines.append(f" - {_pct_str(r.branches_hit, r.branches_found)}") total.lines_found += r.lines_found total.lines_hit += r.lines_hit total.branches_found += r.branches_found @@ -172,8 +190,8 @@ def coverage_rows(records: list[FileCoverage]) -> str: lines.append(f" * - {total.source}") lines.append(f" - {total.lines_found}") lines.append(f" - {total.lines_hit}") - lines.append(f" - {total.line_pct:.1f}") + lines.append(f" - {_pct_str(total.lines_hit, total.lines_found)}") lines.append(f" - {total.branches_found}") lines.append(f" - {total.branches_hit}") - lines.append(f" - {total.branch_pct:.1f}") + lines.append(f" - {_pct_str(total.branches_hit, total.branches_found)}") return "\n".join(lines) From 41bcf536faf038f20b07c13ce77e45db29c91925 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 26 Aug 2026 07:07:21 +0000 Subject: [PATCH 19/25] refactor: split docs_and_test macro out into #759 Removes bzl/docs_and_test.bzl, bzl/run_docs_and_test.py and the BUILD exports_files entry for the driver script, along with the 'Running tests and docs together: docs_and_test' documentation section, since this PR should only cover the score_module_verification_report Sphinx extension. The docs_and_test macro (including the -- separator fix for negative Bazel target patterns) now lives in https://github.com/eclipse-score/docs-as-code/pull/759. --- BUILD | 1 - bzl/docs_and_test.bzl | 124 ------------------ bzl/run_docs_and_test.py | 119 ----------------- .../docs/module_verification_report.rst | 62 --------- 4 files changed, 306 deletions(-) delete mode 100644 bzl/docs_and_test.bzl delete mode 100644 bzl/run_docs_and_test.py diff --git a/BUILD b/BUILD index 38174f0ba..4d7ca80fc 100644 --- a/BUILD +++ b/BUILD @@ -17,7 +17,6 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "default_conf.py.tpl", "pyproject.toml", - "bzl/run_docs_and_test.py", ]) docs( diff --git a/bzl/docs_and_test.bzl b/bzl/docs_and_test.bzl deleted file mode 100644 index 3a1cef3e9..000000000 --- a/bzl/docs_and_test.bzl +++ /dev/null @@ -1,124 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Bazel macro that chains ``bazel test`` (or ``bazel coverage``) with a -docs target in one command. - -Usage in a consumer ``BUILD``:: - - load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") - - docs_and_test( - name = "docs_full", - test_targets = ["//score/..."], - ) - -Generates two ``py_binary`` targets:: - - bazel run //:docs_full # tests/coverage, then //:docs - bazel run //:docs_full_preview # tests/coverage, then //:live_preview - -Bazel itself has no mechanism to make a build target depend on the -execution of a test, so the orchestration lives outside the dependency -graph in a small Python driver shipped with this module. -""" - -load("@rules_python//python:defs.bzl", "py_binary") - -_DEFAULT_DRIVER = Label("@score_docs_as_code//:bzl/run_docs_and_test.py") - -def _pipeline_binary(name, driver, test_targets, coverage, run_target, help_text): - py_binary( - name = name, - srcs = [driver], - main = driver, - args = [ - "--tests", - ",".join(test_targets), - "--coverage" if coverage else "--no-coverage", - "--docs", - run_target, - ], - tags = ["cli_help=%s:\nbazel run //:%s" % (help_text, name)], - ) - -def docs_and_test( - name, - test_targets, - coverage = True, - docs_target = "//:docs", - preview_target = "//:live_preview", - driver = None): - """Create ``py_binary`` targets that run tests, then a docs command. - - Two targets are generated: - - * ``<name>`` — runs tests, then ``docs_target``. - * ``<name>_preview`` — runs tests, then ``preview_target`` - (typically ``//:live_preview``). - - Coverage is on by default. When ``coverage = True`` the pipeline uses - ``bazel coverage --combined_report=lcov`` on ``test_targets`` instead of - plain ``bazel test``; that runs the same tests with LLVM/GCC coverage - instrumentation and produces the aggregated LCOV at - ``bazel-out/_coverage/_coverage_report.dat`` in a single rebuild. - Targets without unit tests simply contribute no coverage data. Set - ``coverage = False`` to fall back to plain ``bazel test`` (faster on - first run, no LCOV). - - Extra Bazel CLI flags (e.g. ``--config=bl-x86_64-linux``) are not - hard-coded in the ``BUILD`` file. Pass them on the command line after - ``--``:: - - bazel run //:docs_full -- \\ - --test-flag=--config=bl-x86_64-linux - - ``--test-flag`` is repeatable and forwarded to whichever underlying - Bazel command runs (``test`` or ``coverage``). - - Args: - name: Base target name; invoke with ``bazel run //:<name>`` or - ``bazel run //:<name>_preview``. - test_targets: Bazel labels/patterns for the test/coverage step - (e.g. ``["//score/..."]``). Pass ``[]`` to skip. - coverage: If ``True`` (default), replace ``bazel test`` with - ``bazel coverage --combined_report=lcov`` so the docs build can - pick up per-source-file LCOV data. If ``False``, run plain - ``bazel test`` and produce no LCOV. - docs_target: Label of the docs binary to invoke via ``bazel run``. - Defaults to ``//:docs``. - preview_target: Label of the live-preview binary to invoke via - ``bazel run``. Defaults to ``//:live_preview``. Pass ``None`` to - skip generating the preview target. - driver: Label of the Python driver script. Defaults to the driver - shipped with ``score_docs_as_code``; only override when you want - to inject a custom driver. - """ - driver = driver or _DEFAULT_DRIVER - _pipeline_binary( - name = name, - driver = driver, - test_targets = test_targets, - coverage = coverage, - run_target = docs_target, - help_text = "Run tests, then build documentation", - ) - - if preview_target: - _pipeline_binary( - name = name + "_preview", - driver = driver, - test_targets = test_targets, - coverage = coverage, - run_target = preview_target, - help_text = "Run tests, then start the docs live preview", - ) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py deleted file mode 100644 index ad59380cc..000000000 --- a/bzl/run_docs_and_test.py +++ /dev/null @@ -1,119 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Driver for the :bzl:`docs_and_test` macro. - -Runs either ``bazel test`` or ``bazel coverage`` on the configured targets, -then ``bazel run`` on the docs target. Aborts the pipeline on the first -non-zero exit code so a failing step does not silently ship stale docs. - -Extra Bazel CLI flags (typically ``--config=…``) are **not** baked into -the ``BUILD`` file; pass them at ``bazel run`` time after ``--``:: - - bazel run //:docs_full -- \\ - --test-flag=--config=bl-x86_64-linux - -``--test-flag`` is repeatable and forwarded to whichever underlying Bazel -command runs (``test`` or ``coverage``). - -The script must be invoked from the workspace root — ``bazel run`` sets -``BUILD_WORKSPACE_DIRECTORY`` accordingly, so we chdir there before -invoking any nested Bazel commands. -""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys - - -def _split(csv: str) -> list[str]: - return [x for x in csv.split(",") if x] - - -def _run(cmd: list[str]) -> None: - print(f">>> {' '.join(cmd)}", flush=True) - try: - result = subprocess.run(cmd, check=False) - except KeyboardInterrupt: - # Ctrl+C hits both us and the child via the process group. The child - # already exited with 130; propagate the same status without dumping - # a Python traceback so `docs_full_preview` behaves like a bare - # `bazel run //:live_preview`. - sys.exit(130) - if result.returncode != 0: - print( - f"!!! step failed with exit code {result.returncode}: {' '.join(cmd)}", - file=sys.stderr, - ) - sys.exit(result.returncode) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--tests", - default="", - help="Comma-separated Bazel labels/patterns for the test step. " - "Empty string skips the step entirely.", - ) - parser.add_argument( - "--coverage", - dest="coverage", - action=argparse.BooleanOptionalAction, - default=True, - help="Run 'bazel coverage --combined_report=lcov' instead of " - "'bazel test' on --tests. Default: True.", - ) - parser.add_argument( - "--docs", - required=True, - help="Bazel label of the docs binary to invoke via 'bazel run'.", - ) - parser.add_argument( - "--test-flag", - action="append", - default=[], - help="Extra CLI flag forwarded to the test/coverage step " - "(repeatable). Typically '--config=…'.", - ) - args = parser.parse_args() - - # `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the workspace root; nested - # bazel invocations must run from there so they see MODULE.bazel etc. - workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") - if workspace: - os.chdir(workspace) - - test_targets = _split(args.tests) - - if test_targets: - if args.coverage: - _run( - [ - "bazel", - "coverage", - "--combined_report=lcov", - *args.test_flag, - "--", - *test_targets, - ] - ) - else: - _run(["bazel", "test", *args.test_flag, "--", *test_targets]) - _run(["bazel", "run", args.docs]) - - -if __name__ == "__main__": - main() diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index fa2d873d3..afb742cd0 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -110,65 +110,3 @@ scheme, ``score_metamodel`` reports a warning. Since the documentation build runs Sphinx with ``-W`` (warnings treated as errors), any such mismatch aborts the build instead of silently producing an inconsistent report. - -Running tests and docs together: ``docs_and_test`` ----------------------------------------------------- - -A verification report is only meaningful if it reflects a fresh test run. -Bazel itself has no way to make a build target depend on a test's -*execution*, so ``score_docs_as_code`` ships a small macro, -``docs_and_test`` (``@score_docs_as_code//:bzl/docs_and_test.bzl``), that -chains the two steps outside the dependency graph: - -.. code-block:: python - - load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") - - docs_and_test( - name = "module_verification_report", - test_targets = ["//score/..."], - docs_target = "//:docs", - ) - -Calling the macro declares two ``py_binary`` targets (declaration only — -neither runs anything at ``BUILD``-load time): - -.. list-table:: - :header-rows: 1 - :widths: 30 70 - - * - Target - - What ``bazel run`` does - - * - ``<name>`` - - Runs ``bazel coverage`` (or ``bazel test``) on ``test_targets``, - then ``bazel run <docs_target>`` (default ``//:docs``) — builds - static HTML. - - * - ``<name>_preview`` - - Same test/coverage step, then ``bazel run <preview_target>`` - (default ``//:live_preview``) — starts the live-reloading preview - server instead of just building HTML. Omitted if - ``preview_target = None`` is passed to the macro. - -Each target is fully independent: running one does not run the other, and -running either does not build/execute both. The driver aborts the whole -pipeline on the first non-zero exit code, so a failing test (or coverage -run) prevents a stale or incomplete report from being built. - -By default (``coverage = True``) the test step is -``bazel coverage --combined_report=lcov``, whose aggregated LCOV file is -what the ``.. module-verification-report::`` directive reads (via the -``mvr_coverage_lcov`` Sphinx config value) to render the coverage -statistics in the report. Set ``coverage = False`` to fall back to plain -``bazel test`` — faster, but without coverage numbers in the report. - -Extra Bazel flags for the nested test/coverage invocation (e.g. a -``--config``) are not hard-coded in the macro; pass them after ``--`` on -the command line:: - - bazel run //:module_verification_report -- \ - --test-flag=--config=bl-aarch64-linux - -``--test-flag`` is repeatable and forwarded as-is to the underlying -``bazel test``/``bazel coverage`` call. From 4666ce2e52d9f98f9966db82c5dec87a19359092 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov <anton.krivoborodov@bmw.de> Date: Wed, 26 Aug 2026 07:16:18 +0000 Subject: [PATCH 20/25] refactor: remove testcase result annotation, now covered by #739 The doctree-resolved hook that decorates testcase back-links with a coloured (passed)/(failed) badge has been extracted, generalized (it now also covers GitHub testlink references on requirements, not just links on module-verification-report pages) and moved to score_source_code_linker in #739. Drop the local duplicate: - testcase_annotations.py and its tests - the doctree-resolved registration + docstring bullet in __init__.py - the corresponding comment in directive.py The module_verification_report_registry itself is kept: it is still used by consistency_checks.py for the build-finished component-link validation, independent of testcase annotation. Depends on: eclipse-score/docs-as-code#739 --- .../__init__.py | 9 +- .../directive.py | 5 +- .../testcase_annotations.py | 86 --------- .../tests/test_testcase_annotations.py | 170 ------------------ 4 files changed, 6 insertions(+), 264 deletions(-) delete mode 100644 src/extensions/score_module_verification_report/testcase_annotations.py delete mode 100644 src/extensions/score_module_verification_report/tests/test_testcase_annotations.py diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 12db2aadf..a14802752 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -37,10 +37,13 @@ * :mod:`.templates` — RST templates + default workproduct lists + CSS * :mod:`.rendering` — template expansion / report body assembly * :mod:`.directive` — the ``ModuleVerificationReportDirective`` class -* :mod:`.testcase_annotations` — ``doctree-resolved`` badge decoration - for ``testcase__…`` back-links on pages that render the directive * :mod:`.consistency_checks` — ``build-finished`` validation that every component is properly linked in the needs graph + +Testcase back-links rendered by this directive are annotated with a +``(passed)`` / ``(failed)`` result badge by +``score_source_code_linker``'s ``doctree-resolved`` hook, not by this +extension. """ from __future__ import annotations @@ -54,7 +57,6 @@ purge_registry, ) from .directive import ModuleVerificationReportDirective -from .testcase_annotations import annotate_testcase_results def setup(app: Any) -> dict: @@ -67,7 +69,6 @@ def setup(app: Any) -> dict: app.connect("env-before-read-docs", init_registry) app.connect("env-purge-doc", purge_registry) app.connect("env-merge-info", merge_registry) - app.connect("doctree-resolved", annotate_testcase_results) app.connect("build-finished", check_consistency) return { "version": "0.9", diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index c1ae0dad9..362e5cd7a 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -183,10 +183,7 @@ def run(self) -> list[nodes.Node]: nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] # Register module/feature/component metadata so the build-finished - # consistency check can validate need links without a pre-scan, - # and so the ``doctree-resolved`` hook in ``testcase_annotations`` - # knows to decorate testcase back-links with a coloured - # ``(passed)`` / ``(failed)`` badge here. + # consistency check can validate need links without a pre-scan. if not hasattr(self.env, "module_verification_report_registry"): self.env.module_verification_report_registry = {} # type: ignore[attr-defined] self.env.module_verification_report_registry[module_id] = { # type: ignore[attr-defined] diff --git a/src/extensions/score_module_verification_report/testcase_annotations.py b/src/extensions/score_module_verification_report/testcase_annotations.py deleted file mode 100644 index e9c30dce7..000000000 --- a/src/extensions/score_module_verification_report/testcase_annotations.py +++ /dev/null @@ -1,86 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Post-processing hook that decorates ``testcase__…`` back-links inside a -rendered module verification report with a coloured -``(passed)`` / ``(failed)`` badge derived from each testcase need's -``result`` field. - -The hook is a no-op unless the directive actually ran on the current -document — checked by looking for a matching ``docname`` in -``env.module_verification_report_registry`` so unrelated pages are left -untouched. -""" - -from __future__ import annotations - -from typing import Any - -from docutils import nodes - -# Colours match the pie-chart palette used by the report body. -RESULT_COLORS = { - "passed": "#37a12d", - "failed": "#ca2828", - "skipped": "#f0a500", - "disabled": "#888888", -} -_FALLBACK_COLOR = "#666666" - - -def _needs_view(env: Any): - """Return the sphinx-needs ``NeedsView`` for ``env`` or ``None`` if - sphinx-needs is not available / not initialised yet.""" - try: - from sphinx_needs.data import SphinxNeedsData - except ImportError: - return None - try: - return SphinxNeedsData(env).get_needs_view() - except Exception: - return None - - -def annotate_testcase_results(app, doctree, docname): - """``doctree-resolved`` handler: append a coloured ``(<result>)`` span - to every reference whose visible text starts with ``testcase__`` on - pages where the module-verification-report directive was rendered.""" - registry = getattr(app.env, "module_verification_report_registry", None) - if not registry or not any( - info["docname"] == docname for info in registry.values() - ): - return - - needs = _needs_view(app.env) - if needs is None: - return - - for ref in list(doctree.findall(nodes.reference)): - if not ref.children: - continue - first = ref.children[0] - if not isinstance(first, nodes.Text): - continue - text = first.astext() - if not text.startswith("testcase__"): - continue - need = needs.get(text) - if not need: - continue - result = need.get("result") or "" - if not result: - continue - color = RESULT_COLORS.get(result, _FALLBACK_COLOR) - status_html = f'<span style="color:{color};font-weight:bold"> ({result})</span>' - # Keep the id text, append the coloured status inline. - ref.replace(first, nodes.Text(text)) - ref.append(nodes.raw("", status_html, format="html")) diff --git a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py b/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py deleted file mode 100644 index 7d77cde29..000000000 --- a/src/extensions/score_module_verification_report/tests/test_testcase_annotations.py +++ /dev/null @@ -1,170 +0,0 @@ -# ******************************************************************************* -# 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 tests for -:mod:`score_module_verification_report.testcase_annotations`.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import patch - -from docutils import nodes - -from src.extensions.score_module_verification_report import ( - testcase_annotations as ta, -) -from src.extensions.score_module_verification_report.testcase_annotations import ( # noqa: E501 - _FALLBACK_COLOR, - RESULT_COLORS, - annotate_testcase_results, -) - - -class _FakeNeedsView: - def __init__(self, needs): - self._needs = needs - - def get(self, need_id): - return self._needs.get(need_id) - - -def _patch_needs(needs): - """Return a context-manager patching the module-level ``_needs_view`` - helper so tests do not depend on sphinx-needs being importable.""" - view = _FakeNeedsView(needs) if needs is not None else None - return patch.object(ta, "_needs_view", lambda env: view) - - -def _doctree_with_testcase_link(text, refid="testcase__foo"): - """Build a tiny docutils tree containing a single reference whose - visible text is ``text`` (mimicking a sphinx-needs back-link).""" - doc = nodes.document(None, None) # type: ignore[arg-type] - ref = nodes.reference("", "", nodes.Text(text), refid=refid) - doc.append(ref) - return doc, ref - - -def _app(env): - return SimpleNamespace(env=env) - - -# --------------------------------------------------------------------------- -# annotate_testcase_results — happy paths -# --------------------------------------------------------------------------- - - -def test_annotates_passed_result_in_green(): - doc, ref = _doctree_with_testcase_link("testcase__foo") - env = SimpleNamespace( - module_verification_report_registry={"mod__foo": {"docname": "my_report"}}, - ) - with _patch_needs({"testcase__foo": {"result": "passed"}}): - annotate_testcase_results(_app(env), doc, "my_report") - - # Original text preserved as first child. - assert isinstance(ref.children[0], nodes.Text) - assert ref.children[0].astext() == "testcase__foo" - # Coloured raw HTML span appended. - assert isinstance(ref.children[-1], nodes.raw) - html = ref.children[-1].astext() - assert RESULT_COLORS["passed"] in html - assert "(passed)" in html - - -def test_annotates_failed_result_in_red(): - doc, ref = _doctree_with_testcase_link("testcase__bar") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs({"testcase__bar": {"result": "failed"}}): - annotate_testcase_results(_app(env), doc, "r") - html = ref.children[-1].astext() - assert RESULT_COLORS["failed"] in html - assert "(failed)" in html - - -def test_unknown_result_uses_fallback_color(): - doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs({"testcase__x": {"result": "weird"}}): - annotate_testcase_results(_app(env), doc, "r") - html = ref.children[-1].astext() - assert _FALLBACK_COLOR in html - assert "(weird)" in html - - -# --------------------------------------------------------------------------- -# annotate_testcase_results — no-op paths -# --------------------------------------------------------------------------- - - -def test_noop_when_docname_not_registered(): - doc, ref = _doctree_with_testcase_link("testcase__foo") - env = SimpleNamespace( - module_verification_report_registry={"mod__other": {"docname": "other"}} - ) - with _patch_needs({"testcase__foo": {"result": "passed"}}): - annotate_testcase_results(_app(env), doc, "my_report") - # Untouched. - assert len(ref.children) == 1 - assert ref.children[0].astext() == "testcase__foo" - - -def test_noop_when_attr_absent(): - doc, ref = _doctree_with_testcase_link("testcase__foo") - env = SimpleNamespace() - with _patch_needs({"testcase__foo": {"result": "passed"}}): - annotate_testcase_results(_app(env), doc, "my_report") - assert len(ref.children) == 1 - - -def test_noop_when_text_not_testcase(): - doc, ref = _doctree_with_testcase_link("comp_req__foo") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs({"comp_req__foo": {"result": "passed"}}): - annotate_testcase_results(_app(env), doc, "r") - assert len(ref.children) == 1 - - -def test_noop_when_need_missing(): - doc, ref = _doctree_with_testcase_link("testcase__missing") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs({}): # empty - annotate_testcase_results(_app(env), doc, "r") - assert len(ref.children) == 1 - - -def test_noop_when_result_empty(): - doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs({"testcase__x": {"result": ""}}): - annotate_testcase_results(_app(env), doc, "r") - assert len(ref.children) == 1 - - -def test_noop_when_needs_view_unavailable(): - doc, ref = _doctree_with_testcase_link("testcase__x") - env = SimpleNamespace( - module_verification_report_registry={"mod__r": {"docname": "r"}} - ) - with _patch_needs(None): # sphinx_needs not importable / not ready - annotate_testcase_results(_app(env), doc, "r") - assert len(ref.children) == 1 From ef19a7dbb951a6322273516895877d72add3f7de Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak <maximilian.pollak@qorix.com> Date: Thu, 27 Aug 2026 18:27:37 +0200 Subject: [PATCH 21/25] WIP: Graph checks introduces & covers => features & components --- .../docs/module_verification_report.rst | 64 +++- .../checks/mod_ver_report_checks.py | 122 +++++++ src/extensions/score_metamodel/metamodel.yaml | 3 +- .../__init__.py | 30 +- .../consistency_checks.py | 155 --------- .../directive.py | 100 +++--- .../rendering.py | 45 ++- .../templates.py | 6 +- .../tests/test_consistency_checks.py | 308 ------------------ .../tests/test_directive.py | 70 ++-- .../tests/test_rendering.py | 74 ++++- 11 files changed, 402 insertions(+), 575 deletions(-) create mode 100644 src/extensions/score_metamodel/checks/mod_ver_report_checks.py delete mode 100644 src/extensions/score_module_verification_report/consistency_checks.py delete mode 100644 src/extensions/score_module_verification_report/tests/test_consistency_checks.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index afb742cd0..877cff025 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -34,6 +34,11 @@ Typical usage (``verification_report/module_verification_report.rst``): .. module-verification-report:: :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b + :features: feat__mymodule + :safety: QM + :security: YES + :status: valid + :verification-method: test_and_inspection .. _mvr_directive: @@ -51,17 +56,23 @@ Options * - ``:module-id:`` - yes - sphinx-needs id of the ``.. mod::`` need (e.g. ``mod__mymodule``). - Drives defaults for ``:feature-id:`` and ``:component-prefix:``. + Drives the default for ``:component-prefix:``. * - ``:components:`` - yes - - Comma-separated list of ``.. comp::`` need ids. Multi-line values - are supported. Optional ``[version==N]`` qualifiers are stripped. + - Comma-separated list of ``.. comp::`` need ids. Named after the + ``components`` link of the ``mod_ver_report`` need type, which it + populates verbatim. Multi-line values are supported. Optional + ``[version==N]`` qualifiers are stripped. - * - ``:feature-id:`` - - no - - sphinx-needs id of the ``.. feat::`` need. Default: - ``feat__<module-short>`` (derived from ``:module-id:``). + * - ``:features:`` + - yes + - Comma-separated list of ``.. feat::`` need ids. Named after the + ``features`` link of the ``mod_ver_report`` need type, which it + populates verbatim. Usually a single id; one ``Feature`` section is + rendered per entry. Not derived from ``:module-id:`` — guessing a + mandatory traceability link would silently produce a dangling link + whenever the guess is wrong. * - ``:component-prefix:`` - no @@ -97,9 +108,11 @@ Metamodel validation ``:version:`` are not just directive options — the directive uses them to emit a single sphinx-needs ``mod_ver_report`` need (id ``mod_vrep__<module-short>__report``, linked ``belongs_to`` the module's -``.. mod::`` need). This need type, its id format and the allowed values -for each option are declared in ``score_metamodel``'s ``metamodel.yaml`` -(``mod_ver_report`` entry). +``.. mod::`` need). ``:components:`` and ``:features:`` are passed straight +through to the need's ``components`` and ``features`` links, which +``metamodel.yaml`` declares mandatory and types to ``comp`` / ``feat``. This +need type, its id format and the allowed values for each option are declared +in ``score_metamodel``'s ``metamodel.yaml`` (``mod_ver_report`` entry). Every generated need is checked against that definition by the ``score_metamodel`` Sphinx extension as part of the regular build. If any @@ -110,3 +123,34 @@ scheme, ``score_metamodel`` reports a warning. Since the documentation build runs Sphinx with ``-W`` (warnings treated as errors), any such mismatch aborts the build instead of silently producing an inconsistent report. + +Graph consistency +----------------- + +Because the emitted need records which architecture needs the report +describes, the report can be cross-checked against them. That is done by +``score_metamodel``'s ``check_mod_ver_report_links`` graph check +(``src/extensions/score_metamodel/checks/mod_ver_report_checks.py``), which +runs together with every other metamodel check — there is no separate +build-finished pass any more. + +It enforces two rules per ``mod_ver_report`` need: + +#. The need's ``components`` and the module's ``:includes:`` must be the same + set. The report and the module are two independent statements about which + components make up the module; if they disagree, one of them is stale. Both + directions are warnings: a report that skips a component of its module is + exactly as wrong as one that describes a component the module does not + have. +#. Every listed component must ``:belongs_to:`` one of the listed features. A + report naming ``:features: feat__x`` and ``:components: comp__y`` asserts + that ``comp__y`` is part of ``feat__x``; the ``.. comp::`` need has to say + so too. + +Ids in ``:components:`` or ``:features:`` that do not resolve to a need are +reported as well. Every problem is reported as a warning rather than raised, +so one build surfaces all of them. + +Like every other graph check, it can be disabled or run in isolation via the +``score_metamodel_checks`` config value, e.g. +``score_metamodel_checks = "check_mod_ver_report_links"``. diff --git a/src/extensions/score_metamodel/checks/mod_ver_report_checks.py b/src/extensions/score_metamodel/checks/mod_ver_report_checks.py new file mode 100644 index 000000000..2586e954c --- /dev/null +++ b/src/extensions/score_metamodel/checks/mod_ver_report_checks.py @@ -0,0 +1,122 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Graph checks for ``mod_ver_report`` needs. + +A ``mod_ver_report`` need is emitted by the +``.. module-verification-report::`` directive of +``score_module_verification_report``. It declares the module it belongs to +(``belongs_to``) and the artifacts the report covers (``covers``: the feature +and every component the report renders a section for). + +Because all of that lives in the needs graph, the report can be validated +against the architecture needs it claims to describe: + +1. ``covers`` and the module's ``includes`` must name the *same* components. + The report and the module are two independent statements about which + components make up the module — if they disagree, one of them is stale. +2. Every covered component must ``belongs_to`` every covered feature. A report + that covers ``feat__x`` and ``comp__y`` asserts that ``comp__y`` is part of + ``feat__x``; the component need has to agree. + +Both directions of rule 1 are reported, but only the direction that was +already enforced before this check existed (component covered by the report, +missing from the module) is a hard warning. The opposite direction is reported +as a non-fatal "new check" so that existing modules with an incomplete report +do not break their build immediately. +""" + +from __future__ import annotations + +from typing import Any + +from score_metamodel import ( + CheckLogger, + graph_check, +) +from sphinx.application import Sphinx +from sphinx_needs.data import NeedsView +from sphinx_needs.need_item import NeedItem + + +def _resolve( + report: NeedItem, + link: str, + all_needs: NeedsView, + log: CheckLogger, +) -> list[NeedItem]: + """Resolve the ids linked via *link* to needs, warning about unknown ones.""" + resolved: list[NeedItem] = [] + for need_id in report.get(link, []): + target = all_needs.get(need_id) + if target is None: + log.warning_for_need( + report, f"`{link}` references `{need_id}`, which is not a known need." + ) + continue + resolved.append(target) + return resolved + + +def _check_component_parity(report: NeedItem, module: NeedItem, log: CheckLogger): + module_components: set[str] = set(module.get("includes")) + report_components: set[str] = set(report.get("components")) + components_not_mention_in_report = module_components.difference(report_components) + if components_not_mention_in_report: + msg = f"Module includes components: {components_not_mention_in_report} that are not mentioned in the Module verification report: {report.id}" + log.warning_for_need(report, msg) + + components_in_report_not_in_module = report_components.difference( + module_components + ) + if components_in_report_not_in_module: + msg = f"Module verification Report: {report.id} mentiones components the linked Module:{report.belongs_to} does not mention. Components mentioned: {components_in_report_not_in_module}" + log.warning_for_need(report, msg) + + +def _check_features_included( + report: NeedItem, features: list[NeedItem], components: list[NeedItem], log +): + comp_feat_dict = {c.id: c.get("belongs_to")[0] for c in components} + features_in_components = set(comp_feat_dict.values()) + feats_missing_in_components = features_in_components.difference(set(features)) + if feats_missing_in_components: + comp_feat_missing = [comp_feat_dict[feat] for feat in features_in_components] + msg = f"Components: {comp_feat_missing} are mentioning Features: {features_in_components} that are not mentioned in the Module Verification Report: {report.id}" + log.warning_for_need(report, msg) + + +@graph_check +def check_mod_ver_report_links( + app: Sphinx, + all_needs: NeedsView, + log: CheckLogger, +) -> None: + """Validate that every ``mod_ver_report`` agrees with the needs it covers.""" + reports = all_needs.filter_is_external(False).filter_types(["mod_ver_report"]) + + for report in reports.values(): + # TODO: improve errors + components = _resolve(report, "components", all_needs, log) + features = _resolve(report, "features", all_needs, log) + modules = _resolve(report, "belongs_to", all_needs, log) + # There can only be one module linked to a mod_ver_report + # Needed? + assert modules + if len(modules) != 1: + msg = f"Only one module is allowed to be mentioned in Module Verification Report: {report.id}" + log.warning_for_need(report, msg) + module = modules[0] + + # Module should have all the same components as the mod_ver_report + _check_component_parity(report, module, log) + _check_features_included(report, features, components, log) diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 33b088d52..31596cdb4 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -988,11 +988,12 @@ needs_types: mandatory_links: # req-Id: tool_req__docs_verification_report_need belongs_to: mod + components: comp + features: feat optional_links: # req-Id: tool_req__docs_verification_report_need contains: ANY evidence: ANY - covers: ANY realizes: workproduct tags: - verification_report diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index a14802752..50e159a7a 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -16,7 +16,7 @@ .. module-verification-report:: :module-id: mod__baselibs - :feature-id: feat__baselibs + :features: feat__baselibs :safety: ASIL_B :security: YES :status: valid @@ -26,19 +26,23 @@ comp__baselibs_containers ``safety``/``security``/``status``/``verification-method`` are the -mandatory options of the sphinx-needs ``mod_ver_report`` need type (see -metamodel.yaml). The directive emits one such need -(``belongs_to: module-id``) so the report is machine-readable and its -links are validated by score_metamodel's generic need-link checks — -not just rendered RST. +mandatory options of the sphinx-needs ``mod_ver_report`` need type, and +``components``/``features`` are its mandatory links (see metamodel.yaml). The +directive emits one such need — ``belongs_to`` the module, ``components`` and +``features`` passed straight through from the options of the same name — so +the report is machine-readable and its links are validated by +score_metamodel's need-link and graph checks, not just rendered RST. Implementation is split across: * :mod:`.templates` — RST templates + default workproduct lists + CSS * :mod:`.rendering` — template expansion / report body assembly * :mod:`.directive` — the ``ModuleVerificationReportDirective`` class -* :mod:`.consistency_checks` — ``build-finished`` validation that every - component is properly linked in the needs graph + +Consistency of the emitted need with the rest of the needs graph (does the +module ``includes`` exactly the components the report lists? does every listed +component ``belongs_to`` a listed feature?) is validated by ``score_metamodel`` +'s ``check_mod_ver_report_links`` graph check, not by this extension. Testcase back-links rendered by this directive are annotated with a ``(passed)`` / ``(failed)`` result badge by @@ -50,12 +54,6 @@ from typing import Any -from .consistency_checks import ( - check_consistency, - init_registry, - merge_registry, - purge_registry, -) from .directive import ModuleVerificationReportDirective @@ -66,10 +64,6 @@ def setup(app: Any) -> dict: "bazel-out/_coverage/_coverage_report.dat", "env", ) - app.connect("env-before-read-docs", init_registry) - app.connect("env-purge-doc", purge_registry) - app.connect("env-merge-info", merge_registry) - app.connect("build-finished", check_consistency) return { "version": "0.9", "parallel_read_safe": True, diff --git a/src/extensions/score_module_verification_report/consistency_checks.py b/src/extensions/score_module_verification_report/consistency_checks.py deleted file mode 100644 index 215b5334e..000000000 --- a/src/extensions/score_module_verification_report/consistency_checks.py +++ /dev/null @@ -1,155 +0,0 @@ -# ******************************************************************************* -# 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-finished check: validate that each component listed in -``:components:`` is properly linked in the sphinx-needs graph. - -Two rules are enforced for every ``.. module-verification-report::`` -directive instance: - -1. ``comp_id ∈ mod_need["includes"]`` - — the module need must explicitly include the component. -2. ``feature_id ∈ comp_need["belongs_to"]`` - — the component need must declare that it belongs to the feature. - -Violations are reported as Sphinx warnings so they surface in CI logs -without stopping the build. - -The registry (``env.module_verification_report_registry``) is populated by -:class:`~.directive.ModuleVerificationReportDirective` during the read phase -and is parallel-read safe via ``init_registry`` / ``purge_registry`` / -``merge_registry`` lifecycle hooks. -""" - -from __future__ import annotations - -from typing import Any - -from sphinx.util import logging - -logger = logging.getLogger(__name__) - - -def _needs_view(env: Any) -> Any | None: - """Return sphinx-needs view or *None* when sphinx-needs is not loaded.""" - try: - from sphinx_needs.data import SphinxNeedsData # type: ignore[import-untyped] - - return SphinxNeedsData(env).get_needs_view() - except Exception: # pragma: no cover — only absent in test env - return None - - -# --------------------------------------------------------------------------- -# Lifecycle hooks (parallel-read safe) -# --------------------------------------------------------------------------- - - -def init_registry(app: Any, env: Any, docnames: Any) -> None: - """Create the registry dict on the env if it does not exist yet.""" - if not hasattr(env, "module_verification_report_registry"): - env.module_verification_report_registry = {} # type: ignore[attr-defined] - - -def purge_registry(app: Any, env: Any, docname: str) -> None: - """Remove registry entries that were produced by *docname*.""" - registry: dict = getattr(env, "module_verification_report_registry", {}) - stale = [k for k, v in registry.items() if v.get("docname") == docname] - for k in stale: - del registry[k] - - -def merge_registry(app: Any, env: Any, docnames: Any, other: Any) -> None: - """Merge the sub-build registry from *other* into *env*.""" - if not hasattr(env, "module_verification_report_registry"): - env.module_verification_report_registry = {} # type: ignore[attr-defined] - other_registry: dict = getattr(other, "module_verification_report_registry", {}) - env_reg = env.module_verification_report_registry # type: ignore[attr-defined] - env_reg.update(other_registry) - - -# --------------------------------------------------------------------------- -# Build-finished consistency check -# --------------------------------------------------------------------------- - - -def _check_module(app: Any, needs: Any, module_id: str, info: dict) -> None: - """Check one module's component links and emit warnings for violations.""" - feature_id: str | None = info["feature_id"] - comp_ids: list[str] = info["comp_ids"] - docname: str = info.get("docname", "?") - mod_need = needs.get(module_id) - - if mod_need is None: - logger.warning( - "[module-verification-report] %s: " - "%s (:module-id:) not found in sphinx-needs — " - "check the id", - docname, - module_id, - ) - - for comp_id in comp_ids: - comp_need = needs.get(comp_id) - - if comp_need is None: - logger.warning( - "[module-verification-report] %s: " - "%s (listed in :components:) not found in " - "sphinx-needs — check the id", - docname, - comp_id, - ) - continue - - # Rule 1: component must be in the module's :includes: - if mod_need is not None: # noqa: SIM102 - if comp_id not in mod_need.get("includes", []): - logger.warning( - "[module-verification-report] %s: " - "%s is listed in :components: but not in " - "%s :includes:", - docname, - comp_id, - module_id, - ) - - # Rule 2: feature must be in the component's :belongs_to: - if feature_id is not None and feature_id not in comp_need.get("belongs_to", []): - logger.warning( - "[module-verification-report] %s: " - "%s is not in %s :belongs_to: " - "(component listed via :components: of %s)", - docname, - feature_id, - comp_id, - module_id, - ) - - -def check_consistency(app: Any, exception: Any) -> None: - """Emit warnings for components that are missing required need links. - - Skipped entirely when the build already failed (*exception* is not None) - or when sphinx-needs is unavailable (e.g. unit-test environment). - """ - if exception: - return - registry: dict = getattr(app.env, "module_verification_report_registry", {}) - if not registry: - return - needs = _needs_view(app.env) - if needs is None: - return - - for module_id, info in registry.items(): - _check_module(app, needs, module_id, info) diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 362e5cd7a..97452758f 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -29,35 +29,53 @@ _VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") -def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: - """Parse a comma-separated list of component ids into component dicts. +def _parse_needs(ids_str: str, prefix: str) -> list[dict]: + """Parse a comma-separated list of need ids into id/slug/title dicts. Each entry may carry an optional ``[version==N]`` qualifier which is stripped silently — the rendered report does not filter by version. - The short slug is the component id with ``component_prefix`` removed - (or the full id if the prefix is absent). The human-readable title is - derived from the slug: underscores replaced with spaces, title-cased. + The short slug is the need id with *prefix* removed (or the full id if the + prefix is absent). The human-readable title is derived from the slug: + underscores replaced with spaces, title-cased. + + Used for both ``:components:`` (with the ``comp__<module>_`` prefix) and + ``:features:`` (with the generic ``feat__`` prefix). """ result = [] for raw in ids_str.split(","): - comp_id = _VERSION_QUALIFIER_RE.sub("", raw.strip()) - if not comp_id: + need_id = _VERSION_QUALIFIER_RE.sub("", raw.strip()) + if not need_id: continue slug = ( - comp_id[len(component_prefix) :] - if component_prefix and comp_id.startswith(component_prefix) - else comp_id + need_id[len(prefix) :] if prefix and need_id.startswith(prefix) else need_id ) title = slug.replace("_", " ").title() - result.append({"id": comp_id, "slug": slug, "title": title}) + result.append({"id": need_id, "slug": slug, "title": title}) return result +def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: + """Parse the ``:components:`` option. See :func:`_parse_needs`.""" + return _parse_needs(ids_str, component_prefix) + + +def _parse_features(ids_str: str) -> list[dict]: + """Parse the ``:features:`` option. See :func:`_parse_needs`.""" + return _parse_needs(ids_str, "feat__") + + # Mandatory options every ``mod_ver_report`` need requires (see # metamodel.yaml) that this directive cannot derive on its own. _MOD_VER_REPORT_OPTIONS = ("safety", "security", "status", "verification-method") +# Mandatory *links* of the ``mod_ver_report`` need type. Both are populated +# from the directive option of the same name, so the option is required too — +# guessing a traceability link (e.g. deriving ``feat__<module>`` from +# ``:module-id:``) would silently produce a dangling link whenever the guess +# is wrong. +_MOD_VER_REPORT_LINKS = ("components", "features") + def _mod_ver_report_id_and_title(module_short: str) -> tuple[str, str]: """Derive the ``mod_vrep__...`` need id and its human-readable title @@ -80,24 +98,28 @@ class ModuleVerificationReportDirective(SphinxDirective): .. module-verification-report:: :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b + :features: feat__mymodule :safety: QM :security: YES :status: valid :verification-method: test_and_inspection - ``feature-id`` and ``component-prefix`` are optional and derived from - ``module-id`` when omitted. ``safety``/``security``/``status``/ - ``verification-method`` are the mandatory options of the sphinx-needs - ``mod_ver_report`` need type (see metamodel.yaml) — this directive - emits one such need (``belongs_to: module-id``) so the report is + ``component-prefix`` is optional and derived from ``module-id`` when + omitted. Everything else is required, because it maps onto a mandatory + option or link of the sphinx-needs ``mod_ver_report`` need type (see + metamodel.yaml) — this directive emits one such need so the report is machine-readable, not just a rendered page. + + The ``:components:`` and ``:features:`` options are named after the need + links they populate: each is a comma-separated id list that is passed + straight through to the emitted need. """ required_arguments = 0 optional_arguments = 0 option_spec = { "module-id": str, - "feature-id": str, + "features": str, "component-prefix": str, "components": str, "safety": str, @@ -116,21 +138,26 @@ def run(self) -> list[nodes.Node]: component_prefix = self.options.get("component-prefix") or ( "comp__" + module_short + "_" if module_short else "comp__" ) - feature_id: str | None = self.options.get("feature-id") or None - feature_slug = ( - (feature_id.split("__", 1)[1] if "__" in feature_id else feature_id) - if feature_id is not None - else None - ) workproducts = DEFAULT_WORKPRODUCTS feature_workproducts = DEFAULT_FEATURE_WORKPRODUCTS - components_str = self.options.get("components", "") - components = _parse_components(components_str, component_prefix) - if not components: + components = _parse_components( + self.options.get("components", ""), component_prefix + ) + features = _parse_features(self.options.get("features", "")) + + # ``components`` and ``features`` are mandatory links of the + # mod_ver_report need type, so an empty list cannot produce a valid + # need — report it here, where the author can see which directive is + # at fault, rather than as a metamodel warning about a generated need. + parsed_links = {"components": components, "features": features} + empty_links = [n for n in _MOD_VER_REPORT_LINKS if not parsed_links[n]] + if empty_links: error = self.state_machine.reporter.error( - "module-verification-report: no components specified — " - "add ':components: comp__<id>, ...' to the directive", + "module-verification-report: no " + f"{' or '.join(empty_links)} specified — add " + + " and ".join(f"':{name}: <id>, ...'" for name in empty_links) + + " to the directive", line=self.lineno, ) return [error] @@ -155,12 +182,15 @@ def run(self) -> list[nodes.Node]: "status": self.options["status"], "verification_method": self.options["verification-method"], "version": self.options.get("version", "1"), + # The two mandatory links, passed straight through from the + # options of the same name. + "components": [c["id"] for c in components], + "features": [f["id"] for f in features], } rst_text = render_report( components, - feature_id, - feature_slug, # type: ignore[arg-type] + features, workproducts, feature_workproducts, coverage_records=load_coverage( @@ -182,14 +212,4 @@ def run(self) -> list[nodes.Node]: container.document = self.state.document nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] - # Register module/feature/component metadata so the build-finished - # consistency check can validate need links without a pre-scan. - if not hasattr(self.env, "module_verification_report_registry"): - self.env.module_verification_report_registry = {} # type: ignore[attr-defined] - self.env.module_verification_report_registry[module_id] = { # type: ignore[attr-defined] - "docname": self.env.docname, - "feature_id": feature_id, - "comp_ids": [c["id"] for c in components], - } - return container.children diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 840992847..6062afc28 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -108,11 +108,17 @@ def render_feature( feature_id: str, feature_slug: str, feature_workproducts: list[dict], + heading: str = "Feature", ) -> str: """Render the ``Feature`` section (Requirements / Architecture / Inspection Statistics), delegating all attribute filtering to sphinx-needs. + *heading* names the section. A report normally covers a single feature and + keeps the plain ``Feature`` heading; when ``:features:`` lists more than + one, :func:`render_report` passes a per-feature heading so the page does + not repeat the same title. + Feature statistics filter ``feat_req`` / ``feat_arc_*`` by ``"{feature_id}" in belongs_to`` — the same link that the source RST declares — so the report tracks the sphinx-needs data model @@ -123,6 +129,8 @@ def render_feature( ids because documents have no direct link back to the feature. """ return FEATURE_TEMPLATE.format( + feature_heading=heading, + feature_heading_underline="-" * len(heading), feature_id=feature_id, feature_slug=feature_slug, feature_workproduct_rows=workproduct_rows( @@ -154,9 +162,19 @@ def render_mod_ver_report( security: str, status: str, verification_method: str, + components: list[str], + features: list[str], version: str = "1", ) -> str: - """Render the ``.. mod_ver_report::`` need declaration for *module_id*.""" + """Render the ``.. mod_ver_report::`` need declaration for *module_id*. + + ``components`` and ``features`` are mandatory links of the + ``mod_ver_report`` need type (see metamodel.yaml): they record which + architecture needs this report describes. Emitting them puts the report + into the needs graph, which is what lets score_metamodel's + ``check_mod_ver_report_links`` graph check compare it against the module's + ``includes`` and the components' ``belongs_to``. + """ return MOD_VER_REPORT_TEMPLATE.format( title=title, report_id=report_id, @@ -166,23 +184,40 @@ def render_mod_ver_report( status=status, verification_method=verification_method, module_id=module_id, + components=", ".join(components), + features=", ".join(features), ) def render_report( components: list[dict], - feature_id: str | None, - feature_slug: str | None, + features: list[dict], workproducts: list[dict], feature_workproducts: list[dict], coverage_records: list[FileCoverage] | None = None, mod_ver_report: dict | None = None, ) -> str: + """Assemble the full report body. + + *features* mirrors *components*: a list of ``{"id", "slug"}`` dicts, one + per id in the directive's ``:features:`` option. One ``Feature`` section is + rendered per entry; with more than one the heading is qualified with the + feature slug so the page has no repeated titles. + """ parts = [WP_TABLE_CSS] if mod_ver_report is not None: parts.append(render_mod_ver_report(**mod_ver_report)) - if feature_id is not None and feature_slug is not None: - parts.append(render_feature(feature_id, feature_slug, feature_workproducts)) + for feature in features: + heading = ( + "Feature" + if len(features) == 1 + else f"Feature: {feature['slug'].replace('_', ' ').title()}" + ) + parts.append( + render_feature( + feature["id"], feature["slug"], feature_workproducts, heading + ) + ) parts += [ COMPONENTS_HEADER, render_overview(components), diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index c3fbba5b4..b684b48c4 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -185,13 +185,15 @@ :status: {status} :verification_method: {verification_method} :belongs_to: {module_id} + :components: {components} + :features: {features} """ FEATURE_TEMPLATE = """\ -Feature -------- +{feature_heading} +{feature_heading_underline} .. needtable:: :filter: id == "{feature_id}" diff --git a/src/extensions/score_module_verification_report/tests/test_consistency_checks.py b/src/extensions/score_module_verification_report/tests/test_consistency_checks.py deleted file mode 100644 index 05a562f6f..000000000 --- a/src/extensions/score_module_verification_report/tests/test_consistency_checks.py +++ /dev/null @@ -1,308 +0,0 @@ -# ******************************************************************************* -# 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 tests for :mod:`score_module_verification_report.consistency_checks`.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from src.extensions.score_module_verification_report.consistency_checks import ( - check_consistency, - init_registry, - merge_registry, - purge_registry, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_env(registry: dict | None = None) -> MagicMock: - env = MagicMock() - if registry is not None: - env.module_verification_report_registry = registry - else: - del env.module_verification_report_registry - # hasattr returns False for deleted attributes on MagicMock - type(env).__contains__ = MagicMock(return_value=False) - return env - - -def _make_app(registry: dict | None = None) -> MagicMock: - app = MagicMock() - app.env = MagicMock() - if registry is not None: - app.env.module_verification_report_registry = registry - else: - # Remove attribute so getattr returns default - if hasattr(app.env, "module_verification_report_registry"): - del app.env.module_verification_report_registry - return app - - -def _registry_entry( - module_id: str = "mod__m", - feature_id: str = "feat__m", - comp_ids: list[str] | None = None, - docname: str = "reporting/index", -) -> dict: - return { - "docname": docname, - "feature_id": feature_id, - "comp_ids": comp_ids or ["comp__m_a"], - } - - -# --------------------------------------------------------------------------- -# Lifecycle: init_registry -# --------------------------------------------------------------------------- - - -def test_init_registry_creates_dict_when_absent() -> None: - env = MagicMock(spec=[]) # no attributes at all - init_registry(None, env, []) - assert env.module_verification_report_registry == {} - - -def test_init_registry_keeps_existing_dict() -> None: - env = MagicMock(spec=["module_verification_report_registry"]) - env.module_verification_report_registry = {"mod__m": {"docname": "x"}} - init_registry(None, env, []) - assert "mod__m" in env.module_verification_report_registry - - -# --------------------------------------------------------------------------- -# Lifecycle: purge_registry -# --------------------------------------------------------------------------- - - -def test_purge_registry_removes_matching_docname() -> None: - env = MagicMock() - env.module_verification_report_registry = { - "mod__m": _registry_entry(docname="docs/report"), - "mod__other": _registry_entry(module_id="mod__other", docname="other/report"), - } - purge_registry(None, env, "docs/report") - assert "mod__m" not in env.module_verification_report_registry - assert "mod__other" in env.module_verification_report_registry - - -def test_purge_registry_noop_when_registry_missing() -> None: - env = MagicMock(spec=[]) - purge_registry(None, env, "any/docname") # must not raise - - -def test_purge_registry_noop_when_docname_unknown() -> None: - env = MagicMock() - env.module_verification_report_registry = {"mod__m": _registry_entry(docname="x")} - purge_registry(None, env, "not_there") - assert "mod__m" in env.module_verification_report_registry - - -# --------------------------------------------------------------------------- -# Lifecycle: merge_registry -# --------------------------------------------------------------------------- - - -def test_merge_registry_copies_other_entries() -> None: - env = MagicMock(spec=["module_verification_report_registry"]) - env.module_verification_report_registry = {} - other = MagicMock() - other.module_verification_report_registry = {"mod__m": _registry_entry()} - merge_registry(None, env, [], other) - assert "mod__m" in env.module_verification_report_registry - - -def test_merge_registry_creates_dict_when_env_missing() -> None: - env = MagicMock(spec=[]) - other = MagicMock(spec=[]) # other also has no registry - merge_registry(None, env, [], other) - assert env.module_verification_report_registry == {} - - -# --------------------------------------------------------------------------- -# check_consistency — early-exit paths -# --------------------------------------------------------------------------- - - -def test_noop_when_exception_set() -> None: - app = MagicMock() - with patch( - "src.extensions.score_module_verification_report.consistency_checks.logger" - ) as mock_logger: - check_consistency(app, exception=RuntimeError("boom")) - mock_logger.warning.assert_not_called() - - -def test_noop_when_registry_empty() -> None: - app = MagicMock() - app.env.module_verification_report_registry = {} - with patch( - "src.extensions.score_module_verification_report.consistency_checks.logger" - ) as mock_logger: - check_consistency(app, exception=None) - mock_logger.warning.assert_not_called() - - -def test_noop_when_registry_missing() -> None: - app = MagicMock() - app.env = MagicMock(spec=[]) # no registry attribute - with patch( - "src.extensions.score_module_verification_report.consistency_checks.logger" - ) as mock_logger: - check_consistency(app, exception=None) - mock_logger.warning.assert_not_called() - - -def test_noop_when_needs_unavailable() -> None: - app = MagicMock() - app.env.module_verification_report_registry = {"mod__m": _registry_entry()} - with ( - patch( - "src.extensions.score_module_verification_report.consistency_checks._needs_view", - return_value=None, - ), - patch( - "src.extensions.score_module_verification_report.consistency_checks.logger" - ) as mock_logger, - ): - check_consistency(app, exception=None) - mock_logger.warning.assert_not_called() - - -# --------------------------------------------------------------------------- -# check_consistency — warning cases -# --------------------------------------------------------------------------- - - -def _run_check( - registry: dict, - needs: dict, -) -> list[str]: - """Run check_consistency and return list of warning messages.""" - app = MagicMock() - app.env.module_verification_report_registry = registry - warnings: list[str] = [] - - def _capture(*args: object) -> None: - # logger.warning(fmt, *args) — interpolate for easy assertion - fmt = str(args[0]) if args else "" - warnings.append(fmt % args[1:] if len(args) > 1 else fmt) - - with ( - patch( - "src.extensions.score_module_verification_report.consistency_checks._needs_view", - return_value=needs, - ), - patch( - "src.extensions.score_module_verification_report.consistency_checks.logger" - ) as mock_logger, - ): - mock_logger.warning.side_effect = _capture - check_consistency(app, exception=None) - return warnings - - -def test_warns_when_comp_missing_from_module_includes() -> None: - registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a"])} - needs = { - "mod__m": {"includes": []}, # comp__m_a NOT listed - "comp__m_a": {"belongs_to": ["feat__m"]}, - } - warnings = _run_check(registry, needs) - assert any("comp__m_a" in w and "mod__m" in w and "includes" in w for w in warnings) - - -def test_warns_when_feature_missing_from_comp_belongs_to() -> None: - registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a"])} - needs = { - "mod__m": {"includes": ["comp__m_a"]}, - "comp__m_a": {"belongs_to": []}, # feat__m NOT listed - } - warnings = _run_check(registry, needs) - assert any( - "feat__m" in w and "comp__m_a" in w and "belongs_to" in w for w in warnings - ) - - -def test_no_belongs_to_check_when_feature_id_is_none() -> None: - """When feature_id is None (no :feature-id: option), the belongs_to rule is skipped.""" - registry = { - "mod__m": _registry_entry(feature_id=None, comp_ids=["comp__m_a"]) # type: ignore[arg-type] - } - needs = { - "mod__m": {"includes": ["comp__m_a"]}, - "comp__m_a": {"belongs_to": []}, # would fail if feature_id were set - } - warnings = _run_check(registry, needs) - assert not any("belongs_to" in w for w in warnings) - - -def test_no_warning_when_all_links_correct() -> None: - registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a", "comp__m_b"])} - needs = { - "mod__m": {"includes": ["comp__m_a", "comp__m_b"]}, - "comp__m_a": {"belongs_to": ["feat__m"]}, - "comp__m_b": {"belongs_to": ["feat__m"]}, - } - warnings = _run_check(registry, needs) - assert warnings == [] - - -def test_skips_module_check_when_mod_need_not_found() -> None: - registry = { - "mod__missing": _registry_entry( - module_id="mod__missing", comp_ids=["comp__m_a"] - ) - } - needs = { - # mod__missing is absent - "comp__m_a": {"belongs_to": ["feat__m"]}, - } - warnings = _run_check(registry, needs) - # warns that module id was not found - assert any("mod__missing" in w and "not found" in w for w in warnings) - # belongs_to is correct — no second warning - assert not any("belongs_to" in w for w in warnings) - - -def test_warns_when_comp_need_not_found() -> None: - registry = {"mod__m": _registry_entry(comp_ids=["comp__missing"])} - needs = { - "mod__m": {"includes": ["comp__missing"]}, - # comp__missing absent from needs - } - warnings = _run_check(registry, needs) - assert any("comp__missing" in w and "not found" in w for w in warnings) - - -def test_warning_includes_docname() -> None: - registry = { - "mod__m": _registry_entry(comp_ids=["comp__m_a"], docname="docs/report") - } - needs = { - "mod__m": {"includes": []}, - "comp__m_a": {"belongs_to": ["feat__m"]}, - } - warnings = _run_check(registry, needs) - assert any("docs/report" in w for w in warnings) - - registry = {"mod__m": _registry_entry(comp_ids=["comp__m_a", "comp__m_b"])} - needs = { - "mod__m": {"includes": []}, # neither comp listed - "comp__m_a": {"belongs_to": []}, # feature missing - "comp__m_b": {"belongs_to": []}, # feature missing - } - warnings = _run_check(registry, needs) - assert len(warnings) == 4 # 2× includes + 2× belongs_to diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 7d7b37644..0f1f23cd5 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -14,14 +14,16 @@ in :mod:`score_module_verification_report.directive`. The directive requires a full Sphinx environment to instantiate, so we test -the pure derivation rules and the ``_parse_components`` helper in isolation. +the pure derivation rules and the id-parsing helpers in isolation. """ from __future__ import annotations from src.extensions.score_module_verification_report.directive import ( + _MOD_VER_REPORT_LINKS, _mod_ver_report_id_and_title, _parse_components, + _parse_features, ) # --------------------------------------------------------------------------- @@ -33,10 +35,13 @@ def _resolve( *, option_module_id: str = "", - option_feature_id: str = "", option_component_prefix: str = "", ) -> dict: - """Mirror the derivation logic from ``run()`` and return resolved fields.""" + """Mirror the derivation logic from ``run()`` and return resolved fields. + + Only ``component_prefix`` is still derived — ``:features:`` is an explicit + option now, parsed by :func:`_parse_features` like any other id list. + """ module_id = option_module_id module_short = ( module_id[len("mod__") :] if module_id.startswith("mod__") else module_id @@ -44,18 +49,10 @@ def _resolve( component_prefix = option_component_prefix or ( "comp__" + module_short + "_" if module_short else "comp__" ) - feature_id: str | None = option_feature_id or None - feature_slug = ( - (feature_id.split("__", 1)[1] if "__" in feature_id else feature_id) - if feature_id is not None - else None - ) return { "module_id": module_id, "module_short": module_short, "component_prefix": component_prefix, - "feature_id": feature_id, - "feature_slug": feature_slug, } @@ -128,19 +125,11 @@ def test_parse_multiline_string(): def test_module_id_option_derives_prefix_only_not_feature(): - """Without :feature-id:, feature_id is None — no name guessing.""" + """:module-id: drives the component prefix — never the feature.""" r = _resolve(option_module_id="mod__baselibs") assert r["module_id"] == "mod__baselibs" assert r["module_short"] == "baselibs" assert r["component_prefix"] == "comp__baselibs_" - assert r["feature_id"] is None - assert r["feature_slug"] is None - - -def test_explicit_feature_id_option_overrides_derived(): - r = _resolve(option_module_id="mod__baselibs", option_feature_id="feat__bl") - assert r["feature_id"] == "feat__bl" - assert r["feature_slug"] == "bl" def test_explicit_component_prefix_option_overrides_derived(): @@ -152,29 +141,52 @@ def test_module_id_without_mod_prefix(): r = _resolve(option_module_id="mymodule") assert r["module_short"] == "mymodule" assert r["component_prefix"] == "comp__mymodule_" - assert r["feature_id"] is None def test_empty_module_id_gives_generic_prefix(): r = _resolve() assert r["module_id"] == "" assert r["component_prefix"] == "comp__" - assert r["feature_id"] is None # --------------------------------------------------------------------------- -# Tests — feature_slug extraction +# _parse_features # --------------------------------------------------------------------------- -def test_feature_slug_splits_on_double_underscore(): - r = _resolve(option_feature_id="feat__my_module") - assert r["feature_slug"] == "my_module" +def test_parse_single_feature(): + result = _parse_features("feat__my_module") + assert result == [ + {"id": "feat__my_module", "slug": "my_module", "title": "My Module"} + ] + + +def test_parse_multiple_features(): + """:features: is a list link, so the option accepts a list.""" + result = _parse_features("feat__one,\n feat__two\n") + assert [f["id"] for f in result] == ["feat__one", "feat__two"] + assert [f["slug"] for f in result] == ["one", "two"] + + +def test_parse_features_strips_version_qualifier(): + result = _parse_features("feat__demo[version==2]") + assert result[0]["id"] == "feat__demo" + + +def test_parse_features_keeps_full_id_without_feat_prefix(): + result = _parse_features("noprefix") + assert result[0]["id"] == "noprefix" + assert result[0]["slug"] == "noprefix" + + +def test_parse_features_empty(): + assert _parse_features("") == [] + assert _parse_features(" , ") == [] -def test_feature_slug_falls_back_to_full_id_when_no_double_underscore(): - r = _resolve(option_feature_id="noprefix") - assert r["feature_slug"] == "noprefix" +def test_components_and_features_are_the_mandatory_links(): + """The directive must require exactly the need type's mandatory links.""" + assert _MOD_VER_REPORT_LINKS == ("components", "features") # --------------------------------------------------------------------------- diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index d873d8459..bf1feca76 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -129,6 +129,8 @@ def test_render_mod_ver_report_contains_only_mandatory_fields() -> None: security="YES", status="valid", verification_method="test_and_inspection", + components=["comp__demo_a", "comp__demo_b"], + features=["feat__demo"], ) assert ".. mod_ver_report:: Demo Verification Report" in out assert ":id: mod_vrep__demo__report" in out @@ -138,10 +140,32 @@ def test_render_mod_ver_report_contains_only_mandatory_fields() -> None: assert ":status: valid" in out assert ":verification_method: test_and_inspection" in out assert ":belongs_to: mod__demo" in out + # The two mandatory links, comma-joined in option order. + assert ":components: comp__demo_a, comp__demo_b" in out + assert ":features: feat__demo" in out # Only mandatory fields — no optional coverage/percent/realizes options. assert "coverage_percent" not in out assert ":realizes:" not in out - assert ":covers:" not in out + + +def test_render_mod_ver_report_links_stay_inside_the_directive_block() -> None: + """The link options must not slip past the terminating blank line.""" + out = render_mod_ver_report( + module_id="mod__demo", + report_id="mod_vrep__demo__report", + title="Demo Verification Report", + safety="QM", + security="YES", + status="valid", + verification_method="test_and_inspection", + components=["comp__demo_a"], + features=["feat__demo"], + ) + assert out.endswith( + ":belongs_to: mod__demo\n" + " :components: comp__demo_a\n" + " :features: feat__demo\n\n" + ) # --------------------------------------------------------------------------- @@ -156,8 +180,7 @@ def test_render_report_assembles_all_sections() -> None: ] out = render_report( components=components, - feature_id="feat__demo", - feature_slug="demo", + features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], workproducts=_WP, feature_workproducts=_WP, ) @@ -175,8 +198,7 @@ def test_render_report_includes_mod_ver_report_when_given() -> None: components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] out = render_report( components=components, - feature_id=None, - feature_slug=None, + features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], workproducts=_WP, feature_workproducts=_WP, mod_ver_report={ @@ -187,19 +209,57 @@ def test_render_report_includes_mod_ver_report_when_given() -> None: "security": "YES", "status": "valid", "verification_method": "test_and_inspection", + "components": ["comp__demo_a"], + "features": ["feat__demo"], }, ) assert ".. mod_ver_report:: Demo Verification Report" in out assert ":belongs_to: mod__demo" in out + assert ":components: comp__demo_a" in out + assert ":features: feat__demo" in out def test_render_report_omits_mod_ver_report_when_absent() -> None: components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] out = render_report( components=components, - feature_id=None, - feature_slug=None, + features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], workproducts=_WP, feature_workproducts=_WP, ) assert ".. mod_ver_report::" not in out + + +# --------------------------------------------------------------------------- +# render_report — multiple features +# --------------------------------------------------------------------------- + + +def test_render_report_renders_one_section_per_feature() -> None: + """``:features:`` is a list, so every entry gets its own section.""" + out = render_report( + components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], + features=[ + {"id": "feat__demo_one", "slug": "one", "title": "One"}, + {"id": "feat__demo_two", "slug": "two", "title": "Two"}, + ], + workproducts=_WP, + feature_workproducts=_WP, + ) + assert 'id == "feat__demo_one"' in out + assert 'id == "feat__demo_two"' in out + # Headings are qualified so the page has no two identical titles. + assert "Feature: One" in out + assert "Feature: Two" in out + assert "Feature\n-------" not in out + + +def test_single_feature_keeps_the_plain_heading() -> None: + out = render_report( + components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], + features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], + workproducts=_WP, + feature_workproducts=_WP, + ) + assert "Feature\n-------" in out + assert "Feature: " not in out From 5a4467ea7d824620b9c82d13e05c8aae33099bdf Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak <maximilian.pollak@qorix.com> Date: Thu, 27 Aug 2026 19:15:13 +0200 Subject: [PATCH 22/25] WIP: testing implementation of template --- .../docs/module_verification_report.rst | 47 ++- .../score_module_verification_report/BUILD | 3 + .../__init__.py | 5 + .../directive.py | 148 +++---- .../render_context.py | 74 ++++ .../rendering.py | 190 +-------- .../templates.py | 364 +----------------- .../tests/test_directive.py | 196 +++------- .../tests/test_needs_template.py | 221 +++++++++++ .../tests/test_rendering.py | 248 ++---------- src/needs_templates/mod_ver_report.need | 345 +++++++++++++++++ 11 files changed, 828 insertions(+), 1013 deletions(-) create mode 100644 src/extensions/score_module_verification_report/render_context.py create mode 100644 src/extensions/score_module_verification_report/tests/test_needs_template.py create mode 100644 src/needs_templates/mod_ver_report.need diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 877cff025..5000961bf 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -18,11 +18,13 @@ Module Verification Report extension ==================================== ``score_module_verification_report`` provides the -``.. module-verification-report::`` directive, which expands into the -standard per-module verification report: a feature summary, a component -overview table, and one detailed section per component. Traceability is -resolved by sphinx-needs at render time — the directive only emits -``.. needtable::`` / ``.. needpie::`` widgets with the right filters. +``.. module-verification-report::`` directive, which emits the module's +``mod_ver_report`` need. The report body — a feature summary, a component +overview table, and one detailed section per component — is a Sphinx-Needs +content template (``src/needs_templates/mod_ver_report.need``) that the need +selects via ``:template:``. Traceability is resolved by sphinx-needs at render +time: the template only emits ``.. needtable::`` / ``.. needpie::`` widgets +with the right filters. The extension is part of the :ref:`score_sphinx_bundle<extensions>`. No external config file is required for the common case. @@ -56,7 +58,9 @@ Options * - ``:module-id:`` - yes - sphinx-needs id of the ``.. mod::`` need (e.g. ``mod__mymodule``). - Drives the default for ``:component-prefix:``. + Also names the module whose component-id prefix + (``comp__<module-short>_``) the template strips to derive component + slugs and titles. * - ``:components:`` - yes @@ -74,12 +78,6 @@ Options mandatory traceability link would silently produce a dangling link whenever the guess is wrong. - * - ``:component-prefix:`` - - no - - Prefix stripped from each component id to derive its slug (used - for section headings and document-id matching). Default: - ``comp__<module-short>_``. - * - ``:safety:`` - yes - ASIL classification of the module. One of ``QM`` or ``ASIL_B``. @@ -154,3 +152,28 @@ so one build surfaces all of them. Like every other graph check, it can be disabled or run in isolation via the ``score_metamodel_checks`` config value, e.g. ``score_metamodel_checks = "check_mod_ver_report_links"``. + +The report template +------------------- + +The body lives in ``src/needs_templates/mod_ver_report.need`` and is rendered +by Sphinx-Needs, not by this extension. Two consequences are worth knowing: + +*Templates render during the read phase*, when the need is created and the +needs graph does not exist yet. The template therefore never looks other needs +up. It reads ``belongs_to`` / ``components`` / ``features`` off the need +itself, derives component slugs and titles from the ids by string +manipulation, and leaves everything else to ``needtable`` / ``needpie``, which +resolve at write time. + +*A need's content cannot open new sections*, so the report uses +``.. rubric::`` where a standalone page would use headings. Rubrics carry no +TOC entries; each component section is still a link target +(``comp-<slug-with-dashes>``). + +Coverage is the one thing the template cannot reach on its own — LCOV data is +a file on disk, not a need. ``render_context.py`` registers a +``mvr_coverage(slug)`` helper in ``needs_render_context`` that returns +ready-made table rows for a component, or an empty string when there is no +match, and the template renders either the table or a "no coverage data" note. +The LCOV file is parsed once per build, on first use. diff --git a/src/extensions/score_module_verification_report/BUILD b/src/extensions/score_module_verification_report/BUILD index 80d1e70d5..7d90f76c6 100644 --- a/src/extensions/score_module_verification_report/BUILD +++ b/src/extensions/score_module_verification_report/BUILD @@ -47,6 +47,9 @@ score_pytest( name = "score_module_verification_report_tests", size = "small", srcs = glob(["tests/*.py"]), + # test_needs_template.py renders the shipped ``mod_ver_report.need`` + # template, so it must be present in the test's runfiles. + data = ["@score_docs_as_code//src/needs_templates:files"], deps = [":score_module_verification_report"], pytest_config = "//:pyproject.toml", ) diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 50e159a7a..0d753e658 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -55,6 +55,7 @@ from typing import Any from .directive import ModuleVerificationReportDirective +from .render_context import register_render_context def setup(app: Any) -> dict: @@ -64,6 +65,10 @@ def setup(app: Any) -> dict: "bazel-out/_coverage/_coverage_report.dat", "env", ) + # The ``mod_ver_report`` need template renders coverage tables, but LCOV + # data lives on disk rather than in the needs graph — expose it as a + # render-context helper the template can call. + app.connect("config-inited", register_render_context) return { "version": "0.9", "parallel_read_safe": True, diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 97452758f..275f04751 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -21,48 +21,25 @@ from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles -from .coverage import load_coverage -from .rendering import render_report -from .templates import DEFAULT_FEATURE_WORKPRODUCTS, DEFAULT_WORKPRODUCTS +from .rendering import render_mod_ver_report -# Strip an optional ``[version==N]`` qualifier from a component id. +# Strip an optional ``[version==N]`` qualifier from a need id. _VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") -def _parse_needs(ids_str: str, prefix: str) -> list[dict]: - """Parse a comma-separated list of need ids into id/slug/title dicts. +def _parse_ids(ids_str: str) -> list[str]: + """Parse a comma-separated option value into a list of need ids. - Each entry may carry an optional ``[version==N]`` qualifier which is - stripped silently — the rendered report does not filter by version. - - The short slug is the need id with *prefix* removed (or the full id if the - prefix is absent). The human-readable title is derived from the slug: - underscores replaced with spaces, title-cased. - - Used for both ``:components:`` (with the ``comp__<module>_`` prefix) and - ``:features:`` (with the generic ``feat__`` prefix). + Multi-line values are supported (docutils folds them into one string) and + an optional ``[version==N]`` qualifier is stripped silently — the report + does not filter by version. """ - result = [] + ids = [] for raw in ids_str.split(","): need_id = _VERSION_QUALIFIER_RE.sub("", raw.strip()) - if not need_id: - continue - slug = ( - need_id[len(prefix) :] if prefix and need_id.startswith(prefix) else need_id - ) - title = slug.replace("_", " ").title() - result.append({"id": need_id, "slug": slug, "title": title}) - return result - - -def _parse_components(ids_str: str, component_prefix: str) -> list[dict]: - """Parse the ``:components:`` option. See :func:`_parse_needs`.""" - return _parse_needs(ids_str, component_prefix) - - -def _parse_features(ids_str: str) -> list[dict]: - """Parse the ``:features:`` option. See :func:`_parse_needs`.""" - return _parse_needs(ids_str, "feat__") + if need_id: + ids.append(need_id) + return ids # Mandatory options every ``mod_ver_report`` need requires (see @@ -91,9 +68,9 @@ def _mod_ver_report_id_and_title(module_short: str) -> tuple[str, str]: class ModuleVerificationReportDirective(SphinxDirective): - """Expand to the per-module verification report body. + """Emit the ``mod_ver_report`` need for one module. - Minimal usage:: + Usage:: .. module-verification-report:: :module-id: mod__mymodule @@ -104,24 +81,23 @@ class ModuleVerificationReportDirective(SphinxDirective): :status: valid :verification-method: test_and_inspection - ``component-prefix`` is optional and derived from ``module-id`` when - omitted. Everything else is required, because it maps onto a mandatory - option or link of the sphinx-needs ``mod_ver_report`` need type (see - metamodel.yaml) — this directive emits one such need so the report is - machine-readable, not just a rendered page. + Every option is required: each maps onto a mandatory option or link of the + sphinx-needs ``mod_ver_report`` need type (see metamodel.yaml). The + directive is a shorthand — it derives the need's id and title from + ``:module-id:`` and passes the rest straight through. - The ``:components:`` and ``:features:`` options are named after the need - links they populate: each is a comma-separated id list that is passed - straight through to the emitted need. + The report *body* is not generated here. The emitted need selects the + ``mod_ver_report`` content template + (``src/needs_templates/mod_ver_report.need``), which Sphinx-Needs renders + from the need's own fields. """ required_arguments = 0 optional_arguments = 0 option_spec = { "module-id": str, - "features": str, - "component-prefix": str, "components": str, + "features": str, "safety": str, "security": str, "status": str, @@ -130,86 +106,64 @@ class ModuleVerificationReportDirective(SphinxDirective): } has_content = False + def _error(self, message: str) -> list[nodes.Node]: + return [ + self.state_machine.reporter.error( + f"module-verification-report: {message}", line=self.lineno + ) + ] + def run(self) -> list[nodes.Node]: module_id = self.options.get("module-id", "") module_short = ( module_id[len("mod__") :] if module_id.startswith("mod__") else module_id ) - component_prefix = self.options.get("component-prefix") or ( - "comp__" + module_short + "_" if module_short else "comp__" - ) - workproducts = DEFAULT_WORKPRODUCTS - feature_workproducts = DEFAULT_FEATURE_WORKPRODUCTS - components = _parse_components( - self.options.get("components", ""), component_prefix - ) - features = _parse_features(self.options.get("features", "")) + parsed_links = { + name: _parse_ids(self.options.get(name, "")) + for name in _MOD_VER_REPORT_LINKS + } # ``components`` and ``features`` are mandatory links of the # mod_ver_report need type, so an empty list cannot produce a valid # need — report it here, where the author can see which directive is # at fault, rather than as a metamodel warning about a generated need. - parsed_links = {"components": components, "features": features} empty_links = [n for n in _MOD_VER_REPORT_LINKS if not parsed_links[n]] if empty_links: - error = self.state_machine.reporter.error( - "module-verification-report: no " - f"{' or '.join(empty_links)} specified — add " + return self._error( + f"no {' or '.join(empty_links)} specified — add " + " and ".join(f"':{name}: <id>, ...'" for name in empty_links) - + " to the directive", - line=self.lineno, + + " to the directive" ) - return [error] missing = [opt for opt in _MOD_VER_REPORT_OPTIONS if not self.options.get(opt)] if missing: - error = self.state_machine.reporter.error( - "module-verification-report: missing mandatory option(s) " + return self._error( + "missing mandatory option(s) " f"{', '.join(':' + m + ':' for m in missing)} required to " - "generate the mod_ver_report need", - line=self.lineno, + "generate the mod_ver_report need" ) - return [error] report_id, report_title = _mod_ver_report_id_and_title(module_short) - mod_ver_report = { - "module_id": module_id, - "report_id": report_id, - "title": report_title, - "safety": self.options["safety"], - "security": self.options["security"], - "status": self.options["status"], - "verification_method": self.options["verification-method"], - "version": self.options.get("version", "1"), - # The two mandatory links, passed straight through from the - # options of the same name. - "components": [c["id"] for c in components], - "features": [f["id"] for f in features], - } - - rst_text = render_report( - components, - features, - workproducts, - feature_workproducts, - coverage_records=load_coverage( - getattr(self.config, "mvr_coverage_lcov", "") - ), - mod_ver_report=mod_ver_report, + rst_text = render_mod_ver_report( + module_id=module_id, + report_id=report_id, + title=report_title, + safety=self.options["safety"], + security=self.options["security"], + status=self.options["status"], + verification_method=self.options["verification-method"], + version=self.options.get("version", "1"), + components=parsed_links["components"], + features=parsed_links["features"], ) + view_list = ViewList() source = "<module-verification-report>" for lineno, line in enumerate(rst_text.splitlines()): view_list.append(line, source, lineno) - # Parse into a plain container (not a ``nodes.section``): a section - # wrapper would push every heading we emit one level deeper than the - # surrounding document sections, so ``Component Overview`` would - # render as ``<h4>`` instead of ``<h3>`` alongside - # ``Feature Requirements Statistics``. container = nodes.container() container.document = self.state.document nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] - return container.children diff --git a/src/extensions/score_module_verification_report/render_context.py b/src/extensions/score_module_verification_report/render_context.py new file mode 100644 index 000000000..7dab8b849 --- /dev/null +++ b/src/extensions/score_module_verification_report/render_context.py @@ -0,0 +1,74 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Render-context helpers for the ``mod_ver_report`` need template. + +Sphinx-Needs renders a need's ``:template:`` from the need's own fields plus +whatever sits in ``needs_render_context``. Everything the module verification +report shows is either a field of the need or a ``needtable`` / ``needpie`` +filter — with one exception: test coverage, which comes from an LCOV file on +disk. A Jinja template cannot read files, so the coverage lookup is registered +here as a callable the template invokes by component slug. +""" + +from __future__ import annotations + +from typing import Any + +from .coverage import FileCoverage, coverage_rows, load_coverage, records_for_slug + + +class CoverageLookup: + """``mvr_coverage(slug_norm)`` — coverage table rows for one component. + + Deliberately a class rather than a closure: Sphinx checks every config + value with ``is_serializable``, which rejects ``types.FunctionType`` + outright. A plain function (or lambda) in ``needs_render_context`` makes + Sphinx log ``cannot cache unpickleable configuration value``, which is + fatal in a ``-W`` build. An instance of a module-level class is not a + function type, and its state (a path plus plain dataclasses) pickles + cleanly, so the config cache keeps working. + + The LCOV file is parsed on first use and cached for the rest of the build: + a report with N components would otherwise re-read it N times, and a + project without a report must not pay for parsing it at all. + """ + + def __init__(self, lcov_path: str) -> None: + self.lcov_path = lcov_path + self.records: list[FileCoverage] | None = None + + def __call__(self, slug_norm: str) -> str: + """Return ``list-table`` rows for *slug_norm*, or ``""`` if no match. + + The rows come from :func:`.coverage.coverage_rows` (including the + ``**Total**`` row). The template branches on the empty string to show + either the table or the "no coverage data" note. + """ + if self.records is None: + self.records = load_coverage(self.lcov_path) + return coverage_rows(records_for_slug(self.records, slug_norm)) + + +def register_render_context(app: Any, config: Any) -> None: + """Add the report's helpers to ``needs_render_context``. + + Runs on ``config-inited`` so the helpers are in place before sphinx-needs + starts creating needs — templates render during the read phase. + """ + context = getattr(config, "needs_render_context", None) + if context is None: + context = {} + config.needs_render_context = context + context.setdefault( + "mvr_coverage", CoverageLookup(getattr(config, "mvr_coverage_lcov", "")) + ) diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py index 6062afc28..c2db17624 100644 --- a/src/extensions/score_module_verification_report/rendering.py +++ b/src/extensions/score_module_verification_report/rendering.py @@ -10,148 +10,11 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Rendering functions that expand :mod:`.templates` for the report body.""" +"""Rendering of the ``mod_ver_report`` need declaration.""" from __future__ import annotations -import re - -from .coverage import FileCoverage, coverage_rows, records_for_slug -from .templates import ( - COMPONENT_COVERAGE_TEMPLATE, - COMPONENT_TEMPLATE, - COMPONENTS_HEADER, - COVERAGE_EMPTY_BODY, - COVERAGE_TABLE_HEADER, - FEATURE_TEMPLATE, - MOD_VER_REPORT_TEMPLATE, - OVERVIEW_TEMPLATE, - WP_TABLE_CSS, -) - - -def normalize_slug(text: str) -> str: - """Return *text* stripped of underscores and lower-cased. - - Component ids and document ids sometimes spell the same component - with different underscoring (``bit_manipulation`` vs. - ``bitmanipulation``). Comparing on the underscore-free form makes - that difference invisible without introducing per-component config. - """ - return text.replace("_", "").lower() - - -def slugify(text: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") - - -def workproduct_rows( - slug_norm: str, - workproducts: list[dict], -) -> str: - """Render the work-product rows for one component or the feature. - - Each row has four cells: the work-product ``:need:`` link, its - label, the realising document, and its status. The "Realized by" - and "Status" cells are both rendered as ``.. needtable::`` with the - same filter (``type == "document"``, normalised-slug substring match - on the doc id, ``realizes`` link containing ``wp['wp_id']``) but - different ``:columns:``. If nothing matches, both cells are empty. - """ - lines: list[str] = [] - for wp in workproducts: - lines.append(f" * - :need:`{wp['wp_id']}`") - lines.append(f" - {wp['label']}") - filter_expr = ( - f'type == "document" and ' - f'"{slug_norm}" in id.replace("_", "") and ' - f'"{wp["wp_id"]}" in realizes' - ) - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: id") - lines.append(" :style: table") - lines.append(" - .. needtable::") - lines.append(f" :filter: {filter_expr}") - lines.append(" :columns: status") - lines.append(" :style: table") - return "\n".join(lines) - - -def render_component( - comp: dict, - workproducts: list[dict], - coverage_records: list[FileCoverage] | None = None, -) -> str: - title = comp["title"] - slug = comp["slug"] - ref = "comp-" + slugify(title) - slug_norm = normalize_slug(slug) - matched = records_for_slug(coverage_records or [], slug_norm) - if matched: - coverage_body = COVERAGE_TABLE_HEADER + coverage_rows(matched) - else: - coverage_body = COVERAGE_EMPTY_BODY - coverage_block = COMPONENT_COVERAGE_TEMPLATE.format(coverage_body=coverage_body) - return COMPONENT_TEMPLATE.format( - ref=ref, - title=title, - title_underline="~" * len(title), - comp_id=comp["id"], - slug=slug, - workproduct_rows=workproduct_rows(slug_norm, workproducts), - coverage_block=coverage_block, - ) - - -def render_feature( - feature_id: str, - feature_slug: str, - feature_workproducts: list[dict], - heading: str = "Feature", -) -> str: - """Render the ``Feature`` section (Requirements / Architecture / - Inspection Statistics), delegating all attribute filtering to - sphinx-needs. - - *heading* names the section. A report normally covers a single feature and - keeps the plain ``Feature`` heading; when ``:features:`` lists more than - one, :func:`render_report` passes a per-feature heading so the page does - not repeat the same title. - - Feature statistics filter ``feat_req`` / ``feat_arc_*`` by - ``"{feature_id}" in belongs_to`` — the same link that the source - RST declares — so the report tracks the sphinx-needs data model - directly instead of guessing from id substrings. The Feature summary - ``needtable`` pulls title / safety / security / status from the - ``feat__*`` need itself. The Inspection Statistics work-product - rows still substring-match the ``feature_slug`` against document - ids because documents have no direct link back to the feature. - """ - return FEATURE_TEMPLATE.format( - feature_heading=heading, - feature_heading_underline="-" * len(heading), - feature_id=feature_id, - feature_slug=feature_slug, - feature_workproduct_rows=workproduct_rows( - normalize_slug(feature_slug), - feature_workproducts, - ), - ) - - -def render_overview(components: list[dict]) -> str: - """Render the component overview as a ``.. needtable::``. - - Delegating to sphinx-needs means ``safety``/``security``/``status`` - come from its data model (validated, normalised, consistent with the - rest of the site) rather than from raw strings scraped by our - filesystem scan. Trade-off: the ``Component`` cell links to the - need's detail page, not to the per-component section further down - this page. - """ - ids_literal = "[" + ", ".join(f'"{c["id"]}"' for c in components) + "]" - return OVERVIEW_TEMPLATE.format(ids_literal=ids_literal) +from .templates import MOD_VER_REPORT_TEMPLATE, NEEDS_TEMPLATE_NAME def render_mod_ver_report( @@ -169,15 +32,16 @@ def render_mod_ver_report( """Render the ``.. mod_ver_report::`` need declaration for *module_id*. ``components`` and ``features`` are mandatory links of the - ``mod_ver_report`` need type (see metamodel.yaml): they record which - architecture needs this report describes. Emitting them puts the report - into the needs graph, which is what lets score_metamodel's - ``check_mod_ver_report_links`` graph check compare it against the module's - ``includes`` and the components' ``belongs_to``. + ``mod_ver_report`` need type (see metamodel.yaml). They record which + architecture needs the report describes, which serves two purposes: the + ``mod_ver_report`` content template renders the report body from them, and + score_metamodel's ``check_mod_ver_report_links`` graph check compares them + against the module's ``includes`` and the components' ``belongs_to``. """ return MOD_VER_REPORT_TEMPLATE.format( title=title, report_id=report_id, + template_name=NEEDS_TEMPLATE_NAME, version=version, safety=safety, security=security, @@ -187,41 +51,3 @@ def render_mod_ver_report( components=", ".join(components), features=", ".join(features), ) - - -def render_report( - components: list[dict], - features: list[dict], - workproducts: list[dict], - feature_workproducts: list[dict], - coverage_records: list[FileCoverage] | None = None, - mod_ver_report: dict | None = None, -) -> str: - """Assemble the full report body. - - *features* mirrors *components*: a list of ``{"id", "slug"}`` dicts, one - per id in the directive's ``:features:`` option. One ``Feature`` section is - rendered per entry; with more than one the heading is qualified with the - feature slug so the page has no repeated titles. - """ - parts = [WP_TABLE_CSS] - if mod_ver_report is not None: - parts.append(render_mod_ver_report(**mod_ver_report)) - for feature in features: - heading = ( - "Feature" - if len(features) == 1 - else f"Feature: {feature['slug'].replace('_', ' ').title()}" - ) - parts.append( - render_feature( - feature["id"], feature["slug"], feature_workproducts, heading - ) - ) - parts += [ - COMPONENTS_HEADER, - render_overview(components), - ] - for comp in components: - parts.append(render_component(comp, workproducts, coverage_records)) - return "\n".join(parts) diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py index b684b48c4..b903077d0 100644 --- a/src/extensions/score_module_verification_report/templates.py +++ b/src/extensions/score_module_verification_report/templates.py @@ -10,175 +10,26 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""RST templates for the module verification report.""" +"""The RST emitted by the ``.. module-verification-report::`` directive. -from __future__ import annotations - -COMPONENT_TEMPLATE = """ -.. _{ref}: - -{title} -{title_underline} - -.. raw:: html - - <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> - -Component Requirements Statistics -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {title} Requirements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type == "comp_req" and "{comp_id}" in satisfied_by and status == "valid" - type == "comp_req" and "{comp_id}" in satisfied_by and status == "invalid" - - .. grid-item:: - - .. needpie:: {title} Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "comp_req" and "{comp_id}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{comp_id}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) - type == "comp_req" and "{comp_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) - -Component Architecture Statistics -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {title} Architecture Elements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "valid" - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and status == "invalid" - - .. grid-item:: - - .. needpie:: {title} Architecture Elements Inspection Status - :labels: inspected, not inspected - :colors: #37a12d, #ca2828 - :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_id}" in belongs_to and "inspected" in tags - type in ["comp_arc_sta", "comp_arc_dyn"] and "{comp_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 "{comp_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 -{coverage_block} -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 "{comp_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 (``\u2014``) 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} +Only the ``mod_ver_report`` need declaration lives here. The report *body* — +feature statistics, component overview, per-component sections, work-product +and coverage tables — is a Sphinx-Needs content template, +``src/needs_templates/mod_ver_report.need``, selected via the ``:template:`` +option below. Sphinx-Needs renders it from the need's own fields, so the body +follows the needs model instead of a second, parallel description of it. """ +from __future__ import annotations -COMPONENT_COVERAGE_TEMPLATE = """ -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 -{coverage_body} -""" - - -COVERAGE_TABLE_HEADER = """ - .. list-table:: - :header-rows: 1 - :widths: 45 10 10 10 10 10 10 - - * - Source - - Lines found - - Lines hit - - Line % - - Branches found - - Branches hit - - Branch % -""" - - -COVERAGE_EMPTY_BODY = """ - .. note:: - - No coverage data available for this component. Run ``bazel coverage`` - with the corresponding targets and rebuild the docs to populate this - table. -""" - +# ``:template:`` is a Sphinx-Needs core option, so score_metamodel's +# option check accepts it on a metamodel-defined need type. +NEEDS_TEMPLATE_NAME = "mod_ver_report" -# Emits an actual sphinx-needs ``mod_ver_report`` need (see metamodel.yaml) -# so the module verification report is machine-readable, not just a rendered -# RST page. Only the four type-specific *mandatory* options + the globally -# mandatory ``version`` + the mandatory ``belongs_to`` link are set here — -# score_metamodel's generic need-link/option validation (the same mechanism -# used for every other need type) takes over from there. MOD_VER_REPORT_TEMPLATE = """\ .. mod_ver_report:: {title} :id: {report_id} + :template: {template_name} :version: {version} :safety: {safety} :security: {security} @@ -189,194 +40,3 @@ :features: {features} """ - - -FEATURE_TEMPLATE = """\ -{feature_heading} -{feature_heading_underline} - -.. needtable:: - :filter: id == "{feature_id}" - :columns: title as "Name";id as "Id";safety;security;status - :style: table - -Feature Requirements Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: Feature Requirements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type == "feat_req" and "{feature_id}" in satisfied_by and status == "valid" - type == "feat_req" and "{feature_id}" in satisfied_by and status == "invalid" - - .. grid-item:: - - .. needpie:: Feature Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - - type == "feat_req" and "{feature_id}" in satisfied_by and ("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 - -Feature Architecture Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: Feature Architecture Elements Status - :labels: valid, invalid - :colors: #37a12d, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "valid" - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and status == "invalid" - - .. grid-item:: - - .. needpie:: Feature Architecture Elements Inspection Status - :labels: inspected, not inspected - :colors: #37a12d, #ca2828 - :legend: - - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" in tags - type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to and "inspected" not in tags - -.. dropdown:: Show architectural elements table - :animate: fade-in - - .. needtable:: - :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{feature_id}" in belongs_to - :style: table - :columns: id;title;safety;status;tags - :colwidths: 25,30,10,15,20 - :sort: id - -Feature Inspection Statistics -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Presence of the feature-level inspection work products. - -.. dropdown:: Show work products table - :animate: fade-in - - .. list-table:: - :header-rows: 1 - :widths: 30 25 25 20 - :class: wp-doc-table - - * - Work Product - - Kind - - Realized by - - Status -{feature_workproduct_rows} -""" - - -COMPONENTS_HEADER = """\ -Components ----------- - -""" - - -# Hide the auto-generated header / chrome of the inner ``.. needtable::`` -# widgets that render the "Realized by" / "Status" cells of the WP tables. -# Without this the cells show a nested table with its own "ID" / "Status" -# header row and datatables toolbar, which is visually noisy for a single -# value. Scoped to ``.wp-doc-table`` set as the outer list-table's class. -WP_TABLE_CSS = """\ -.. raw:: html - - <style> - .wp-doc-table td .needstable_wrapper, - .wp-doc-table td .pst-scrollable-table-container { - margin: 0; padding: 0; overflow: visible; - } - .wp-doc-table td table.NEEDS_TABLE, - .wp-doc-table td table.NEEDS_DATATABLES { - border: 0; margin: 0; box-shadow: none; background: transparent; - width: auto; - } - .wp-doc-table td table.NEEDS_TABLE thead, - .wp-doc-table td table.NEEDS_DATATABLES thead { display: none; } - .wp-doc-table td table.NEEDS_TABLE tbody tr, - .wp-doc-table td table.NEEDS_DATATABLES tbody tr { background: transparent; } - .wp-doc-table td table.NEEDS_TABLE tbody td, - .wp-doc-table td table.NEEDS_DATATABLES tbody td { - border: 0; padding: 0; background: transparent; - } - .wp-doc-table td .dataTables_wrapper .dataTables_length, - .wp-doc-table td .dataTables_wrapper .dataTables_filter, - .wp-doc-table td .dataTables_wrapper .dataTables_info, - .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } - </style> -""" - - -OVERVIEW_TEMPLATE = """\ -Component Overview -~~~~~~~~~~~~~~~~~~ - -.. needtable:: - :filter: id in {ids_literal} - :columns: id as "Component";safety;security;status - :style: table - :sort: id -""" - - -DEFAULT_WORKPRODUCTS = [ - { - "key": "requirements_inspect", - "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect", - }, - { - "key": "sw_arch_verification", - "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification", - }, - { - "key": "sw_implementation_inspection", - "label": "Implementation Inspection", - "wp_id": "wp__sw_implementation_inspection", - }, - {"key": "sw_component_dfa", "label": "DFA", "wp_id": "wp__sw_component_dfa"}, - {"key": "sw_component_fmea", "label": "FMEA", "wp_id": "wp__sw_component_fmea"}, -] - - -DEFAULT_FEATURE_WORKPRODUCTS = [ - { - "key": "requirements_inspect", - "label": "Requirements Inspection", - "wp_id": "wp__requirements_inspect", - }, - { - "key": "sw_arch_verification", - "label": "Architecture Inspection", - "wp_id": "wp__sw_arch_verification", - }, -] diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 0f1f23cd5..307e1ba6c 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -10,197 +10,93 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Unit tests for the component-parsing and option-derivation logic -in :mod:`score_module_verification_report.directive`. +"""Unit tests for the option parsing and id derivation in +:mod:`score_module_verification_report.directive`. -The directive requires a full Sphinx environment to instantiate, so we test -the pure derivation rules and the id-parsing helpers in isolation. +The directive needs a full Sphinx environment to instantiate, so the pure +helpers are tested in isolation. """ from __future__ import annotations from src.extensions.score_module_verification_report.directive import ( _MOD_VER_REPORT_LINKS, + _MOD_VER_REPORT_OPTIONS, _mod_ver_report_id_and_title, - _parse_components, - _parse_features, + _parse_ids, ) # --------------------------------------------------------------------------- -# Helpers that mirror the derivation logic in directive.py so we can test -# it without a Sphinx environment. -# --------------------------------------------------------------------------- - - -def _resolve( - *, - option_module_id: str = "", - option_component_prefix: str = "", -) -> dict: - """Mirror the derivation logic from ``run()`` and return resolved fields. - - Only ``component_prefix`` is still derived — ``:features:`` is an explicit - option now, parsed by :func:`_parse_features` like any other id list. - """ - module_id = option_module_id - module_short = ( - module_id[len("mod__") :] if module_id.startswith("mod__") else module_id - ) - component_prefix = option_component_prefix or ( - "comp__" + module_short + "_" if module_short else "comp__" - ) - return { - "module_id": module_id, - "module_short": module_short, - "component_prefix": component_prefix, - } - - -# --------------------------------------------------------------------------- -# _parse_components +# _parse_ids # --------------------------------------------------------------------------- def test_parse_single_id(): - result = _parse_components("comp__mymod_json", "comp__mymod_") - assert len(result) == 1 - assert result[0]["id"] == "comp__mymod_json" - assert result[0]["slug"] == "json" - assert result[0]["title"] == "Json" - - -def test_parse_multiple_ids(): - result = _parse_components( - "comp__mymod_json, comp__mymod_bit_manipulation", "comp__mymod_" - ) - assert len(result) == 2 - assert result[0]["slug"] == "json" - assert result[1]["slug"] == "bit_manipulation" - assert result[1]["title"] == "Bit Manipulation" - - -def test_parse_strips_version_qualifier(): - result = _parse_components( - "comp__mymod_json[version==1], comp__mymod_result[version==2]", - "comp__mymod_", - ) - assert result[0]["id"] == "comp__mymod_json" - assert result[1]["id"] == "comp__mymod_result" - + assert _parse_ids("comp__mymod_json") == ["comp__mymod_json"] -def test_parse_empty_string_returns_empty(): - assert _parse_components("", "comp__mymod_") == [] +def test_parse_multiple_ids_preserves_order(): + result = _parse_ids("comp__mymod_json, comp__mymod_bit_manipulation") + assert result == ["comp__mymod_json", "comp__mymod_bit_manipulation"] -def test_parse_whitespace_only_entries_skipped(): - result = _parse_components("comp__mymod_json, , ", "comp__mymod_") - assert len(result) == 1 - -def test_parse_without_matching_prefix_uses_full_id_as_slug(): - result = _parse_components("comp__other_json", "comp__mymod_") - assert result[0]["slug"] == "comp__other_json" - assert result[0]["title"] == "Comp Other Json" +def test_parse_handles_multiline_values(): + """docutils folds a multi-line option value into one string.""" + assert _parse_ids("comp__m_json,\n comp__m_result\n") == [ + "comp__m_json", + "comp__m_result", + ] -def test_parse_no_prefix_uses_full_id(): - result = _parse_components("comp__mymod_json", "") - assert result[0]["slug"] == "comp__mymod_json" +def test_parse_strips_version_qualifier(): + result = _parse_ids("comp__m_json[version==1], comp__m_result[version==2]") + assert result == ["comp__m_json", "comp__m_result"] -def test_parse_title_uses_titlecase(): - result = _parse_components("comp__m_memory_shared", "comp__m_") - assert result[0]["title"] == "Memory Shared" +def test_parse_skips_empty_entries(): + assert _parse_ids("comp__m_json, , ") == ["comp__m_json"] + assert _parse_ids("") == [] + assert _parse_ids(" , ") == [] -def test_parse_multiline_string(): - """Continuation lines (as docutils joins them with whitespace) work.""" - result = _parse_components("comp__m_json,\n comp__m_result\n", "comp__m_") - assert len(result) == 2 +def test_parse_is_type_agnostic(): + """The same parser serves :components: and :features:.""" + assert _parse_ids("feat__one, feat__two") == ["feat__one", "feat__two"] # --------------------------------------------------------------------------- -# Tests — option-only (no config file) +# _mod_ver_report_id_and_title # --------------------------------------------------------------------------- -def test_module_id_option_derives_prefix_only_not_feature(): - """:module-id: drives the component prefix — never the feature.""" - r = _resolve(option_module_id="mod__baselibs") - assert r["module_id"] == "mod__baselibs" - assert r["module_short"] == "baselibs" - assert r["component_prefix"] == "comp__baselibs_" - - -def test_explicit_component_prefix_option_overrides_derived(): - r = _resolve(option_module_id="mod__baselibs", option_component_prefix="comp__bl_") - assert r["component_prefix"] == "comp__bl_" - - -def test_module_id_without_mod_prefix(): - r = _resolve(option_module_id="mymodule") - assert r["module_short"] == "mymodule" - assert r["component_prefix"] == "comp__mymodule_" +def test_id_and_title_derived_from_module_slug(): + need_id, title = _mod_ver_report_id_and_title("baselibs") + assert need_id == "mod_vrep__baselibs__report" + assert title == "Baselibs Verification Report" -def test_empty_module_id_gives_generic_prefix(): - r = _resolve() - assert r["module_id"] == "" - assert r["component_prefix"] == "comp__" +def test_id_keeps_exactly_two_separators_for_multiword_modules(): + """``mod_ver_report`` declares ``parts: 3`` — no more, no less.""" + need_id, title = _mod_ver_report_id_and_title("my_module") + assert need_id.count("__") == 2 + assert need_id == "mod_vrep__my_module__report" + assert title == "My Module Verification Report" # --------------------------------------------------------------------------- -# _parse_features +# Required options # --------------------------------------------------------------------------- -def test_parse_single_feature(): - result = _parse_features("feat__my_module") - assert result == [ - {"id": "feat__my_module", "slug": "my_module", "title": "My Module"} - ] - - -def test_parse_multiple_features(): - """:features: is a list link, so the option accepts a list.""" - result = _parse_features("feat__one,\n feat__two\n") - assert [f["id"] for f in result] == ["feat__one", "feat__two"] - assert [f["slug"] for f in result] == ["one", "two"] - - -def test_parse_features_strips_version_qualifier(): - result = _parse_features("feat__demo[version==2]") - assert result[0]["id"] == "feat__demo" - - -def test_parse_features_keeps_full_id_without_feat_prefix(): - result = _parse_features("noprefix") - assert result[0]["id"] == "noprefix" - assert result[0]["slug"] == "noprefix" - - -def test_parse_features_empty(): - assert _parse_features("") == [] - assert _parse_features(" , ") == [] - - def test_components_and_features_are_the_mandatory_links(): """The directive must require exactly the need type's mandatory links.""" assert _MOD_VER_REPORT_LINKS == ("components", "features") -# --------------------------------------------------------------------------- -# Tests — _mod_ver_report_id_and_title -# --------------------------------------------------------------------------- - - -def test_mod_ver_report_id_and_title_basic(): - report_id, title = _mod_ver_report_id_and_title("baselibs") - assert report_id == "mod_vrep__baselibs__report" - assert title == "Baselibs Verification Report" - - -def test_mod_ver_report_id_and_title_underscored_slug(): - report_id, title = _mod_ver_report_id_and_title("bit_manipulation") - assert report_id == "mod_vrep__bit_manipulation__report" - assert title == "Bit Manipulation Verification Report" +def test_mandatory_options_match_the_need_type(): + assert _MOD_VER_REPORT_OPTIONS == ( + "safety", + "security", + "status", + "verification-method", + ) diff --git a/src/extensions/score_module_verification_report/tests/test_needs_template.py b/src/extensions/score_module_verification_report/tests/test_needs_template.py new file mode 100644 index 000000000..80cc6ba75 --- /dev/null +++ b/src/extensions/score_module_verification_report/tests/test_needs_template.py @@ -0,0 +1,221 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Tests for the ``mod_ver_report`` Sphinx-Needs content template. + +The template is rendered by Sphinx-Needs from the need's own fields, using +MiniJinja. These tests render it directly with the same engine and the same +context shape, so a broken template fails here instead of in a docs build. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from sphinx_needs._jinja import render_template_string + +from src.extensions.score_module_verification_report.coverage import ( + FileCoverage, + coverage_rows, + records_for_slug, +) + +TEMPLATE = ( + Path(__file__).resolve().parents[3] / "needs_templates" / "mod_ver_report.need" +) + +_COVERAGE = [ + FileCoverage("src/json/json.cpp", 100, 96, 40, 35), + FileCoverage("src/json/json.h", 10, 10, 0, 0), +] + + +def _render(**overrides: object) -> str: + context: dict[str, object] = { + "id": "mod_vrep__baselibs__report", + "title": "Baselibs Verification Report", + "belongs_to": ["mod__baselibs"], + "components": ["comp__baselibs_json", "comp__baselibs_bit_manipulation"], + "features": ["feat__baselibs"], + "mvr_coverage": lambda slug: coverage_rows(records_for_slug(_COVERAGE, slug)), + } + context.update(overrides) + return render_template_string(TEMPLATE.read_text(), context, autoescape=False) + + +def test_template_file_is_shipped() -> None: + assert TEMPLATE.is_file(), TEMPLATE + + +# --------------------------------------------------------------------------- +# Feature sections +# --------------------------------------------------------------------------- + + +def test_feature_section_uses_the_features_link() -> None: + """The feature is read off the need, never guessed from the module id.""" + out = _render(features=["feat__something_else"]) + assert 'id == "feat__something_else"' in out + assert "feat__baselibs" not in out + + +def test_single_feature_keeps_the_plain_heading() -> None: + out = _render() + assert ".. rubric:: Feature\n" in out + assert "Feature: " not in out + + +def test_one_section_per_feature_with_qualified_headings() -> None: + out = _render(features=["feat__demo_one", "feat__demo_two"]) + assert 'id == "feat__demo_one"' in out + assert 'id == "feat__demo_two"' in out + assert ".. rubric:: Feature: Demo One" in out + assert ".. rubric:: Feature: Demo Two" in out + + +def test_feature_workproducts_match_on_the_feature_slug() -> None: + out = _render(features=["feat__baselibs"]) + assert '"baselibs" in id.replace("_", "").lower()' in out + # The feature table carries only the two feature-level work products. + feature_block = out[: out.index(".. rubric:: Components")] + assert "wp__requirements_inspect" in feature_block + assert "wp__sw_arch_verification" in feature_block + assert "wp__sw_component_fmea" not in feature_block + + +# --------------------------------------------------------------------------- +# Component sections +# --------------------------------------------------------------------------- + + +def test_component_overview_lists_exactly_the_linked_components() -> None: + out = _render() + assert ( + ':filter: id in ["comp__baselibs_json", "comp__baselibs_bit_manipulation"]' + in out + ) + + +def test_component_title_and_anchor_derive_from_the_id() -> None: + out = _render() + assert ".. _comp-bit-manipulation:" in out + assert ".. rubric:: Bit Manipulation" in out + assert ".. _comp-json:" in out + assert ".. rubric:: Json" in out + + +def test_every_component_gets_the_full_set_of_workproducts() -> None: + out = _render(components=["comp__baselibs_json"]) + for wp in ( + "wp__requirements_inspect", + "wp__sw_arch_verification", + "wp__sw_implementation_inspection", + "wp__sw_component_dfa", + "wp__sw_component_fmea", + ): + assert f":need:`{wp}`" in out + + +def test_needpie_filters_guard_against_missing_verify_fields() -> None: + """Needs without ``*_verifies_back`` must not break the pie filters.""" + out = _render() + assert '"fully_verifies_back" in locals()' in out + assert '"partially_verifies_back" in locals()' in out + + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- + + +def test_coverage_table_rendered_when_data_matches() -> None: + out = _render(components=["comp__baselibs_json"]) + assert "``src/json/json.cpp``" in out + assert "**Total**" in out + assert "No coverage data available" not in out + + +def test_coverage_note_rendered_when_nothing_matches() -> None: + out = _render(components=["comp__baselibs_bit_manipulation"]) + assert "No coverage data available" in out + assert "src/json/json.cpp" not in out + + +def test_coverage_helper_is_called_with_the_normalised_slug() -> None: + seen: list[str] = [] + _render( + components=["comp__baselibs_bit_manipulation"], + mvr_coverage=lambda slug: seen.append(slug) or "", + ) + assert seen == ["bitmanipulation"] + + +# --------------------------------------------------------------------------- +# Structure +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "rubric", + [ + "Feature Requirements Statistics", + "Feature Architecture Statistics", + "Feature Inspection Statistics", + "Components", + "Component Overview", + "Component Requirements Statistics", + "Component Architecture Statistics", + "Requirements Traceability", + "Test Coverage", + "Architectural Elements", + "Verification & Safety Analysis Documents", + ], +) +def test_all_report_sections_are_present(rubric: str) -> None: + assert f".. rubric:: {rubric}" in _render() + + +def test_list_tables_have_a_consistent_number_of_fields_per_row() -> None: + """A short row silently corrupts a ``list-table``; catch it here.""" + lines = _render().splitlines() + checked = 0 + i = 0 + while i < len(lines): + if not lines[i].strip().startswith(".. list-table::"): + i += 1 + continue + indent = len(lines[i]) - len(lines[i].lstrip()) + j, per_row = i + 1, [] + while j < len(lines): + line = lines[j] + if line.strip() and (len(line) - len(line.lstrip())) <= indent: + break + stripped = line.strip() + # A cell may be empty ("- " with nothing after it), e.g. the + # branch-% column for a file with no branch data. + if stripped == "*" or stripped.startswith("* - ") or stripped == "* -": + per_row.append(1) + elif (stripped == "-" or stripped.startswith("- ")) and per_row: + per_row[-1] += 1 + j += 1 + assert len(set(per_row)) == 1, f"ragged list-table at line {i + 1}: {per_row}" + checked += 1 + i = j + # feature WPs, component overview is a needtable, 2x component WPs, coverage + assert checked >= 4 + + +def test_no_unrendered_jinja_remains() -> None: + out = _render() + for marker in ("{{", "}}", "{%", "%}"): + assert marker not in out, marker diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index bf1feca76..d55cf42b0 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -15,122 +15,25 @@ from __future__ import annotations from src.extensions.score_module_verification_report.rendering import ( - normalize_slug, - render_component, - render_feature, render_mod_ver_report, - render_overview, - render_report, - slugify, - workproduct_rows, ) -_WP = [ - {"key": "req", "label": "Requirements Inspection", "wp_id": "wp__req"}, - {"key": "arc", "label": "Architecture Inspection", "wp_id": "wp__arc"}, -] - - -# --------------------------------------------------------------------------- -# slug utilities -# --------------------------------------------------------------------------- - - -def test_normalize_slug_strips_underscores_and_lowercases() -> None: - assert normalize_slug("Bit_Manipulation") == "bitmanipulation" - - -def test_slugify_converts_non_alnum_to_dashes() -> None: - assert slugify("Foo Bar / Baz!") == "foo-bar-baz" - - -def test_slugify_strips_leading_trailing_dashes() -> None: - assert slugify("---weird---") == "weird" - - -# --------------------------------------------------------------------------- -# workproduct_rows -# --------------------------------------------------------------------------- - - -def test_workproduct_rows_uses_needtable_by_default() -> None: - out = workproduct_rows("kvs", workproducts=_WP) - assert ":need:`wp__req`" in out - assert "Requirements Inspection" in out - # Both id and status cells rendered as needtables with matching filter. - assert out.count(".. needtable::") == 4 - # Slug substring match (id.replace("_", "")) is emitted verbatim. - assert '"kvs" in id.replace("_", "")' in out - assert '"wp__req" in realizes' in out - - -def test_workproduct_rows_no_rows_when_workproducts_empty() -> None: - assert workproduct_rows("kvs", []) == "" - - -# --------------------------------------------------------------------------- -# render_overview -# --------------------------------------------------------------------------- - - -def test_render_overview_builds_id_list_literal() -> None: - components = [{"id": "comp__a"}, {"id": "comp__b"}] - out = render_overview(components) - assert 'id in ["comp__a", "comp__b"]' in out - assert ".. needtable::" in out - - -def test_render_overview_empty_components() -> None: - assert "id in []" in render_overview([]) - - -# --------------------------------------------------------------------------- -# render_component / render_feature -# --------------------------------------------------------------------------- - - -def test_render_component_contains_component_specific_filters() -> None: - comp = {"id": "comp__demo_kvs", "slug": "kvs", "title": "Key-Value Store"} - out = render_component(comp, workproducts=_WP) - # Title underline (~ * len(title)). - assert "~" * len("Key-Value Store") in out - # comp_id substituted into all filter expressions. - assert '"comp__demo_kvs" in satisfied_by' in out - assert '"comp__demo_kvs" in belongs_to' in out - # Anchor uses slugified title (spaces / punctuation collapsed). - assert ".. _comp-key-value-store:" in out - # Workproduct table headers present. - assert "Verification & Safety Analysis Documents" in out - - -def test_render_feature_substitutes_feature_id_and_slug() -> None: - out = render_feature( - feature_id="feat__demo", - feature_slug="demo", - feature_workproducts=_WP, - ) - assert 'id == "feat__demo"' in out - assert '"feat__demo" in satisfied_by' in out - assert '"feat__demo" in belongs_to' in out - assert "Feature Inspection Statistics" in out - - -# --------------------------------------------------------------------------- -# render_mod_ver_report -# --------------------------------------------------------------------------- +_ARGS = dict( + module_id="mod__demo", + report_id="mod_vrep__demo__report", + title="Demo Verification Report", + safety="QM", + security="YES", + status="valid", + verification_method="test_and_inspection", +) -def test_render_mod_ver_report_contains_only_mandatory_fields() -> None: +def test_render_mod_ver_report_emits_all_mandatory_fields() -> None: out = render_mod_ver_report( - module_id="mod__demo", - report_id="mod_vrep__demo__report", - title="Demo Verification Report", - safety="QM", - security="YES", - status="valid", - verification_method="test_and_inspection", components=["comp__demo_a", "comp__demo_b"], features=["feat__demo"], + **_ARGS, ) assert ".. mod_ver_report:: Demo Verification Report" in out assert ":id: mod_vrep__demo__report" in out @@ -140,126 +43,31 @@ def test_render_mod_ver_report_contains_only_mandatory_fields() -> None: assert ":status: valid" in out assert ":verification_method: test_and_inspection" in out assert ":belongs_to: mod__demo" in out - # The two mandatory links, comma-joined in option order. assert ":components: comp__demo_a, comp__demo_b" in out assert ":features: feat__demo" in out - # Only mandatory fields — no optional coverage/percent/realizes options. - assert "coverage_percent" not in out - assert ":realizes:" not in out -def test_render_mod_ver_report_links_stay_inside_the_directive_block() -> None: - """The link options must not slip past the terminating blank line.""" +def test_render_mod_ver_report_selects_the_needs_template() -> None: + """The body comes from the ``mod_ver_report`` content template.""" out = render_mod_ver_report( - module_id="mod__demo", - report_id="mod_vrep__demo__report", - title="Demo Verification Report", - safety="QM", - security="YES", - status="valid", - verification_method="test_and_inspection", - components=["comp__demo_a"], - features=["feat__demo"], + components=["comp__demo_a"], features=["feat__demo"], **_ARGS + ) + assert ":template: mod_ver_report" in out + # The directive emits the need only — no rendered body. + assert "needtable" not in out + assert "needpie" not in out + + +def test_render_mod_ver_report_block_is_self_contained() -> None: + """Every option must sit inside the directive block.""" + out = render_mod_ver_report( + components=["comp__demo_a"], features=["feat__demo"], **_ARGS ) assert out.endswith( ":belongs_to: mod__demo\n" " :components: comp__demo_a\n" " :features: feat__demo\n\n" ) - - -# --------------------------------------------------------------------------- -# render_report -# --------------------------------------------------------------------------- - - -def test_render_report_assembles_all_sections() -> None: - components = [ - {"id": "comp__demo_a", "slug": "a", "title": "A"}, - {"id": "comp__demo_b", "slug": "b", "title": "B"}, - ] - out = render_report( - components=components, - features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], - workproducts=_WP, - feature_workproducts=_WP, - ) - # CSS block for wp-doc-table styling. - assert ".wp-doc-table" in out - # Feature, Components header, overview needtable, per-component sections. - assert 'id == "feat__demo"' in out - assert "Components\n----------" in out - assert 'id in ["comp__demo_a", "comp__demo_b"]' in out - assert '"comp__demo_a" in satisfied_by' in out - assert '"comp__demo_b" in satisfied_by' in out - - -def test_render_report_includes_mod_ver_report_when_given() -> None: - components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] - out = render_report( - components=components, - features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], - workproducts=_WP, - feature_workproducts=_WP, - mod_ver_report={ - "module_id": "mod__demo", - "report_id": "mod_vrep__demo__report", - "title": "Demo Verification Report", - "safety": "QM", - "security": "YES", - "status": "valid", - "verification_method": "test_and_inspection", - "components": ["comp__demo_a"], - "features": ["feat__demo"], - }, - ) - assert ".. mod_ver_report:: Demo Verification Report" in out - assert ":belongs_to: mod__demo" in out - assert ":components: comp__demo_a" in out - assert ":features: feat__demo" in out - - -def test_render_report_omits_mod_ver_report_when_absent() -> None: - components = [{"id": "comp__demo_a", "slug": "a", "title": "A"}] - out = render_report( - components=components, - features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], - workproducts=_WP, - feature_workproducts=_WP, - ) - assert ".. mod_ver_report::" not in out - - -# --------------------------------------------------------------------------- -# render_report — multiple features -# --------------------------------------------------------------------------- - - -def test_render_report_renders_one_section_per_feature() -> None: - """``:features:`` is a list, so every entry gets its own section.""" - out = render_report( - components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], - features=[ - {"id": "feat__demo_one", "slug": "one", "title": "One"}, - {"id": "feat__demo_two", "slug": "two", "title": "Two"}, - ], - workproducts=_WP, - feature_workproducts=_WP, - ) - assert 'id == "feat__demo_one"' in out - assert 'id == "feat__demo_two"' in out - # Headings are qualified so the page has no two identical titles. - assert "Feature: One" in out - assert "Feature: Two" in out - assert "Feature\n-------" not in out - - -def test_single_feature_keeps_the_plain_heading() -> None: - out = render_report( - components=[{"id": "comp__demo_a", "slug": "a", "title": "A"}], - features=[{"id": "feat__demo", "slug": "demo", "title": "Demo"}], - workproducts=_WP, - feature_workproducts=_WP, - ) - assert "Feature\n-------" in out - assert "Feature: " not in out + body = [ln for ln in out.splitlines() if ln.strip()] + assert body[0].startswith(".. mod_ver_report::") + assert all(ln.startswith(" :") for ln in body[1:]) diff --git a/src/needs_templates/mod_ver_report.need b/src/needs_templates/mod_ver_report.need new file mode 100644 index 000000000..2258b1637 --- /dev/null +++ b/src/needs_templates/mod_ver_report.need @@ -0,0 +1,345 @@ +{# + Content template for the ``mod_ver_report`` need type. + + Everything the report needs is read off the need itself, so the template is + self-sufficient: ``belongs_to`` names the module, ``components`` and + ``features`` name the architecture needs the report describes (all three are + mandatory links, see metamodel.yaml). Sphinx-Needs renders this template when + the need is created, which is *before* the needs graph exists — so the + template must never try to look other needs up. Component titles and slugs + are therefore derived from the ids by string manipulation, and everything + else is delegated to ``needtable`` / ``needpie``, which resolve at write + time. + + The single exception is coverage: LCOV data lives on disk, not in the graph. + ``score_module_verification_report`` puts a ``mvr_coverage(slug)`` helper into + ``needs_render_context``; it returns ready-made ``list-table`` rows for the + component, or an empty string when there is no data. + + Sections are ``.. rubric::`` rather than real headings: the template renders + *inside* a need, where docutils does not allow new sections. +#} +{% set module_id = belongs_to|first|default("") %} +{% set module_short = module_id|replace("mod__", "") %} +{% set component_prefix = "comp__" ~ module_short ~ "_" %} + +{% 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 + + <style> + .wp-doc-table td .needstable_wrapper, + .wp-doc-table td .pst-scrollable-table-container { + margin: 0; padding: 0; overflow: visible; + } + .wp-doc-table td table.NEEDS_TABLE, + .wp-doc-table td table.NEEDS_DATATABLES { + border: 0; margin: 0; box-shadow: none; background: transparent; + width: auto; + } + .wp-doc-table td table.NEEDS_TABLE thead, + .wp-doc-table td table.NEEDS_DATATABLES thead { display: none; } + .wp-doc-table td table.NEEDS_TABLE tbody tr, + .wp-doc-table td table.NEEDS_DATATABLES tbody tr { background: transparent; } + .wp-doc-table td table.NEEDS_TABLE tbody td, + .wp-doc-table td table.NEEDS_DATATABLES tbody td { + border: 0; padding: 0; background: transparent; + } + .wp-doc-table td .dataTables_wrapper .dataTables_length, + .wp-doc-table td .dataTables_wrapper .dataTables_filter, + .wp-doc-table td .dataTables_wrapper .dataTables_info, + .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } + </style> + +{#- ===================================================================== -#} +{#- Feature sections — one per id in :features: -#} +{#- ===================================================================== -#} +{% for feature_id in features %} +{% set feature_slug = feature_id|replace("feat__", "") %} +{% set feature_slug_norm = feature_slug|replace("_", "")|lower %} + +.. rubric:: {% if features|length == 1 %}Feature{% else %}Feature: {{ feature_slug|replace("_", " ")|title }}{% endif %} + +.. needtable:: + :filter: id == "{{ feature_id }}" + :columns: title as "Name";id as "Id";safety;security;status + :style: table + +.. rubric:: Feature Requirements Statistics + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid" + type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "feat_req" and "{{ feature_id }}" in satisfied_by and ("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 + +.. rubric:: Feature Architecture Statistics + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "valid" + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: Feature Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and "inspected" in tags + type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to and "inspected" not in tags + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["feat_arc_sta", "feat_arc_dyn"] and "{{ feature_id }}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +.. rubric:: Feature Inspection Statistics + +Presence of the feature-level inspection work products. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status +{{- workproduct_rows(feature_slug_norm, feature_workproducts) }} +{% endfor %} + +{#- ===================================================================== -#} +{#- Components -#} +{#- ===================================================================== -#} + +.. rubric:: Components + +.. rubric:: Component Overview + +.. needtable:: + :filter: id in [{% for c in components %}"{{ c }}"{% if not loop.last %}, {% endif %}{% endfor %}] + :columns: id as "Component";safety;security;status + :style: table + :sort: id + +{% for component_id in components %} +{% set component_slug = component_id|replace(component_prefix, "") %} +{% set component_slug_norm = component_slug|replace("_", "")|lower %} +{% set component_title = component_slug|replace("_", " ")|title %} + +.. _comp-{{ component_slug|replace("_", "-")|lower }}: + +.. rubric:: {{ component_title }} + +.. raw:: html + + <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> + +.. rubric:: Component Requirements Statistics + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Requirements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid" + type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid" + + .. grid-item:: + + .. needpie:: {{ component_title }} Requirements Test Coverage + :labels: fully covered, partially covered, not covered + :colors: #37a12d, #f0a500, #ca2828 + :legend: + + type == "comp_req" and "{{ component_id }}" in satisfied_by and ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{{ component_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) + type == "comp_req" and "{{ component_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) + +.. rubric:: Component Architecture Statistics + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Status + :labels: valid, invalid + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "valid" + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "invalid" + + .. grid-item:: + + .. needpie:: {{ component_title }} Architecture Elements Inspection Status + :labels: inspected, not inspected + :colors: #37a12d, #ca2828 + :legend: + + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" in tags + type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" not in tags + +.. rubric:: Requirements Traceability + +The following table lists all requirements of this component together with their +verification status and the tests that (fully or partially) verify them: + +.. dropdown:: Show requirements table + :animate: fade-in + + .. needtable:: + :filter: type == "comp_req" and "{{ component_id }}" in satisfied_by + :style: table + :columns: id;title;safety;status;fully_verifies_back;partially_verifies_back + :colwidths: 13,22,8,10,23,24 + :sort: id + +.. rubric:: Test Coverage + +Per-source-file line and branch coverage aggregated from the LCOV report +produced by ``bazel coverage``. + +.. dropdown:: Show test coverage table + :animate: fade-in +{%- set coverage_body = mvr_coverage(component_slug_norm) %} +{%- if coverage_body %} + + .. list-table:: + :header-rows: 1 + :widths: 45 10 10 10 10 10 10 + + * - Source + - Lines found + - Lines hit + - Line % + - Branches found + - Branches hit + - Branch % +{{ coverage_body }} +{%- else %} + + .. note:: + + No coverage data available for this component. Run ``bazel coverage`` + with the corresponding targets and rebuild the docs to populate this + table. +{%- endif %} + +.. rubric:: Architectural Elements + +The following table lists the architectural elements of this component +together with their inspection status. Elements that have been formally +inspected carry the ``inspected`` tag; elements without that tag have not +yet been inspected. + +.. dropdown:: Show architectural elements table + :animate: fade-in + + .. needtable:: + :filter: type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to + :style: table + :columns: id;title;safety;status;tags + :colwidths: 25,30,10,15,20 + :sort: id + +.. rubric:: Verification & Safety Analysis Documents + +Presence of the standard verification and safety analysis work products for +this component. A dash (``—``) means the corresponding document is missing. + +.. dropdown:: Show work products table + :animate: fade-in + + .. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + :class: wp-doc-table + + * - Work Product + - Kind + - Realized by + - Status +{{- workproduct_rows(component_slug_norm, component_workproducts) }} +{% endfor %} From 1bf4e92c7faaa095db9da824a9f77be1a4c49bc0 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak <maximilian.pollak@qorix.com> Date: Thu, 27 Aug 2026 19:57:00 +0200 Subject: [PATCH 23/25] WIP: templates working & some simplification --- .../docs/module_verification_report.rst | 12 +- .../checks/mod_ver_report_checks.py | 163 ++++++++++++------ src/extensions/score_metamodel/metamodel.yaml | 14 ++ .../__init__.py | 1 + .../directive.py | 29 ++-- .../tests/test_directive.py | 31 ++-- .../tests/test_rendering.py | 2 +- 7 files changed, 169 insertions(+), 83 deletions(-) diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 5000961bf..33afc5a60 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -34,6 +34,7 @@ Typical usage (``verification_report/module_verification_report.rst``): .. code-block:: rst .. module-verification-report:: + :id: mod_vrep__mymodule__report :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b :features: feat__mymodule @@ -55,6 +56,13 @@ Options - Required - Description + * - ``:id:`` + - yes + - Id of the generated ``mod_ver_report`` need, used verbatim. Must + follow the 3-part scheme the need type requires + (``mod_vrep__<abbrev>__<element>``); ``score_metamodel`` validates it + like any other need id. + * - ``:module-id:`` - yes - sphinx-needs id of the ``.. mod::`` need (e.g. ``mod__mymodule``). @@ -104,8 +112,8 @@ Metamodel validation ``:safety:``, ``:security:``, ``:status:``, ``:verification-method:`` and ``:version:`` are not just directive options — the directive uses them to -emit a single sphinx-needs ``mod_ver_report`` need (id -``mod_vrep__<module-short>__report``, linked ``belongs_to`` the module's +emit a single sphinx-needs ``mod_ver_report`` need (id taken from +``:id:``, linked ``belongs_to`` the module's ``.. mod::`` need). ``:components:`` and ``:features:`` are passed straight through to the need's ``components`` and ``features`` links, which ``metamodel.yaml`` declares mandatory and types to ``comp`` / ``feat``. This diff --git a/src/extensions/score_metamodel/checks/mod_ver_report_checks.py b/src/extensions/score_metamodel/checks/mod_ver_report_checks.py index 2586e954c..8fe1b45af 100644 --- a/src/extensions/score_metamodel/checks/mod_ver_report_checks.py +++ b/src/extensions/score_metamodel/checks/mod_ver_report_checks.py @@ -12,33 +12,32 @@ # ******************************************************************************* """Graph checks for ``mod_ver_report`` needs. -A ``mod_ver_report`` need is emitted by the -``.. module-verification-report::`` directive of -``score_module_verification_report``. It declares the module it belongs to -(``belongs_to``) and the artifacts the report covers (``covers``: the feature -and every component the report renders a section for). +A ``mod_ver_report`` need declares the module it belongs to (``belongs_to``) +and the architecture needs it describes (``components`` and ``features``, both +mandatory links). Its body is rendered by the ``mod_ver_report`` content +template from those same fields. Because all of that lives in the needs graph, the report can be validated -against the architecture needs it claims to describe: - -1. ``covers`` and the module's ``includes`` must name the *same* components. - The report and the module are two independent statements about which - components make up the module — if they disagree, one of them is stale. -2. Every covered component must ``belongs_to`` every covered feature. A report - that covers ``feat__x`` and ``comp__y`` asserts that ``comp__y`` is part of - ``feat__x``; the component need has to agree. - -Both directions of rule 1 are reported, but only the direction that was -already enforced before this check existed (component covered by the report, -missing from the module) is a hard warning. The opposite direction is reported -as a non-fatal "new check" so that existing modules with an incomplete report -do not break their build immediately. +against the needs it claims to describe: + +1. ``components`` and the module's ``includes`` must name the *same* set. The + report and the module are two independent statements about which components + make up the module — if they disagree, one of them is stale. Both + directions are reported, and independently: a report that skips a component + of its module is exactly as wrong as one that describes a component the + module does not have. +2. Every feature a listed component ``belongs_to`` must itself be listed in + ``features``. A report spanning several features is fine — what is not fine + is a component whose feature the report never mentions, because the + feature-level statistics then silently omit it. + +Everything here reports through :class:`CheckLogger` rather than raising: a +malformed report must not abort the whole docs build, and the author needs to +see every problem in one run, not just the first. """ from __future__ import annotations -from typing import Any - from score_metamodel import ( CheckLogger, graph_check, @@ -48,6 +47,20 @@ from sphinx_needs.need_item import NeedItem +def _linked_ids(need: NeedItem, link: str) -> list[str]: + """Return the ids linked via *link*, or an empty list. + + A declared but unset link yields ``[]``. The ``or []`` also covers a need + type that does not declare *link* at all, where the lookup yields ``None``. + """ + return need.get(link) or [] + + +def _join(ids: list[str]) -> str: + """Render a list of need ids for a warning message.""" + return ", ".join(f"`{i}`" for i in sorted(ids)) + + def _resolve( report: NeedItem, link: str, @@ -56,7 +69,7 @@ def _resolve( ) -> list[NeedItem]: """Resolve the ids linked via *link* to needs, warning about unknown ones.""" resolved: list[NeedItem] = [] - for need_id in report.get(link, []): + for need_id in _linked_ids(report, link): target = all_needs.get(need_id) if target is None: log.warning_for_need( @@ -67,32 +80,68 @@ def _resolve( return resolved -def _check_component_parity(report: NeedItem, module: NeedItem, log: CheckLogger): - module_components: set[str] = set(module.get("includes")) - report_components: set[str] = set(report.get("components")) - components_not_mention_in_report = module_components.difference(report_components) - if components_not_mention_in_report: - msg = f"Module includes components: {components_not_mention_in_report} that are not mentioned in the Module verification report: {report.id}" - log.warning_for_need(report, msg) +def _check_component_parity( + report: NeedItem, module: NeedItem, log: CheckLogger +) -> None: + """The report's ``components`` and the module's ``includes`` must match.""" + module_components = set(_linked_ids(module, "includes")) + report_components = set(_linked_ids(report, "components")) + module_id = module["id"] + + # The two directions are independent problems, so they are reported + # independently — a report that lists a stale component must still be told + # about the component it is missing. + missing_from_report = module_components - report_components + if missing_from_report: + log.warning_for_need( + report, + f"does not list {_join(list(missing_from_report))} under " + f"`components`, but `{module_id}` `includes` " + f"{'them' if len(missing_from_report) > 1 else 'it'}. The " + "verification report must describe every component of the module.", + ) - components_in_report_not_in_module = report_components.difference( - module_components + missing_from_module = report_components - module_components + if missing_from_module: + log.warning_for_need( + report, + f"lists {_join(list(missing_from_module))} under `components`, but " + f"`{module_id}` does not `includes` " + f"{'them' if len(missing_from_module) > 1 else 'it'}.", ) - if components_in_report_not_in_module: - msg = f"Module verification Report: {report.id} mentiones components the linked Module:{report.belongs_to} does not mention. Components mentioned: {components_in_report_not_in_module}" - log.warning_for_need(report, msg) def _check_features_included( - report: NeedItem, features: list[NeedItem], components: list[NeedItem], log -): - comp_feat_dict = {c.id: c.get("belongs_to")[0] for c in components} - features_in_components = set(comp_feat_dict.values()) - feats_missing_in_components = features_in_components.difference(set(features)) - if feats_missing_in_components: - comp_feat_missing = [comp_feat_dict[feat] for feat in features_in_components] - msg = f"Components: {comp_feat_missing} are mentioning Features: {features_in_components} that are not mentioned in the Module Verification Report: {report.id}" - log.warning_for_need(report, msg) + report: NeedItem, + features: list[NeedItem], + components: list[NeedItem], + log: CheckLogger, +) -> None: + """Every feature a listed component belongs to must be listed too. + + A component may belong to more than one feature, and a report may span + more than one feature, so this compares the *full* set of features reached + through the components against the set the report declares. + """ + listed_feature_ids = {feature["id"] for feature in features} + + # feature id -> the listed components that belong to it. Keyed by feature + # so the warning can name both the feature that is missing and the + # components that pointed at it. + unlisted: dict[str, list[str]] = {} + for component in components: + for feature_id in _linked_ids(component, "belongs_to"): + if feature_id not in listed_feature_ids: + unlisted.setdefault(feature_id, []).append(component["id"]) + + for feature_id in sorted(unlisted): + components_str = _join(unlisted[feature_id]) + log.warning_for_need( + report, + f"does not list `{feature_id}` under `features`, but " + f"{components_str} " + f"{'belong' if len(unlisted[feature_id]) > 1 else 'belongs'} to it.", + ) @graph_check @@ -101,22 +150,26 @@ def check_mod_ver_report_links( all_needs: NeedsView, log: CheckLogger, ) -> None: - """Validate that every ``mod_ver_report`` agrees with the needs it covers.""" + """Validate that every ``mod_ver_report`` agrees with the needs it describes.""" reports = all_needs.filter_is_external(False).filter_types(["mod_ver_report"]) for report in reports.values(): - # TODO: improve errors components = _resolve(report, "components", all_needs, log) features = _resolve(report, "features", all_needs, log) modules = _resolve(report, "belongs_to", all_needs, log) - # There can only be one module linked to a mod_ver_report - # Needed? - assert modules - if len(modules) != 1: - msg = f"Only one module is allowed to be mentioned in Module Verification Report: {report.id}" - log.warning_for_need(report, msg) - module = modules[0] - - # Module should have all the same components as the mod_ver_report - _check_component_parity(report, module, log) + _check_features_included(report, features, components, log) + + if not modules: + # `belongs_to` is a mandatory link: the option checks report it + # missing, and _resolve already warned about an unresolvable id. + # Nothing left to compare the components against. + continue + if len(modules) > 1: + log.warning_for_need( + report, + f"`belongs_to` names {len(modules)} modules " + f"({_join([m['id'] for m in modules])}); a verification report " + "describes exactly one module.", + ) + _check_component_parity(report, modules[0], log) diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index 31596cdb4..f794d8dbd 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -1143,6 +1143,20 @@ needs_extra_links: incoming: evidence_for outgoing: evidence + # req-Id: tool_req__docs_verification_report_need + # Mandatory links of `mod_ver_report`: the architecture needs a module + # verification report describes. They must be declared here as well, not just + # in the need type's `mandatory_links` — sphinx-needs only creates a + # directive option for links listed in `needs_extra_links`, and silently + # drops the value of any option it does not know. + components: + incoming: component_reported_by + outgoing: components + + features: + incoming: feature_reported_by + outgoing: features + ############################################################## # Graph Checks diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 0d753e658..8d376e5ae 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -15,6 +15,7 @@ Usage in RST:: .. module-verification-report:: + :id: mod_vrep__baselibs__report :module-id: mod__baselibs :features: feat__baselibs :safety: ASIL_B diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index 275f04751..c2cb24dc9 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -44,7 +44,7 @@ def _parse_ids(ids_str: str) -> list[str]: # Mandatory options every ``mod_ver_report`` need requires (see # metamodel.yaml) that this directive cannot derive on its own. -_MOD_VER_REPORT_OPTIONS = ("safety", "security", "status", "verification-method") +_MOD_VER_REPORT_OPTIONS = ("id", "safety", "security", "status", "verification-method") # Mandatory *links* of the ``mod_ver_report`` need type. Both are populated # from the directive option of the same name, so the option is required too — @@ -54,17 +54,16 @@ def _parse_ids(ids_str: str) -> list[str]: _MOD_VER_REPORT_LINKS = ("components", "features") -def _mod_ver_report_id_and_title(module_short: str) -> tuple[str, str]: - """Derive the ``mod_vrep__...`` need id and its human-readable title - from the module slug. +def _mod_ver_report_title(module_short: str) -> str: + """Derive the report's human-readable title from the module slug. - The id follows the ``<Req Type>__<Abbreviations>__<Architectural - Element>`` scheme mandated for 3-part need types (``mod_ver_report`` - declares ``parts: 3`` in metamodel.yaml), so exactly two ``__`` - separators are required: ``mod_vrep__<module_short>__report``. + Only the display title is derived. The need's id comes from the + directive's ``:id:`` option and is passed through untouched, so the + author stays in control of it — ``mod_ver_report`` declares + ``parts: 3`` in metamodel.yaml, and the metamodel's id checks report a + value that does not follow that scheme. """ - title_case = module_short.replace("_", " ").title() - return f"mod_vrep__{module_short}__report", f"{title_case} Verification Report" + return f"{module_short.replace('_', ' ').title()} Verification Report" class ModuleVerificationReportDirective(SphinxDirective): @@ -73,6 +72,7 @@ class ModuleVerificationReportDirective(SphinxDirective): Usage:: .. module-verification-report:: + :id: mod_vrep__mymodule__report :module-id: mod__mymodule :components: comp__mymodule_a, comp__mymodule_b :features: feat__mymodule @@ -83,8 +83,9 @@ class ModuleVerificationReportDirective(SphinxDirective): Every option is required: each maps onto a mandatory option or link of the sphinx-needs ``mod_ver_report`` need type (see metamodel.yaml). The - directive is a shorthand — it derives the need's id and title from - ``:module-id:`` and passes the rest straight through. + directive is a shorthand — ``:id:`` becomes the need's id verbatim, the + title is derived from ``:module-id:``, and the rest is passed straight + through. The report *body* is not generated here. The emitted need selects the ``mod_ver_report`` content template @@ -95,6 +96,7 @@ class ModuleVerificationReportDirective(SphinxDirective): required_arguments = 0 optional_arguments = 0 option_spec = { + "id": str, "module-id": str, "components": str, "features": str, @@ -144,7 +146,8 @@ def run(self) -> list[nodes.Node]: "generate the mod_ver_report need" ) - report_id, report_title = _mod_ver_report_id_and_title(module_short) + report_id = self.options["id"] + report_title = _mod_ver_report_title(module_short) rst_text = render_mod_ver_report( module_id=module_id, report_id=report_id, diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 307e1ba6c..84db5a9fe 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -22,7 +22,7 @@ from src.extensions.score_module_verification_report.directive import ( _MOD_VER_REPORT_LINKS, _MOD_VER_REPORT_OPTIONS, - _mod_ver_report_id_and_title, + _mod_ver_report_title, _parse_ids, ) @@ -65,22 +65,27 @@ def test_parse_is_type_agnostic(): # --------------------------------------------------------------------------- -# _mod_ver_report_id_and_title +# _mod_ver_report_title # --------------------------------------------------------------------------- -def test_id_and_title_derived_from_module_slug(): - need_id, title = _mod_ver_report_id_and_title("baselibs") - assert need_id == "mod_vrep__baselibs__report" - assert title == "Baselibs Verification Report" +def test_title_derived_from_module_slug(): + assert _mod_ver_report_title("baselibs") == "Baselibs Verification Report" -def test_id_keeps_exactly_two_separators_for_multiword_modules(): - """``mod_ver_report`` declares ``parts: 3`` — no more, no less.""" - need_id, title = _mod_ver_report_id_and_title("my_module") - assert need_id.count("__") == 2 - assert need_id == "mod_vrep__my_module__report" - assert title == "My Module Verification Report" +def test_title_title_cases_multiword_modules(): + assert _mod_ver_report_title("my_module") == "My Module Verification Report" + + +def test_id_is_not_derived(): + """The need id comes from the author's :id:, never from the module slug.""" + import inspect + + from src.extensions.score_module_verification_report import directive + + source = inspect.getsource(directive.ModuleVerificationReportDirective.run) + assert 'report_id = self.options["id"]' in source + assert "mod_vrep__" not in source # --------------------------------------------------------------------------- @@ -94,7 +99,9 @@ def test_components_and_features_are_the_mandatory_links(): def test_mandatory_options_match_the_need_type(): + """:id: is required too — the directive no longer invents one.""" assert _MOD_VER_REPORT_OPTIONS == ( + "id", "safety", "security", "status", diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py index d55cf42b0..8eee6e536 100644 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ b/src/extensions/score_module_verification_report/tests/test_rendering.py @@ -20,7 +20,7 @@ _ARGS = dict( module_id="mod__demo", - report_id="mod_vrep__demo__report", + report_id="mod_vrep__demo__report", # supplied by the directive's :id: title="Demo Verification Report", safety="QM", security="YES", From 3d09fb5551068453ffdc72faf5c4856d057a4900 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak <maximilian.pollak@qorix.com> Date: Thu, 27 Aug 2026 20:09:09 +0200 Subject: [PATCH 24/25] Simplification Removing LCOV & other simplifications --- .../docs/module_verification_report.rst | 54 ++++- .../__init__.py | 49 ++--- .../coverage.py | 197 ------------------ .../directive.py | 186 +++++++---------- .../render_context.py | 74 ------- .../rendering.py | 53 ----- .../templates.py | 42 ---- .../tests/test_directive.py | 143 +++++++++---- .../tests/test_needs_template.py | 44 +--- .../tests/test_rendering.py | 73 ------- src/needs_templates/mod_ver_report.need | 36 ---- 11 files changed, 244 insertions(+), 707 deletions(-) delete mode 100644 src/extensions/score_module_verification_report/coverage.py delete mode 100644 src/extensions/score_module_verification_report/render_context.py delete mode 100644 src/extensions/score_module_verification_report/rendering.py delete mode 100644 src/extensions/score_module_verification_report/templates.py delete mode 100644 src/extensions/score_module_verification_report/tests/test_rendering.py diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 33afc5a60..2c6415ef1 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -179,9 +179,51 @@ resolve at write time. TOC entries; each component section is still a link target (``comp-<slug-with-dashes>``). -Coverage is the one thing the template cannot reach on its own — LCOV data is -a file on disk, not a need. ``render_context.py`` registers a -``mvr_coverage(slug)`` helper in ``needs_render_context`` that returns -ready-made table rows for a component, or an empty string when there is no -match, and the template renders either the table or a "no coverage data" note. -The LCOV file is parsed once per build, on first use. +Graph consistency +----------------- + +Because the emitted need records which architecture needs the report +describes, the report can be cross-checked against them. That is done by +``score_metamodel``'s ``check_mod_ver_report_links`` graph check +(``src/extensions/score_metamodel/checks/mod_ver_report_checks.py``), which +runs together with every other metamodel check — there is no separate +build-finished pass any more. + +It enforces two rules per ``mod_ver_report`` need: + +#. The need's ``components`` and the module's ``:includes:`` must be the same + set. The report and the module are two independent statements about which + components make up the module; if they disagree, one of them is stale. Both + directions are warnings: a report that skips a component of its module is + exactly as wrong as one that describes a component the module does not + have. +#. Every listed component must ``:belongs_to:`` one of the listed features. A + report naming ``:features: feat__x`` and ``:components: comp__y`` asserts + that ``comp__y`` is part of ``feat__x``; the ``.. comp::`` need has to say + so too. + +Ids in ``:components:`` or ``:features:`` that do not resolve to a need are +reported as well. Every problem is reported as a warning rather than raised, +so one build surfaces all of them. + +Like every other graph check, it can be disabled or run in isolation via the +``score_metamodel_checks`` config value, e.g. +``score_metamodel_checks = "check_mod_ver_report_links"``. + +The report template +------------------- + +The body lives in ``src/needs_templates/mod_ver_report.need`` and is rendered +by Sphinx-Needs, not by this extension. Two consequences are worth knowing: + +*Templates render during the read phase*, when the need is created and the +needs graph does not exist yet. The template therefore never looks other needs +up. It reads ``belongs_to`` / ``components`` / ``features`` off the need +itself, derives component slugs and titles from the ids by string +manipulation, and leaves everything else to ``needtable`` / ``needpie``, which +resolve at write time. + +*A need's content cannot open new sections*, so the report uses +``.. rubric::`` where a standalone page would use headings. Rubrics carry no +TOC entries; each component section is still a link target +(``comp-<slug-with-dashes>``). diff --git a/src/extensions/score_module_verification_report/__init__.py b/src/extensions/score_module_verification_report/__init__.py index 8d376e5ae..2e3426fcd 100644 --- a/src/extensions/score_module_verification_report/__init__.py +++ b/src/extensions/score_module_verification_report/__init__.py @@ -10,7 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Sphinx extension that generates the per-module verification report body. +"""Sphinx extension providing the module verification report directive. Usage in RST:: @@ -18,37 +18,22 @@ :id: mod_vrep__baselibs__report :module-id: mod__baselibs :features: feat__baselibs + :components: comp__baselibs_json, comp__baselibs_containers :safety: ASIL_B :security: YES :status: valid :verification-method: test_and_inspection - :components: comp__baselibs_json, - comp__baselibs_bit_manipulation, - comp__baselibs_containers -``safety``/``security``/``status``/``verification-method`` are the -mandatory options of the sphinx-needs ``mod_ver_report`` need type, and -``components``/``features`` are its mandatory links (see metamodel.yaml). The -directive emits one such need — ``belongs_to`` the module, ``components`` and -``features`` passed straight through from the options of the same name — so -the report is machine-readable and its links are validated by -score_metamodel's need-link and graph checks, not just rendered RST. +The directive emits a single sphinx-needs ``mod_ver_report`` need. Its body — +feature summary and statistics, component overview, and one section per +component — comes from the ``mod_ver_report`` content template +(``src/needs_templates/mod_ver_report.need``), which Sphinx-Needs renders from +the need's own fields. -Implementation is split across: - -* :mod:`.templates` — RST templates + default workproduct lists + CSS -* :mod:`.rendering` — template expansion / report body assembly -* :mod:`.directive` — the ``ModuleVerificationReportDirective`` class - -Consistency of the emitted need with the rest of the needs graph (does the -module ``includes`` exactly the components the report lists? does every listed -component ``belongs_to`` a listed feature?) is validated by ``score_metamodel`` -'s ``check_mod_ver_report_links`` graph check, not by this extension. - -Testcase back-links rendered by this directive are annotated with a -``(passed)`` / ``(failed)`` result badge by -``score_source_code_linker``'s ``doctree-resolved`` hook, not by this -extension. +Consistency of the need with the rest of the graph — does the module +``includes`` exactly the components the report lists? does every listed +component ``belongs_to`` a listed feature? — is validated by +``score_metamodel``'s ``check_mod_ver_report_links`` graph check. """ from __future__ import annotations @@ -56,22 +41,12 @@ from typing import Any from .directive import ModuleVerificationReportDirective -from .render_context import register_render_context def setup(app: Any) -> dict: app.add_directive("module-verification-report", ModuleVerificationReportDirective) - app.add_config_value( - "mvr_coverage_lcov", - "bazel-out/_coverage/_coverage_report.dat", - "env", - ) - # The ``mod_ver_report`` need template renders coverage tables, but LCOV - # data lives on disk rather than in the needs graph — expose it as a - # render-context helper the template can call. - app.connect("config-inited", register_render_context) return { - "version": "0.9", + "version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/src/extensions/score_module_verification_report/coverage.py b/src/extensions/score_module_verification_report/coverage.py deleted file mode 100644 index ec1c2e844..000000000 --- a/src/extensions/score_module_verification_report/coverage.py +++ /dev/null @@ -1,197 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""LCOV parsing and per-component aggregation for the coverage dropdown. - -The report renderer calls :func:`load_coverage` once per build; the result -is a list of :class:`FileCoverage` records that :func:`records_for_slug` -filters per component using the same normalised-slug substring match that -:func:`.rendering.workproduct_rows` uses for work-product zuordnung. - -If the LCOV file cannot be found (no ``bazel coverage`` run yet), the -functions return empty lists so the coverage dropdown is silently -omitted rather than breaking the docs build. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from pathlib import Path - -logger = logging.getLogger(__name__) - - -def _workspace_root() -> Path | None: - """Return the workspace root or ``None`` if it cannot be determined. - - ``helper_lib.find_ws_root`` is only importable at Bazel run-time; in - plain pytest runs it is absent, in which case we fall back to walking - up from the current working directory looking for ``MODULE.bazel`` - or ``WORKSPACE``. - """ - try: - from helper_lib import find_ws_root # type: ignore[import-not-found] - - root = find_ws_root() - if root is not None: - return root - except ImportError: - pass - cwd = Path.cwd() - for candidate in (cwd, *cwd.parents): - if (candidate / "MODULE.bazel").exists() or (candidate / "WORKSPACE").exists(): - return candidate - return None - - -@dataclass -class FileCoverage: - """Aggregated coverage for a single source file (one ``SF:`` record).""" - - source: str - lines_found: int = 0 - lines_hit: int = 0 - branches_found: int = 0 - branches_hit: int = 0 - - @property - def line_pct(self) -> float: - return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 0.0 - - @property - def branch_pct(self) -> float: - return ( - 100.0 * self.branches_hit / self.branches_found - if self.branches_found - else 0.0 - ) - - -def parse_lcov(path: Path) -> list[FileCoverage]: - """Parse an LCOV ``*.dat`` file into per-source coverage records.""" - records: list[FileCoverage] = [] - current: FileCoverage | None = None - for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): - line = raw.strip() - if line.startswith("SF:"): - current = FileCoverage(source=line[3:]) - records.append(current) - elif current is None: - continue - elif line.startswith("LF:"): - current.lines_found = int(line[3:] or 0) - elif line.startswith("LH:"): - current.lines_hit = int(line[3:] or 0) - elif line.startswith("BRF:"): - current.branches_found = int(line[4:] or 0) - elif line.startswith("BRH:"): - current.branches_hit = int(line[4:] or 0) - elif line == "end_of_record": - current = None - return records - - -def load_coverage(config_path: str) -> list[FileCoverage]: - """Locate and parse the LCOV report. - - ``config_path`` may be absolute or relative. Relative paths are - resolved against the workspace root (same discovery helper used by - the source-code linker for ``bazel-testlogs``). If the file is - missing, an empty list is returned and a single log line is - emitted — the missing file must not break the docs build. - """ - if not config_path: - return [] - path = Path(config_path) - if not path.is_absolute(): - ws_root = _workspace_root() - if ws_root is None: - logger.info("mvr coverage: workspace root not found; skipping LCOV load") - return [] - path = ws_root / path - if not path.is_file(): - logger.info( - "mvr coverage: LCOV file not found at %s; skipping coverage dropdown", - path, - ) - return [] - logger.info("mvr coverage: parsing LCOV %s", path) - return parse_lcov(path) - - -def records_for_slug( - records: list[FileCoverage], - slug_norm: str, -) -> list[FileCoverage]: - """Filter *records* to those whose source path contains *slug_norm*. - - ``slug_norm`` must already be normalised (underscores stripped, - lower-cased) — matching :func:`.rendering.normalize_slug`. - """ - if not slug_norm: - return [] - return [ - r - for r in records - if slug_norm in r.source.replace("_", "").replace("/", "").lower() - ] - - -def _pct_str(hit: int, found: int) -> str: - """Format a hit/found ratio as a percentage string. - - Returns an empty string when *found* is 0 — there is nothing to - cover, so showing ``0.0`` would misleadingly read as "0% covered". - """ - return f"{100.0 * hit / found:.1f}" if found else "" - - -def coverage_rows(records: list[FileCoverage]) -> str: - """Render *records* as ``list-table`` rows (no header row). - - Files with neither line nor branch data (``lines_found == 0`` and - ``branches_found == 0``, e.g. headers never instrumented by the - coverage run) are skipped — a row of all zeroes carries no - information and reads as "0% covered" even though nothing was - measured at all. - - Returns an empty string when *records* is empty so callers can decide - to omit the whole dropdown block. - """ - if not records: - return "" - visible = [r for r in records if r.lines_found or r.branches_found] - if not visible: - return "" - lines: list[str] = [] - total = FileCoverage(source="**Total**") - for r in sorted(visible, key=lambda x: x.source): - lines.append(f" * - ``{r.source}``") - lines.append(f" - {r.lines_found}") - lines.append(f" - {r.lines_hit}") - lines.append(f" - {_pct_str(r.lines_hit, r.lines_found)}") - lines.append(f" - {r.branches_found}") - lines.append(f" - {r.branches_hit}") - lines.append(f" - {_pct_str(r.branches_hit, r.branches_found)}") - total.lines_found += r.lines_found - total.lines_hit += r.lines_hit - total.branches_found += r.branches_found - total.branches_hit += r.branches_hit - lines.append(f" * - {total.source}") - lines.append(f" - {total.lines_found}") - lines.append(f" - {total.lines_hit}") - lines.append(f" - {_pct_str(total.lines_hit, total.lines_found)}") - lines.append(f" - {total.branches_found}") - lines.append(f" - {total.branches_hit}") - lines.append(f" - {_pct_str(total.branches_hit, total.branches_found)}") - return "\n".join(lines) diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index c2cb24dc9..ad235d831 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -14,54 +14,63 @@ from __future__ import annotations -import re - from docutils import nodes from docutils.statemachine import ViewList from sphinx.util.docutils import SphinxDirective -from sphinx.util.nodes import nested_parse_with_titles - -from .rendering import render_mod_ver_report - -# Strip an optional ``[version==N]`` qualifier from a need id. -_VERSION_QUALIFIER_RE = re.compile(r"\[version==\d+\]$") - -def _parse_ids(ids_str: str) -> list[str]: - """Parse a comma-separated option value into a list of need ids. - - Multi-line values are supported (docutils folds them into one string) and - an optional ``[version==N]`` qualifier is stripped silently — the report - does not filter by version. +# ``:template:`` is a Sphinx-Needs core option, so score_metamodel's option +# check accepts it on a metamodel-defined need type. It selects +# ``src/needs_templates/mod_ver_report.need``, which renders the whole report +# body from the need's own fields — this directive emits the need, nothing more. +NEEDS_TEMPLATE_NAME = "mod_ver_report" + +MOD_VER_REPORT_TEMPLATE = """\ +.. mod_ver_report:: {title} + :id: {report_id} + :template: {template_name} + :version: {version} + :safety: {safety} + :security: {security} + :status: {status} + :verification_method: {verification_method} + :belongs_to: {module_id} + :components: {components} + :features: {features} + +""" + +# Every option the directive requires. Each maps onto a mandatory option or +# link of the ``mod_ver_report`` need type (see metamodel.yaml), so none of +# them can be defaulted: guessing a traceability link would silently produce a +# dangling one whenever the guess is wrong. +_REQUIRED_OPTIONS = ( + "id", + "module-id", + "components", + "features", + "safety", + "security", + "status", + "verification-method", +) + + +def _join_ids(ids_str: str) -> str: + """Normalise a comma-separated option value onto a single line. + + Multi-line values are supported — docutils folds them into one string with + newlines, which would break the emitted option. Version qualifiers such as + ``[version==1]`` are passed through: Sphinx-Needs parses them itself, and + stripping them here would silently drop the constraint. """ - ids = [] - for raw in ids_str.split(","): - need_id = _VERSION_QUALIFIER_RE.sub("", raw.strip()) - if need_id: - ids.append(need_id) - return ids - - -# Mandatory options every ``mod_ver_report`` need requires (see -# metamodel.yaml) that this directive cannot derive on its own. -_MOD_VER_REPORT_OPTIONS = ("id", "safety", "security", "status", "verification-method") - -# Mandatory *links* of the ``mod_ver_report`` need type. Both are populated -# from the directive option of the same name, so the option is required too — -# guessing a traceability link (e.g. deriving ``feat__<module>`` from -# ``:module-id:``) would silently produce a dangling link whenever the guess -# is wrong. -_MOD_VER_REPORT_LINKS = ("components", "features") + return ", ".join(part.strip() for part in ids_str.split(",") if part.strip()) -def _mod_ver_report_title(module_short: str) -> str: +def _report_title(module_short: str) -> str: """Derive the report's human-readable title from the module slug. - Only the display title is derived. The need's id comes from the - directive's ``:id:`` option and is passed through untouched, so the - author stays in control of it — ``mod_ver_report`` declares - ``parts: 3`` in metamodel.yaml, and the metamodel's id checks report a - value that does not follow that scheme. + Only the display title is derived. The need's id comes from ``:id:`` and is + passed through untouched, so the author stays in control of it. """ return f"{module_short.replace('_', ' ').title()} Verification Report" @@ -81,92 +90,59 @@ class ModuleVerificationReportDirective(SphinxDirective): :status: valid :verification-method: test_and_inspection - Every option is required: each maps onto a mandatory option or link of the - sphinx-needs ``mod_ver_report`` need type (see metamodel.yaml). The - directive is a shorthand — ``:id:`` becomes the need's id verbatim, the - title is derived from ``:module-id:``, and the rest is passed straight - through. + Every option is required. The directive is a shorthand: ``:id:`` becomes + the need's id verbatim, the title is derived from ``:module-id:``, and the + rest is passed straight through. - The report *body* is not generated here. The emitted need selects the - ``mod_ver_report`` content template - (``src/needs_templates/mod_ver_report.need``), which Sphinx-Needs renders - from the need's own fields. + The report *body* is not generated here — the emitted need selects the + ``mod_ver_report`` content template, which Sphinx-Needs renders from the + need's own fields. """ required_arguments = 0 optional_arguments = 0 - option_spec = { - "id": str, - "module-id": str, - "components": str, - "features": str, - "safety": str, - "security": str, - "status": str, - "verification-method": str, - "version": str, - } + option_spec = {opt: str for opt in _REQUIRED_OPTIONS} | {"version": str} has_content = False - def _error(self, message: str) -> list[nodes.Node]: - return [ - self.state_machine.reporter.error( - f"module-verification-report: {message}", line=self.lineno - ) - ] - def run(self) -> list[nodes.Node]: - module_id = self.options.get("module-id", "") + missing = [opt for opt in _REQUIRED_OPTIONS if not self.options.get(opt)] + if missing: + # Report here, where the author can see which directive is at + # fault, rather than as a metamodel warning about a generated need. + return [ + self.state_machine.reporter.error( + "module-verification-report: missing mandatory option(s) " + f"{', '.join(':' + m + ':' for m in missing)} required to " + "generate the mod_ver_report need", + line=self.lineno, + ) + ] + + module_id = self.options["module-id"] module_short = ( module_id[len("mod__") :] if module_id.startswith("mod__") else module_id ) - - parsed_links = { - name: _parse_ids(self.options.get(name, "")) - for name in _MOD_VER_REPORT_LINKS - } - - # ``components`` and ``features`` are mandatory links of the - # mod_ver_report need type, so an empty list cannot produce a valid - # need — report it here, where the author can see which directive is - # at fault, rather than as a metamodel warning about a generated need. - empty_links = [n for n in _MOD_VER_REPORT_LINKS if not parsed_links[n]] - if empty_links: - return self._error( - f"no {' or '.join(empty_links)} specified — add " - + " and ".join(f"':{name}: <id>, ...'" for name in empty_links) - + " to the directive" - ) - - missing = [opt for opt in _MOD_VER_REPORT_OPTIONS if not self.options.get(opt)] - if missing: - return self._error( - "missing mandatory option(s) " - f"{', '.join(':' + m + ':' for m in missing)} required to " - "generate the mod_ver_report need" - ) - - report_id = self.options["id"] - report_title = _mod_ver_report_title(module_short) - rst_text = render_mod_ver_report( - module_id=module_id, - report_id=report_id, - title=report_title, + rst_text = MOD_VER_REPORT_TEMPLATE.format( + title=_report_title(module_short), + report_id=self.options["id"], + template_name=NEEDS_TEMPLATE_NAME, + version=self.options.get("version", "1"), safety=self.options["safety"], security=self.options["security"], status=self.options["status"], verification_method=self.options["verification-method"], - version=self.options.get("version", "1"), - components=parsed_links["components"], - features=parsed_links["features"], + module_id=module_id, + components=_join_ids(self.options["components"]), + features=_join_ids(self.options["features"]), ) view_list = ViewList() - source = "<module-verification-report>" for lineno, line in enumerate(rst_text.splitlines()): - view_list.append(line, source, lineno) + view_list.append(line, "<module-verification-report>", lineno) + # A plain nested_parse is enough: the emitted block is a single + # directive with no section titles. container = nodes.container() container.document = self.state.document - nested_parse_with_titles(self.state, view_list, container) # type: ignore[arg-type] + self.state.nested_parse(view_list, self.content_offset, container) return container.children diff --git a/src/extensions/score_module_verification_report/render_context.py b/src/extensions/score_module_verification_report/render_context.py deleted file mode 100644 index 7dab8b849..000000000 --- a/src/extensions/score_module_verification_report/render_context.py +++ /dev/null @@ -1,74 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Render-context helpers for the ``mod_ver_report`` need template. - -Sphinx-Needs renders a need's ``:template:`` from the need's own fields plus -whatever sits in ``needs_render_context``. Everything the module verification -report shows is either a field of the need or a ``needtable`` / ``needpie`` -filter — with one exception: test coverage, which comes from an LCOV file on -disk. A Jinja template cannot read files, so the coverage lookup is registered -here as a callable the template invokes by component slug. -""" - -from __future__ import annotations - -from typing import Any - -from .coverage import FileCoverage, coverage_rows, load_coverage, records_for_slug - - -class CoverageLookup: - """``mvr_coverage(slug_norm)`` — coverage table rows for one component. - - Deliberately a class rather than a closure: Sphinx checks every config - value with ``is_serializable``, which rejects ``types.FunctionType`` - outright. A plain function (or lambda) in ``needs_render_context`` makes - Sphinx log ``cannot cache unpickleable configuration value``, which is - fatal in a ``-W`` build. An instance of a module-level class is not a - function type, and its state (a path plus plain dataclasses) pickles - cleanly, so the config cache keeps working. - - The LCOV file is parsed on first use and cached for the rest of the build: - a report with N components would otherwise re-read it N times, and a - project without a report must not pay for parsing it at all. - """ - - def __init__(self, lcov_path: str) -> None: - self.lcov_path = lcov_path - self.records: list[FileCoverage] | None = None - - def __call__(self, slug_norm: str) -> str: - """Return ``list-table`` rows for *slug_norm*, or ``""`` if no match. - - The rows come from :func:`.coverage.coverage_rows` (including the - ``**Total**`` row). The template branches on the empty string to show - either the table or the "no coverage data" note. - """ - if self.records is None: - self.records = load_coverage(self.lcov_path) - return coverage_rows(records_for_slug(self.records, slug_norm)) - - -def register_render_context(app: Any, config: Any) -> None: - """Add the report's helpers to ``needs_render_context``. - - Runs on ``config-inited`` so the helpers are in place before sphinx-needs - starts creating needs — templates render during the read phase. - """ - context = getattr(config, "needs_render_context", None) - if context is None: - context = {} - config.needs_render_context = context - context.setdefault( - "mvr_coverage", CoverageLookup(getattr(config, "mvr_coverage_lcov", "")) - ) diff --git a/src/extensions/score_module_verification_report/rendering.py b/src/extensions/score_module_verification_report/rendering.py deleted file mode 100644 index c2db17624..000000000 --- a/src/extensions/score_module_verification_report/rendering.py +++ /dev/null @@ -1,53 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Rendering of the ``mod_ver_report`` need declaration.""" - -from __future__ import annotations - -from .templates import MOD_VER_REPORT_TEMPLATE, NEEDS_TEMPLATE_NAME - - -def render_mod_ver_report( - module_id: str, - report_id: str, - title: str, - safety: str, - security: str, - status: str, - verification_method: str, - components: list[str], - features: list[str], - version: str = "1", -) -> str: - """Render the ``.. mod_ver_report::`` need declaration for *module_id*. - - ``components`` and ``features`` are mandatory links of the - ``mod_ver_report`` need type (see metamodel.yaml). They record which - architecture needs the report describes, which serves two purposes: the - ``mod_ver_report`` content template renders the report body from them, and - score_metamodel's ``check_mod_ver_report_links`` graph check compares them - against the module's ``includes`` and the components' ``belongs_to``. - """ - return MOD_VER_REPORT_TEMPLATE.format( - title=title, - report_id=report_id, - template_name=NEEDS_TEMPLATE_NAME, - version=version, - safety=safety, - security=security, - status=status, - verification_method=verification_method, - module_id=module_id, - components=", ".join(components), - features=", ".join(features), - ) diff --git a/src/extensions/score_module_verification_report/templates.py b/src/extensions/score_module_verification_report/templates.py deleted file mode 100644 index b903077d0..000000000 --- a/src/extensions/score_module_verification_report/templates.py +++ /dev/null @@ -1,42 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""The RST emitted by the ``.. module-verification-report::`` directive. - -Only the ``mod_ver_report`` need declaration lives here. The report *body* — -feature statistics, component overview, per-component sections, work-product -and coverage tables — is a Sphinx-Needs content template, -``src/needs_templates/mod_ver_report.need``, selected via the ``:template:`` -option below. Sphinx-Needs renders it from the need's own fields, so the body -follows the needs model instead of a second, parallel description of it. -""" - -from __future__ import annotations - -# ``:template:`` is a Sphinx-Needs core option, so score_metamodel's -# option check accepts it on a metamodel-defined need type. -NEEDS_TEMPLATE_NAME = "mod_ver_report" - -MOD_VER_REPORT_TEMPLATE = """\ -.. mod_ver_report:: {title} - :id: {report_id} - :template: {template_name} - :version: {version} - :safety: {safety} - :security: {security} - :status: {status} - :verification_method: {verification_method} - :belongs_to: {module_id} - :components: {components} - :features: {features} - -""" diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index 84db5a9fe..d01266cc7 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -10,81 +10,88 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Unit tests for the option parsing and id derivation in -:mod:`score_module_verification_report.directive`. +"""Unit tests for :mod:`score_module_verification_report.directive`. The directive needs a full Sphinx environment to instantiate, so the pure -helpers are tested in isolation. +helpers and the emitted RST are tested in isolation. """ from __future__ import annotations from src.extensions.score_module_verification_report.directive import ( - _MOD_VER_REPORT_LINKS, - _MOD_VER_REPORT_OPTIONS, - _mod_ver_report_title, - _parse_ids, + _REQUIRED_OPTIONS, + MOD_VER_REPORT_TEMPLATE, + NEEDS_TEMPLATE_NAME, + _join_ids, + _report_title, ) # --------------------------------------------------------------------------- -# _parse_ids +# _join_ids # --------------------------------------------------------------------------- -def test_parse_single_id(): - assert _parse_ids("comp__mymod_json") == ["comp__mymod_json"] +def test_join_single_id(): + assert _join_ids("comp__mymod_json") == "comp__mymod_json" -def test_parse_multiple_ids_preserves_order(): - result = _parse_ids("comp__mymod_json, comp__mymod_bit_manipulation") - assert result == ["comp__mymod_json", "comp__mymod_bit_manipulation"] +def test_join_normalises_spacing_and_order(): + assert ( + _join_ids("comp__mymod_json,comp__mymod_bits") + == "comp__mymod_json, comp__mymod_bits" + ) + +def test_join_folds_multiline_values_onto_one_line(): + """docutils folds a multi-line option value into one string with newlines. -def test_parse_handles_multiline_values(): - """docutils folds a multi-line option value into one string.""" - assert _parse_ids("comp__m_json,\n comp__m_result\n") == [ - "comp__m_json", - "comp__m_result", - ] + They must not survive into the emitted option or the RST breaks. + """ + result = _join_ids("comp__m_json,\n comp__m_result\n") + assert result == "comp__m_json, comp__m_result" + assert "\n" not in result -def test_parse_strips_version_qualifier(): - result = _parse_ids("comp__m_json[version==1], comp__m_result[version==2]") - assert result == ["comp__m_json", "comp__m_result"] +def test_join_preserves_version_qualifiers(): + """Sphinx-Needs parses ``id[version==N]`` itself; stripping loses it.""" + assert ( + _join_ids("comp__m_json[version==1], comp__m_result") + == "comp__m_json[version==1], comp__m_result" + ) -def test_parse_skips_empty_entries(): - assert _parse_ids("comp__m_json, , ") == ["comp__m_json"] - assert _parse_ids("") == [] - assert _parse_ids(" , ") == [] +def test_join_skips_empty_entries(): + assert _join_ids("comp__m_json, , ") == "comp__m_json" + assert _join_ids("") == "" + assert _join_ids(" , ") == "" -def test_parse_is_type_agnostic(): - """The same parser serves :components: and :features:.""" - assert _parse_ids("feat__one, feat__two") == ["feat__one", "feat__two"] +def test_join_is_type_agnostic(): + """The same helper serves :components: and :features:.""" + assert _join_ids("feat__one, feat__two") == "feat__one, feat__two" # --------------------------------------------------------------------------- -# _mod_ver_report_title +# _report_title # --------------------------------------------------------------------------- def test_title_derived_from_module_slug(): - assert _mod_ver_report_title("baselibs") == "Baselibs Verification Report" + assert _report_title("baselibs") == "Baselibs Verification Report" def test_title_title_cases_multiword_modules(): - assert _mod_ver_report_title("my_module") == "My Module Verification Report" + assert _report_title("my_module") == "My Module Verification Report" -def test_id_is_not_derived(): +def test_id_is_never_derived(): """The need id comes from the author's :id:, never from the module slug.""" import inspect from src.extensions.score_module_verification_report import directive source = inspect.getsource(directive.ModuleVerificationReportDirective.run) - assert 'report_id = self.options["id"]' in source + assert 'report_id=self.options["id"]' in source assert "mod_vrep__" not in source @@ -93,17 +100,69 @@ def test_id_is_not_derived(): # --------------------------------------------------------------------------- -def test_components_and_features_are_the_mandatory_links(): - """The directive must require exactly the need type's mandatory links.""" - assert _MOD_VER_REPORT_LINKS == ("components", "features") - - -def test_mandatory_options_match_the_need_type(): - """:id: is required too — the directive no longer invents one.""" - assert _MOD_VER_REPORT_OPTIONS == ( +def test_required_options_cover_every_mandatory_field_and_link(): + """One list drives both the option spec and the missing-option error.""" + assert _REQUIRED_OPTIONS == ( "id", + "module-id", + "components", + "features", "safety", "security", "status", "verification-method", ) + + +# --------------------------------------------------------------------------- +# Emitted RST +# --------------------------------------------------------------------------- + + +def _render(**overrides: str) -> str: + fields = dict( + title="Demo Verification Report", + report_id="mod_vrep__demo__report", + template_name=NEEDS_TEMPLATE_NAME, + version="1", + safety="QM", + security="YES", + status="valid", + verification_method="test_and_inspection", + module_id="mod__demo", + components="comp__demo_a, comp__demo_b", + features="feat__demo", + ) + fields.update(overrides) + return MOD_VER_REPORT_TEMPLATE.format(**fields) + + +def test_emitted_need_carries_every_field(): + out = _render() + for expected in ( + ".. mod_ver_report:: Demo Verification Report", + ":id: mod_vrep__demo__report", + ":template: mod_ver_report", + ":version: 1", + ":safety: QM", + ":security: YES", + ":status: valid", + ":verification_method: test_and_inspection", + ":belongs_to: mod__demo", + ":components: comp__demo_a, comp__demo_b", + ":features: feat__demo", + ): + assert expected in out + + +def test_emitted_need_is_only_the_need(): + """The body comes from the content template, not from here.""" + out = _render() + assert "needtable" not in out + assert "needpie" not in out + + +def test_every_option_stays_inside_the_directive_block(): + body = [line for line in _render().splitlines() if line.strip()] + assert body[0].startswith(".. mod_ver_report::") + assert all(line.startswith(" :") for line in body[1:]) diff --git a/src/extensions/score_module_verification_report/tests/test_needs_template.py b/src/extensions/score_module_verification_report/tests/test_needs_template.py index 80cc6ba75..9a8401a7e 100644 --- a/src/extensions/score_module_verification_report/tests/test_needs_template.py +++ b/src/extensions/score_module_verification_report/tests/test_needs_template.py @@ -24,21 +24,10 @@ import pytest from sphinx_needs._jinja import render_template_string -from src.extensions.score_module_verification_report.coverage import ( - FileCoverage, - coverage_rows, - records_for_slug, -) - TEMPLATE = ( Path(__file__).resolve().parents[3] / "needs_templates" / "mod_ver_report.need" ) -_COVERAGE = [ - FileCoverage("src/json/json.cpp", 100, 96, 40, 35), - FileCoverage("src/json/json.h", 10, 10, 0, 0), -] - def _render(**overrides: object) -> str: context: dict[str, object] = { @@ -47,7 +36,6 @@ def _render(**overrides: object) -> str: "belongs_to": ["mod__baselibs"], "components": ["comp__baselibs_json", "comp__baselibs_bit_manipulation"], "features": ["feat__baselibs"], - "mvr_coverage": lambda slug: coverage_rows(records_for_slug(_COVERAGE, slug)), } context.update(overrides) return render_template_string(TEMPLATE.read_text(), context, autoescape=False) @@ -133,33 +121,6 @@ def test_needpie_filters_guard_against_missing_verify_fields() -> None: assert '"partially_verifies_back" in locals()' in out -# --------------------------------------------------------------------------- -# Coverage -# --------------------------------------------------------------------------- - - -def test_coverage_table_rendered_when_data_matches() -> None: - out = _render(components=["comp__baselibs_json"]) - assert "``src/json/json.cpp``" in out - assert "**Total**" in out - assert "No coverage data available" not in out - - -def test_coverage_note_rendered_when_nothing_matches() -> None: - out = _render(components=["comp__baselibs_bit_manipulation"]) - assert "No coverage data available" in out - assert "src/json/json.cpp" not in out - - -def test_coverage_helper_is_called_with_the_normalised_slug() -> None: - seen: list[str] = [] - _render( - components=["comp__baselibs_bit_manipulation"], - mvr_coverage=lambda slug: seen.append(slug) or "", - ) - assert seen == ["bitmanipulation"] - - # --------------------------------------------------------------------------- # Structure # --------------------------------------------------------------------------- @@ -176,7 +137,6 @@ def test_coverage_helper_is_called_with_the_normalised_slug() -> None: "Component Requirements Statistics", "Component Architecture Statistics", "Requirements Traceability", - "Test Coverage", "Architectural Elements", "Verification & Safety Analysis Documents", ], @@ -211,8 +171,8 @@ def test_list_tables_have_a_consistent_number_of_fields_per_row() -> None: assert len(set(per_row)) == 1, f"ragged list-table at line {i + 1}: {per_row}" checked += 1 i = j - # feature WPs, component overview is a needtable, 2x component WPs, coverage - assert checked >= 4 + # one feature work-product table + one per component + assert checked >= 3 def test_no_unrendered_jinja_remains() -> None: diff --git a/src/extensions/score_module_verification_report/tests/test_rendering.py b/src/extensions/score_module_verification_report/tests/test_rendering.py deleted file mode 100644 index 8eee6e536..000000000 --- a/src/extensions/score_module_verification_report/tests/test_rendering.py +++ /dev/null @@ -1,73 +0,0 @@ -# ******************************************************************************* -# 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 tests for :mod:`score_module_verification_report.rendering`.""" - -from __future__ import annotations - -from src.extensions.score_module_verification_report.rendering import ( - render_mod_ver_report, -) - -_ARGS = dict( - module_id="mod__demo", - report_id="mod_vrep__demo__report", # supplied by the directive's :id: - title="Demo Verification Report", - safety="QM", - security="YES", - status="valid", - verification_method="test_and_inspection", -) - - -def test_render_mod_ver_report_emits_all_mandatory_fields() -> None: - out = render_mod_ver_report( - components=["comp__demo_a", "comp__demo_b"], - features=["feat__demo"], - **_ARGS, - ) - assert ".. mod_ver_report:: Demo Verification Report" in out - assert ":id: mod_vrep__demo__report" in out - assert ":version: 1" in out - assert ":safety: QM" in out - assert ":security: YES" in out - assert ":status: valid" in out - assert ":verification_method: test_and_inspection" in out - assert ":belongs_to: mod__demo" in out - assert ":components: comp__demo_a, comp__demo_b" in out - assert ":features: feat__demo" in out - - -def test_render_mod_ver_report_selects_the_needs_template() -> None: - """The body comes from the ``mod_ver_report`` content template.""" - out = render_mod_ver_report( - components=["comp__demo_a"], features=["feat__demo"], **_ARGS - ) - assert ":template: mod_ver_report" in out - # The directive emits the need only — no rendered body. - assert "needtable" not in out - assert "needpie" not in out - - -def test_render_mod_ver_report_block_is_self_contained() -> None: - """Every option must sit inside the directive block.""" - out = render_mod_ver_report( - components=["comp__demo_a"], features=["feat__demo"], **_ARGS - ) - assert out.endswith( - ":belongs_to: mod__demo\n" - " :components: comp__demo_a\n" - " :features: feat__demo\n\n" - ) - body = [ln for ln in out.splitlines() if ln.strip()] - assert body[0].startswith(".. mod_ver_report::") - assert all(ln.startswith(" :") for ln in body[1:]) diff --git a/src/needs_templates/mod_ver_report.need b/src/needs_templates/mod_ver_report.need index 2258b1637..e39d14d98 100644 --- a/src/needs_templates/mod_ver_report.need +++ b/src/needs_templates/mod_ver_report.need @@ -11,11 +11,6 @@ else is delegated to ``needtable`` / ``needpie``, which resolve at write time. - The single exception is coverage: LCOV data lives on disk, not in the graph. - ``score_module_verification_report`` puts a ``mvr_coverage(slug)`` helper into - ``needs_render_context``; it returns ready-made ``list-table`` rows for the - component, or an empty string when there is no data. - Sections are ``.. rubric::`` rather than real headings: the template renders *inside* a need, where docutils does not allow new sections. #} @@ -276,37 +271,6 @@ verification status and the tests that (fully or partially) verify them: :colwidths: 13,22,8,10,23,24 :sort: id -.. rubric:: Test Coverage - -Per-source-file line and branch coverage aggregated from the LCOV report -produced by ``bazel coverage``. - -.. dropdown:: Show test coverage table - :animate: fade-in -{%- set coverage_body = mvr_coverage(component_slug_norm) %} -{%- if coverage_body %} - - .. list-table:: - :header-rows: 1 - :widths: 45 10 10 10 10 10 10 - - * - Source - - Lines found - - Lines hit - - Line % - - Branches found - - Branches hit - - Branch % -{{ coverage_body }} -{%- else %} - - .. note:: - - No coverage data available for this component. Run ``bazel coverage`` - with the corresponding targets and rebuild the docs to populate this - table. -{%- endif %} - .. rubric:: Architectural Elements The following table lists the architectural elements of this component From ff524ce29a4633777dc7a024b10d5204c9118c01 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak <maximilian.pollak@qorix.com> Date: Thu, 27 Aug 2026 20:47:50 +0200 Subject: [PATCH 25/25] fix: template => post_template Template was the wrong place to use the template. have to use it as post_template otherwise we will not be able to create new sections in the document --- .../docs/module_verification_report.rst | 90 +++++-------------- .../directive.py | 11 ++- .../tests/test_directive.py | 2 +- .../tests/test_needs_template.py | 37 ++++++-- src/needs_templates/mod_ver_report.need | 43 ++++++--- 5 files changed, 88 insertions(+), 95 deletions(-) diff --git a/src/extensions/docs/module_verification_report.rst b/src/extensions/docs/module_verification_report.rst index 2c6415ef1..4b9c058e7 100644 --- a/src/extensions/docs/module_verification_report.rst +++ b/src/extensions/docs/module_verification_report.rst @@ -130,42 +130,11 @@ build runs Sphinx with ``-W`` (warnings treated as errors), any such mismatch aborts the build instead of silently producing an inconsistent report. -Graph consistency ------------------ - -Because the emitted need records which architecture needs the report -describes, the report can be cross-checked against them. That is done by -``score_metamodel``'s ``check_mod_ver_report_links`` graph check -(``src/extensions/score_metamodel/checks/mod_ver_report_checks.py``), which -runs together with every other metamodel check — there is no separate -build-finished pass any more. - -It enforces two rules per ``mod_ver_report`` need: - -#. The need's ``components`` and the module's ``:includes:`` must be the same - set. The report and the module are two independent statements about which - components make up the module; if they disagree, one of them is stale. Both - directions are warnings: a report that skips a component of its module is - exactly as wrong as one that describes a component the module does not - have. -#. Every listed component must ``:belongs_to:`` one of the listed features. A - report naming ``:features: feat__x`` and ``:components: comp__y`` asserts - that ``comp__y`` is part of ``feat__x``; the ``.. comp::`` need has to say - so too. - -Ids in ``:components:`` or ``:features:`` that do not resolve to a need are -reported as well. Every problem is reported as a warning rather than raised, -so one build surfaces all of them. - -Like every other graph check, it can be disabled or run in isolation via the -``score_metamodel_checks`` config value, e.g. -``score_metamodel_checks = "check_mod_ver_report_links"``. - The report template ------------------- -The body lives in ``src/needs_templates/mod_ver_report.need`` and is rendered -by Sphinx-Needs, not by this extension. Two consequences are worth knowing: +The body lives in ``src/needs_templates/mod_ver_report.need``, a Jinja +template rendered by Sphinx-Needs. Two properties of that mechanism shape it: *Templates render during the read phase*, when the need is created and the needs graph does not exist yet. The template therefore never looks other needs @@ -174,33 +143,32 @@ itself, derives component slugs and titles from the ids by string manipulation, and leaves everything else to ``needtable`` / ``needpie``, which resolve at write time. -*A need's content cannot open new sections*, so the report uses -``.. rubric::`` where a standalone page would use headings. Rubrics carry no -TOC entries; each component section is still a link target -(``comp-<slug-with-dashes>``). +*A need's content cannot open new sections* — docutils rejects them with +"Unexpected section title". The template is therefore applied as +``:post_template:``, not ``:template:``: post-content is placed after the need +at document level, where real section headings work. That is what gives the +report its TOC entries and per-component navigation. Each component section is +additionally a stable link target (``comp-<slug-with-dashes>``). + +The heading levels are ``-`` for ``Feature`` and ``Components``, ``~`` for +``Component Overview`` and each component, and ``^`` for the subsections +within a feature or component. Graph consistency ----------------- -Because the emitted need records which architecture needs the report -describes, the report can be cross-checked against them. That is done by -``score_metamodel``'s ``check_mod_ver_report_links`` graph check -(``src/extensions/score_metamodel/checks/mod_ver_report_checks.py``), which -runs together with every other metamodel check — there is no separate -build-finished pass any more. - -It enforces two rules per ``mod_ver_report`` need: +Because the need records which architecture needs the report describes, it can +be cross-checked against them. ``score_metamodel``'s +``check_mod_ver_report_links`` graph check enforces, per report: #. The need's ``components`` and the module's ``:includes:`` must be the same set. The report and the module are two independent statements about which components make up the module; if they disagree, one of them is stale. Both - directions are warnings: a report that skips a component of its module is - exactly as wrong as one that describes a component the module does not - have. -#. Every listed component must ``:belongs_to:`` one of the listed features. A - report naming ``:features: feat__x`` and ``:components: comp__y`` asserts - that ``comp__y`` is part of ``feat__x``; the ``.. comp::`` need has to say - so too. + directions are reported, independently. +#. Every feature a listed component ``belongs_to`` must itself appear in + ``:features:``. A report spanning several features is fine; a component + whose feature the report never mentions is not, because the feature-level + statistics would silently omit it. Ids in ``:components:`` or ``:features:`` that do not resolve to a need are reported as well. Every problem is reported as a warning rather than raised, @@ -209,21 +177,3 @@ so one build surfaces all of them. Like every other graph check, it can be disabled or run in isolation via the ``score_metamodel_checks`` config value, e.g. ``score_metamodel_checks = "check_mod_ver_report_links"``. - -The report template -------------------- - -The body lives in ``src/needs_templates/mod_ver_report.need`` and is rendered -by Sphinx-Needs, not by this extension. Two consequences are worth knowing: - -*Templates render during the read phase*, when the need is created and the -needs graph does not exist yet. The template therefore never looks other needs -up. It reads ``belongs_to`` / ``components`` / ``features`` off the need -itself, derives component slugs and titles from the ids by string -manipulation, and leaves everything else to ``needtable`` / ``needpie``, which -resolve at write time. - -*A need's content cannot open new sections*, so the report uses -``.. rubric::`` where a standalone page would use headings. Rubrics carry no -TOC entries; each component section is still a link target -(``comp-<slug-with-dashes>``). diff --git a/src/extensions/score_module_verification_report/directive.py b/src/extensions/score_module_verification_report/directive.py index ad235d831..ba11c9724 100644 --- a/src/extensions/score_module_verification_report/directive.py +++ b/src/extensions/score_module_verification_report/directive.py @@ -18,16 +18,21 @@ from docutils.statemachine import ViewList from sphinx.util.docutils import SphinxDirective -# ``:template:`` is a Sphinx-Needs core option, so score_metamodel's option -# check accepts it on a metamodel-defined need type. It selects +# ``:post_template:`` is a Sphinx-Needs core option, so score_metamodel's +# option check accepts it on a metamodel-defined need type. It selects # ``src/needs_templates/mod_ver_report.need``, which renders the whole report # body from the need's own fields — this directive emits the need, nothing more. +# +# Post-content, not content: a need's *content* cannot open new sections +# ("Unexpected section title"), so a ``:template:`` body could only use +# ``.. rubric::`` and would produce no TOC entries. Post-content is placed +# after the need at document level, where real section headings work. NEEDS_TEMPLATE_NAME = "mod_ver_report" MOD_VER_REPORT_TEMPLATE = """\ .. mod_ver_report:: {title} :id: {report_id} - :template: {template_name} + :post_template: {template_name} :version: {version} :safety: {safety} :security: {security} diff --git a/src/extensions/score_module_verification_report/tests/test_directive.py b/src/extensions/score_module_verification_report/tests/test_directive.py index d01266cc7..1ba9ea1b2 100644 --- a/src/extensions/score_module_verification_report/tests/test_directive.py +++ b/src/extensions/score_module_verification_report/tests/test_directive.py @@ -142,7 +142,7 @@ def test_emitted_need_carries_every_field(): for expected in ( ".. mod_ver_report:: Demo Verification Report", ":id: mod_vrep__demo__report", - ":template: mod_ver_report", + ":post_template: mod_ver_report", ":version: 1", ":safety: QM", ":security: YES", diff --git a/src/extensions/score_module_verification_report/tests/test_needs_template.py b/src/extensions/score_module_verification_report/tests/test_needs_template.py index 9a8401a7e..2c140944a 100644 --- a/src/extensions/score_module_verification_report/tests/test_needs_template.py +++ b/src/extensions/score_module_verification_report/tests/test_needs_template.py @@ -59,7 +59,7 @@ def test_feature_section_uses_the_features_link() -> None: def test_single_feature_keeps_the_plain_heading() -> None: out = _render() - assert ".. rubric:: Feature\n" in out + assert "Feature\n-------\n" in out assert "Feature: " not in out @@ -67,15 +67,15 @@ def test_one_section_per_feature_with_qualified_headings() -> None: out = _render(features=["feat__demo_one", "feat__demo_two"]) assert 'id == "feat__demo_one"' in out assert 'id == "feat__demo_two"' in out - assert ".. rubric:: Feature: Demo One" in out - assert ".. rubric:: Feature: Demo Two" in out + assert "Feature: Demo One\n" + "-" * len("Feature: Demo One") in out + assert "Feature: Demo Two\n" + "-" * len("Feature: Demo Two") in out def test_feature_workproducts_match_on_the_feature_slug() -> None: out = _render(features=["feat__baselibs"]) assert '"baselibs" in id.replace("_", "").lower()' in out # The feature table carries only the two feature-level work products. - feature_block = out[: out.index(".. rubric:: Components")] + feature_block = out[: out.index("Components\n----------")] assert "wp__requirements_inspect" in feature_block assert "wp__sw_arch_verification" in feature_block assert "wp__sw_component_fmea" not in feature_block @@ -97,9 +97,9 @@ def test_component_overview_lists_exactly_the_linked_components() -> None: def test_component_title_and_anchor_derive_from_the_id() -> None: out = _render() assert ".. _comp-bit-manipulation:" in out - assert ".. rubric:: Bit Manipulation" in out + assert "Bit Manipulation\n" + "~" * len("Bit Manipulation") in out assert ".. _comp-json:" in out - assert ".. rubric:: Json" in out + assert "Json\n~~~~" in out def test_every_component_gets_the_full_set_of_workproducts() -> None: @@ -142,7 +142,8 @@ def test_needpie_filters_guard_against_missing_verify_fields() -> None: ], ) def test_all_report_sections_are_present(rubric: str) -> None: - assert f".. rubric:: {rubric}" in _render() + """Every section must be a real heading — rubrics produce no TOC entries.""" + assert f"{rubric}\n" in _render() def test_list_tables_have_a_consistent_number_of_fields_per_row() -> None: @@ -179,3 +180,25 @@ def test_no_unrendered_jinja_remains() -> None: out = _render() for marker in ("{{", "}}", "{%", "%}"): assert marker not in out, marker + + +def test_no_rubrics_are_used() -> None: + """A rubric is not a section: it yields no TOC entry and no anchor.""" + assert ".. rubric::" not in _render() + + +def test_every_heading_underline_is_long_enough() -> None: + """A short underline makes docutils drop the section (and its TOC entry).""" + lines = _render().splitlines() + headings = 0 + for title, underline in zip(lines, lines[1:], strict=False): + if not underline or set(underline) - set("-~^") or not title.strip(): + continue + if len(set(underline)) != 1 or len(underline) < 3: + continue + assert len(underline) >= len(title.rstrip()), ( + f"underline too short for {title!r}" + ) + headings += 1 + # 3 feature + Components + Component Overview + 2x(title + 5 subsections) + assert headings >= 15, headings diff --git a/src/needs_templates/mod_ver_report.need b/src/needs_templates/mod_ver_report.need index e39d14d98..607d69921 100644 --- a/src/needs_templates/mod_ver_report.need +++ b/src/needs_templates/mod_ver_report.need @@ -11,8 +11,11 @@ else is delegated to ``needtable`` / ``needpie``, which resolve at write time. - Sections are ``.. rubric::`` rather than real headings: the template renders - *inside* a need, where docutils does not allow new sections. + The template is applied as ``:post_template:``, not ``:template:``. A need's + *content* cannot open new sections ("Unexpected section title"), but + post-content is placed after the need at document level, where real headings + work — and real headings are what give the report its TOC entries and + per-component navigation. #} {% set module_id = belongs_to|first|default("") %} {% set module_short = module_id|replace("mod__", "") %} @@ -81,14 +84,16 @@ {% set feature_slug = feature_id|replace("feat__", "") %} {% set feature_slug_norm = feature_slug|replace("_", "")|lower %} -.. rubric:: {% if features|length == 1 %}Feature{% else %}Feature: {{ feature_slug|replace("_", " ")|title }}{% endif %} +{% set feature_heading = "Feature" if features|length == 1 else "Feature: " ~ feature_slug|replace("_", " ")|title %}{{ feature_heading }} +{{ "-" * (feature_heading|length) }} .. needtable:: :filter: id == "{{ feature_id }}" :columns: title as "Name";id as "Id";safety;security;status :style: table -.. rubric:: Feature Requirements Statistics +Feature Requirements Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -124,7 +129,8 @@ :colwidths: 13,22,8,10,23,24 :sort: id -.. rubric:: Feature Architecture Statistics +Feature Architecture Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. grid:: 1 2 2 2 :gutter: 3 @@ -159,7 +165,8 @@ :colwidths: 25,30,10,15,20 :sort: id -.. rubric:: Feature Inspection Statistics +Feature Inspection Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Presence of the feature-level inspection work products. @@ -182,9 +189,11 @@ Presence of the feature-level inspection work products. {#- Components -#} {#- ===================================================================== -#} -.. rubric:: Components +Components +---------- -.. rubric:: Component Overview +Component Overview +~~~~~~~~~~~~~~~~~~ .. needtable:: :filter: id in [{% for c in components %}"{{ c }}"{% if not loop.last %}, {% endif %}{% endfor %}] @@ -199,13 +208,15 @@ Presence of the feature-level inspection work products. .. _comp-{{ component_slug|replace("_", "-")|lower }}: -.. rubric:: {{ component_title }} +{{ component_title }} +{{ "~" * (component_title|length) }} .. raw:: html <hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;"> -.. rubric:: Component Requirements Statistics +Component Requirements Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. grid:: 1 2 2 2 :gutter: 3 @@ -231,7 +242,8 @@ Presence of the feature-level inspection work products. type == "comp_req" and "{{ component_id }}" in satisfied_by and ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) type == "comp_req" and "{{ component_id }}" in satisfied_by and not ("fully_verifies_back" in locals() and len(fully_verifies_back) > 0) and not ("partially_verifies_back" in locals() and len(partially_verifies_back) > 0) -.. rubric:: Component Architecture Statistics +Component Architecture Statistics +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. grid:: 1 2 2 2 :gutter: 3 @@ -256,7 +268,8 @@ Presence of the feature-level inspection work products. type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" in tags type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and "inspected" not in tags -.. rubric:: Requirements Traceability +Requirements Traceability +^^^^^^^^^^^^^^^^^^^^^^^^^ The following table lists all requirements of this component together with their verification status and the tests that (fully or partially) verify them: @@ -271,7 +284,8 @@ verification status and the tests that (fully or partially) verify them: :colwidths: 13,22,8,10,23,24 :sort: id -.. rubric:: Architectural Elements +Architectural Elements +^^^^^^^^^^^^^^^^^^^^^^ The following table lists the architectural elements of this component together with their inspection status. Elements that have been formally @@ -288,7 +302,8 @@ yet been inspected. :colwidths: 25,30,10,15,20 :sort: id -.. rubric:: Verification & Safety Analysis Documents +Verification & Safety Analysis Documents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Presence of the standard verification and safety analysis work products for this component. A dash (``—``) means the corresponding document is missing.