diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index c7b7894ba..acbced5cc 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -230,6 +230,10 @@ needs_types: author: ^.*$ approver: ^.*$ reviewer: ^.*$ + # Scopes module_verification_report/platform_verification_report to + # requirements with valid_from <= report_version. Unset means unscoped + # (all requirements shown), used e.g. for the "_latest" reports. + report_version: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$ # req-Id: tool_req__docs_doc_generic_mandatory mandatory_links: realizes: workproduct diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index a7b33e859..3de92144f 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -178,6 +178,72 @@ def __call__(self, need_type: str) -> list[NeedItem]: _needs_of_type_callable = _NeedsOfType() +def _parse_version(value: str) -> tuple[int, int, int]: + """Parse a ``valid_from``/``report_version``-style milestone string. + + Accepts ``vMAJOR.MINOR`` or ``vMAJOR.MINOR.PATCH`` (e.g. ``v0.8`` or + ``v1.0.1``), matching the format enforced by the metamodel for + ``valid_from``/``valid_until``/``report_version``. + """ + numbers = [int(part) for part in value.strip().lstrip("vV").split(".")] + while len(numbers) < 3: + numbers.append(0) + return (numbers[0], numbers[1], numbers[2]) + + +class _RequirementInReportVersion: + """Decide whether a requirement Need belongs to a ``report_version`` scope. + + ``feat_req`` (and ``stkh_req``) carry ``valid_from`` directly. ``comp_req`` + has no ``valid_from`` of its own, so its scope is inherited from the + ``feat_req`` Need(s) it is ``derived_from``. A requirement without a + resolvable ``valid_from`` (directly or through ``derived_from``) is + excluded whenever a ``report_version`` scope is active, matching the rule + that only requirements with ``valid_from`` set are considered relevant for + a given release. + + Calling with an empty/``None`` ``report_version`` always returns ``True``, + which keeps unscoped reports (e.g. "latest") showing every requirement as + before. + """ + + def __call__(self, need: NeedItem, report_version: str | None) -> bool: + if not report_version: + return True + + valid_from = need.get("valid_from") + if valid_from: + try: + return _parse_version(valid_from) <= _parse_version(report_version) + except ValueError: + return False + + linked_feat_reqs = _linked_needs_callable(need["id"], "derived_from") + return any(self(feat_req, report_version) for feat_req in linked_feat_reqs) + + +_req_in_report_version_callable = _RequirementInReportVersion() + + +class _AnyRequirementInReportVersion: + """Decide whether a Feature/Component has any requirement in scope. + + Used to drop an entire Feature/Component section from the report when + ``report_version`` is set and none of its requirements qualify, instead of + rendering an empty section. An empty/``None`` ``report_version`` always + returns ``True`` (unscoped reports keep every Feature/Component, even + ones without any requirement at all, as before). + """ + + def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: + if not report_version: + return True + return any(_req_in_report_version_callable(req, report_version) for req in reqs) + + +_any_req_in_report_version_callable = _AnyRequirementInReportVersion() + + def _post_templates_requiring_reread(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" template_folder = _needs_template_folder() @@ -262,6 +328,12 @@ def setup(app: Sphinx) -> dict[str, object]: ) app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable) app.config.needs_render_context.setdefault("needs_of_type", _needs_of_type_callable) + app.config.needs_render_context.setdefault( + "req_in_report_version", _req_in_report_version_callable + ) + app.config.needs_render_context.setdefault( + "any_req_in_report_version", _any_req_in_report_version_callable + ) app.connect("builder-inited", _capture_build_environment) # Run after the source-code linker has injected generated testcase Needs and # their verification backlinks (priority 525), so report templates can diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index 0cc018d98..85224bf13 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -68,3 +68,104 @@ def test_backlinks_merge_indexed_and_new_outgoing_links( linked = linked_needs_class()("REQ", "fully_verifies_back") assert [need["id"] for need in linked] == ["TC-old", "TC-new"] + + +def test_parse_version_pads_missing_patch() -> None: + """A two-component milestone implies patch ``0`` for comparison purposes.""" + parse_version = vars(templates)["_parse_version"] + + assert parse_version("v0.8") == (0, 8, 0) + assert parse_version("v1.0.1") == (1, 0, 1) + assert parse_version("V2.3") == (2, 3, 0) + + +def test_req_in_report_version_unscoped_report_version_always_true() -> None: + """No ``report_version`` means the report is unscoped ("latest").""" + req_in_report_version = vars(templates)["_RequirementInReportVersion"]() + need = FakeNeed("feat_req__x") + + assert req_in_report_version(need, None) is True + assert req_in_report_version(need, "") is True + + +def test_req_in_report_version_direct_valid_from_boundary() -> None: + """``valid_from`` is inclusive: equal or later report_version is in scope.""" + req_in_report_version = vars(templates)["_RequirementInReportVersion"]() + need = FakeNeed("feat_req__x") + need["valid_from"] = "v0.8" + + assert req_in_report_version(need, "v0.7") is False + assert req_in_report_version(need, "v0.8") is True + assert req_in_report_version(need, "v0.9") is True + + +def test_req_in_report_version_malformed_valid_from_is_excluded() -> None: + """An unparsable ``valid_from`` must not raise; it just excludes the need.""" + req_in_report_version = vars(templates)["_RequirementInReportVersion"]() + need = FakeNeed("feat_req__x") + need["valid_from"] = "not-a-version" + + assert req_in_report_version(need, "v1.0") is False + + +def test_req_in_report_version_missing_valid_from_without_derived_from_is_excluded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A requirement with no ``valid_from`` and nothing to inherit from is out.""" + req_in_report_version = vars(templates)["_RequirementInReportVersion"]() + comp_req = FakeNeed("comp_req__x") + monkeypatch.setattr( + templates, "_get_available_needs", lambda: {"comp_req__x": comp_req} + ) + + assert req_in_report_version(comp_req, "v1.0") is False + + +def test_req_in_report_version_comp_req_inherits_via_derived_from( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``comp_req`` has no ``valid_from`` of its own; it inherits its scope from + the ``feat_req`` it is ``derived_from``.""" + req_in_report_version = vars(templates)["_RequirementInReportVersion"]() + feat_req = FakeNeed("feat_req__x") + feat_req["valid_from"] = "v0.8" + comp_req = FakeNeed("comp_req__x", links=[FakeLink("feat_req__x")]) + needs = {"feat_req__x": feat_req, "comp_req__x": comp_req} + monkeypatch.setattr(templates, "_get_available_needs", lambda: needs) + + assert req_in_report_version(comp_req, "v0.7") is False + assert req_in_report_version(comp_req, "v0.8") is True + + +def test_any_req_in_report_version_unscoped_true_even_for_empty_list() -> None: + """Unscoped ("latest") reports keep Features/Components with no requirement.""" + any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]() + + assert any_req_in_report_version([], None) is True + + +def test_any_req_in_report_version_scoped_empty_list_is_excluded() -> None: + """A scoped report has nothing to show for a Feature/Component with no reqs.""" + any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]() + + assert any_req_in_report_version([], "v1.0") is False + + +def test_any_req_in_report_version_true_if_at_least_one_requirement_matches() -> None: + any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]() + out_of_scope = FakeNeed("feat_req__a") + out_of_scope["valid_from"] = "v2.0" + in_scope = FakeNeed("feat_req__b") + in_scope["valid_from"] = "v0.8" + + assert any_req_in_report_version([out_of_scope, in_scope], "v1.0") is True + + +def test_any_req_in_report_version_false_if_no_requirement_matches() -> None: + any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]() + a = FakeNeed("feat_req__a") + a["valid_from"] = "v2.0" + b = FakeNeed("feat_req__b") + b["valid_from"] = "v3.0" + + assert any_req_in_report_version([a, b], "v1.0") is False diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index d2f25cb0f..1ea590820 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -21,9 +21,21 @@ per-component navigation. #} {% set module_id = "mod__" ~ id|replace("doc__", "")|replace("_verification_report", "") %} +{# Unset/empty ``report_version`` keeps the report unscoped (all components + and requirements shown), which is what the "_latest" report relies on. #} +{% set report_version = report_version|default(None, true) %} {# Resolve the component list from the module's outgoing graph links. A module - may list a component more than once, so deduplicate the NeedItems by ID. #} -{% set components_in_mod = linked_needs(module_id, "includes")|unique(attribute="id")|list %} + may list a component more than once, so deduplicate the NeedItems by ID. + A component with no requirement in scope is dropped from the report + entirely instead of rendering an empty section. #} +{% set ns_components = namespace(list=[]) %} +{% for component in linked_needs(module_id, "includes")|unique(attribute="id")|list %} +{% set component_reqs = linked_needs(component["id"], "satisfied_by_back")|selectattr("type", "eq", "comp_req")|list %} +{% if any_req_in_report_version(component_reqs, report_version) %} +{% set ns_components.list = ns_components.list + [component] %} +{% endif %} +{% endfor %} +{% set components_in_mod = ns_components.list %} {% set component_workproducts = [ ["wp__requirements_inspect", "Requirements Inspection"], @@ -51,6 +63,13 @@ {%- endfor %} {% endmacro %} +{#- Bracketed, quoted Sphinx-Needs ``id in [...]`` filter literal for a list + of Need IDs, used to scope needpie/needtable filters below to the + requirements that are in scope for ``report_version``. -#} +{% macro id_filter_list(ids) -%} +[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] +{%- endmacro %} + .. raw:: html