diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 3de92144f..806adb609 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -244,6 +244,52 @@ def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: _any_req_in_report_version_callable = _AnyRequirementInReportVersion() +class _ReferencedIds: + """Collect the IDs that in-scope lower-level requirements point at. + + Reference coverage asks the reverse question ("is this requirement + referenced?"), but answering it per requirement means rescanning the Need + graph for every row. Walking the lower requirements once and following + their cheap outgoing link instead turns that into a single pass. + + Returned as a sorted list because the result travels back through the + template; ``split_by_reference`` turns it into a set again for lookups. + """ + + def __call__( + self, lower_type: str, link_name: str, report_version: str | None + ) -> list[str]: + referenced: set[str] = set() + for lower in _needs_of_type_callable(lower_type): + if not _req_in_report_version_callable(lower, report_version): + continue + for upper in _linked_needs_callable(lower["id"], link_name): + referenced.add(upper["id"]) + return sorted(referenced) + + +_referenced_ids_callable = _ReferencedIds() + + +class _SplitByReference: + """Partition requirement IDs into referenced and unreferenced ones. + + Doing this in Python keeps the membership tests out of the template: every + ``in`` test evaluated by MiniJinja crosses the Rust/Python boundary, which + costs far more than the lookup itself. + """ + + def __call__(self, ids: list[str], referenced: list[str]) -> dict[str, list[str]]: + index = set(referenced) + return { + "referenced": [need_id for need_id in ids if need_id in index], + "missing": [need_id for need_id in ids if need_id not in index], + } + + +_split_by_reference_callable = _SplitByReference() + + 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() @@ -334,6 +380,12 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "any_req_in_report_version", _any_req_in_report_version_callable ) + app.config.needs_render_context.setdefault( + "referenced_ids", _referenced_ids_callable + ) + app.config.needs_render_context.setdefault( + "split_by_reference", _split_by_reference_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 2bdf8ec18..bddb56735 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 @@ -315,3 +315,119 @@ def test_tool_qualification_matrix_groups_shared_requirements( ) assert '
' not in html assert '
' not in html + + +def test_platform_report_gap_table_is_report_version_scoped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The gap table lists requirements lacking an *in-scope* reference. + + A stakeholder requirement that is only referenced by a feature requirement + outside the report's ``report_version`` counts as a gap, because the + reference coverage pie classifies it as "not referenced" too. Both are + rendered from the same pre-scoped ID list, so they cannot disagree. + """ + monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(tmp_path)) + (tmp_path / "conf.py").write_text( + """ +extensions = [ + "sphinx_needs", + "score_sphinx_needs_templates", + "score_metamodel", + "sphinx_design", +] +master_doc = "index" +needs_id_regex = r"^[a-zA-Z0-9_]+$" +""", + encoding="utf-8", + ) + (tmp_path / "index.rst").write_text( + """ +.. workproduct:: Platform report work product + :id: wp__platform_report + :status: valid + +.. document:: Platform verification report + :id: doc__platform_report + :status: valid + :safety: ASIL_B + :security: NO + :realizes: wp__platform_report + :report_version: v1.0 + :post_template: platform_verification_report + +.. stkh_req:: Referenced stakeholder requirement + :id: stkh_req__referenced + :reqtype: Functional + :safety: ASIL_B + :security: NO + :status: valid + :rationale: Referenced by an in-scope feature requirement. + :valid_from: v0.8 + + The platform shall satisfy the referenced stakeholder need. + +.. stkh_req:: Stakeholder requirement referenced out of scope only + :id: stkh_req__only_out_of_scope + :reqtype: Functional + :safety: ASIL_B + :security: NO + :status: valid + :rationale: Referenced only by an out-of-scope feature requirement. + :valid_from: v0.8 + + The platform shall satisfy the out-of-scope referenced stakeholder need. + +.. feat:: Scoped feature + :id: feat__scoped + :security: NO + :safety: ASIL_B + :status: valid + +.. feat_req:: In-scope feature requirement + :id: feat_req__in_scope + :reqtype: Functional + :security: NO + :safety: ASIL_B + :status: valid + :satisfied_by: feat__scoped + :derived_from: stkh_req__referenced + :valid_from: v0.8 + + This requirement is inside the report version scope. + +.. feat_req:: Out-of-scope feature requirement + :id: feat_req__out_of_scope + :reqtype: Functional + :security: NO + :safety: ASIL_B + :status: valid + :satisfied_by: feat__scoped + :derived_from: stkh_req__only_out_of_scope + :valid_from: v2.0 + + This requirement is outside the report version scope. +""", + encoding="utf-8", + ) + + app = SphinxTestApp( + srcdir=tmp_path, + outdir=tmp_path / "_build", + buildername="html", + freshenv=True, + ) + try: + app.build(force_all=True) + html = (app.outdir / "index.html").read_text(encoding="utf-8") + finally: + app.cleanup() + + assert "Not referenced by feature requirements" in html + gap_table = html.split("Not referenced by feature requirements", 1)[1].split( + '
{# ===================================================================== #} @@ -104,6 +109,13 @@ Stakeholder Requirements ------------------------ +{#- Reference coverage: a stakeholder requirement is "referenced" when at least + one in-scope feature requirement is ``derived_from`` it. The index is built + and applied in Python, because every ``in`` test evaluated here would cross + the Rust/Python boundary once per comparison. -#} +{% set ns_stkh_ref = split_by_reference( + ns_stkh_reqs.list, + referenced_ids("feat_req", "derived_from", report_version)) %} .. grid:: 1 2 2 2 :gutter: 3 @@ -129,6 +141,17 @@ Stakeholder Requirements type == "stkh_req" and partially_verifies_back and not fully_verifies_back and {{ stkh_req_filter }} type == "stkh_req" and not fully_verifies_back and not partially_verifies_back and {{ stkh_req_filter }} + .. grid-item:: + :class: score-centered-grid-item + + .. needpie:: Stakeholder Requirements Reference Coverage + :labels: referenced by lower, not referenced + :colors: #37a12d, #ca2828 + :legend: + + id in {{ id_filter_list(ns_stkh_ref.referenced) }} + id in {{ id_filter_list(ns_stkh_ref.missing) }} + .. dropdown:: Show requirements table :animate: fade-in @@ -138,6 +161,21 @@ Stakeholder Requirements :columns: id;title;safety;status;testlink :colwidths: 13,22,8,10,47 :sort: id +{% if ns_stkh_ref.missing %} +{#- Detail view of the "not referenced" slice of the reference coverage pie + above; both are rendered from the same in-scope ID list. -#} + +.. dropdown:: Not referenced by feature requirements ({{ ns_stkh_ref.missing|length }}) + :animate: fade-in + :color: warning + + .. needtable:: + :filter: id in {{ id_filter_list(ns_stkh_ref.missing) }} + :style: table + :columns: id;title;safety;status + :colwidths: 15,45,15,25 + :sort: id +{% endif %} {# ===================================================================== #} {# Features #} @@ -155,6 +193,10 @@ Feature Overview :style: table :sort: id +{#- Reference coverage index, built once for all Features: a feature + requirement is "referenced" when at least one in-scope component + requirement is ``derived_from`` it. -#} +{% set comp_referenced_ids = referenced_ids("comp_req", "derived_from", report_version) %} {% for feature in report_features %} {% set feature_id = feature["id"] %} {% set feature_title = feature["title"] %} @@ -182,6 +224,9 @@ Feature Overview {% endif %} {% endfor %} {% set feature_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %} +{#- Reference coverage: partition this Feature's in-scope requirements against + the index built once above. -#} +{% set ns_feat_ref = split_by_reference(ns.req_ids, comp_referenced_ids) %} Requirements Statistics ^^^^^^^^^^^^^^^^^^^^^^^ @@ -210,6 +255,17 @@ Requirements Statistics type == "feat_req" and "{{ feature_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ feature_req_filter }} type == "feat_req" and "{{ feature_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ feature_req_filter }} + .. grid-item:: + :class: score-centered-grid-item + + .. needpie:: {{ feature_title }} Requirements Reference Coverage + :labels: referenced by lower, not referenced + :colors: #37a12d, #ca2828 + :legend: + + id in {{ id_filter_list(ns_feat_ref.referenced) }} + id in {{ id_filter_list(ns_feat_ref.missing) }} + .. dropdown:: Show requirements table :animate: fade-in @@ -219,6 +275,19 @@ Requirements Statistics :columns: id;title;safety;status;testlink :colwidths: 13,22,8,10,47 :sort: id +{% if ns_feat_ref.missing %} + +.. dropdown:: Not referenced by component requirements ({{ ns_feat_ref.missing|length }}) + :animate: fade-in + :color: warning + + .. needtable:: + :filter: id in {{ id_filter_list(ns_feat_ref.missing) }} + :style: table + :columns: id;title;safety;status + :colwidths: 15,45,15,25 + :sort: id +{% endif %} Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^