Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/extensions/score_sphinx_needs_templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,119 @@ def test_tool_qualification_matrix_groups_shared_requirements(
)
assert '<section id="qualification-evidence">' not in html
assert '<section id="traceability-evidence">' 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(
'<section id="features"', 1
)[0]
# Referenced only outside the report scope, so it still counts as a gap.
assert 'href="#stkh_req__only_out_of_scope"' in gap_table
assert 'href="#stkh_req__referenced"' not in gap_table
69 changes: 69 additions & 0 deletions src/needs_templates/platform_verification_report.need
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@
.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; }
/* Centers a pie that wraps onto a row of its own in the two-column grids. */
.score-centered-grid-item {
margin-left: auto;
margin-right: auto;
}
</style>

{# ===================================================================== #}
Expand All @@ -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

Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - the title and description claimed the Referenced by column that was removed in 0ec4ad8.

Updated the title, rewrote the What section, and added an explicit What this PR does not add section explaining why the column was dropped (the raw backlink field is not report-version scoped) and that the drill-down ships as rows instead in the stacked follow-up #921. The Files list now matches the actual single changed file.

: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

Expand All @@ -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 #}
Expand All @@ -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"] %}
Expand Down Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -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

Expand All @@ -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
^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
Loading