From eca388583cf9b5fd3b09011dfa92462e4a9aed79 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:03:15 +0000 Subject: [PATCH 01/26] feat(metrics): add verification metrics for centralized report generation - Extend traceability_metrics_schema.json with verification_metrics section defining per-component status and coverage metrics - Add calculate_status_and_coverage_metrics() and calculate_verification_metrics() to compute status distribution and test coverage per component - Add get_component_status_metrics() and get_component_coverage_metrics() filter functions for needpie directives - Integrate verification_metrics into CALCULATED_METRICS global state This provides a single source of truth for verification report metrics, enabling both CI/CD checks and template consumption via sphinx_filters. --- .../traceability_metrics_schema.json | 72 +++++++++++++++++ .../score_metrics/sphinx_filters.py | 54 +++++++++++++ .../score_metrics/traceability_metrics.py | 79 +++++++++++++++++++ 3 files changed, 205 insertions(+) diff --git a/scripts_bazel/traceability_metrics_schema.json b/scripts_bazel/traceability_metrics_schema.json index dea866a8c..5d7d6df74 100644 --- a/scripts_bazel/traceability_metrics_schema.json +++ b/scripts_bazel/traceability_metrics_schema.json @@ -40,6 +40,17 @@ }, "tests": { "$ref": "#/$defs/testMetrics" + }, + "verification_metrics": { + "type": "object", + "description": "Per-component verification status and coverage metrics for verification reports.", + "additionalProperties": { + "$ref": "#/$defs/componentVerificationMetrics" + }, + "propertyNames": { + "type": "string", + "minLength": 1 + } } }, "$defs": { @@ -134,6 +145,67 @@ "type": "number", "minimum": 0, "maximum": 100 + }, + "componentVerificationMetrics": { + "type": "object", + "description": "Verification metrics for a single component.", + "additionalProperties": false, + "properties": { + "requirements": { + "$ref": "#/$defs/statusAndCoverageMetrics" + }, + "architecture": { + "$ref": "#/$defs/statusAndCoverageMetrics" + } + } + }, + "statusAndCoverageMetrics": { + "type": "object", + "description": "Status distribution and test coverage metrics.", + "additionalProperties": false, + "properties": { + "status": { + "type": "object", + "description": "Count by status value.", + "additionalProperties": false, + "properties": { + "valid": { + "type": "integer", + "minimum": 0 + }, + "valid_inspected": { + "type": "integer", + "minimum": 0 + }, + "invalid": { + "type": "integer", + "minimum": 0 + } + } + }, + "coverage": { + "type": "object", + "description": "Test coverage by verification links.", + "additionalProperties": false, + "properties": { + "fully_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with fully_verifies_back link." + }, + "partially_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with only partially_verifies_back link." + }, + "not_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with no verification links." + } + } + } + } } } } diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index f120157a9..f927a1fca 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -247,3 +247,57 @@ def get_non_overlapping_link_metrics( only_code = with_code - fully results.extend([remaining, only_test, only_code, fully]) + + +def get_component_status_metrics( + needs: list[Any], results: list[int], **kwargs: Any +) -> None: + """Get status distribution for a component's requirements or architecture. + + Arguments via kwargs: + - component_id: The component ID (e.g., "comp__my_component") + - category: Either "requirements" or "architecture" + + Returns [valid_count, valid_inspected_count, invalid_count]. + """ + results.clear() + component_id = str(kwargs.get("component_id", "")) + category = str(kwargs.get("category", "requirements")) + + metrics = CALCULATED_METRICS.get("verification_metrics", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + status = category_metrics.get("status", {}) + + results.extend([ + status.get("valid", 0), + status.get("valid_inspected", 0), + status.get("invalid", 0), + ]) + + +def get_component_coverage_metrics( + needs: list[Any], results: list[int], **kwargs: Any +) -> None: + """Get test coverage for a component's requirements or architecture. + + Arguments via kwargs: + - component_id: The component ID (e.g., "comp__my_component") + - category: Either "requirements" or "architecture" + + Returns [fully_covered, partially_covered, not_covered]. + """ + results.clear() + component_id = str(kwargs.get("component_id", "")) + category = str(kwargs.get("category", "requirements")) + + metrics = CALCULATED_METRICS.get("verification_metrics", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + coverage = category_metrics.get("coverage", {}) + + results.extend([ + coverage.get("fully_covered", 0), + coverage.get("partially_covered", 0), + coverage.get("not_covered", 0), + ]) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 07e40e0b0..1bb91441a 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -119,6 +119,84 @@ def calculate_test_metrics( } +def calculate_status_and_coverage_metrics( + needs_list: list[NeedItem], +) -> dict[str, dict[str, int]]: + """Calculate status distribution and test coverage for a list of needs. + + Returns a dict with: + - status: {valid: int, valid_inspected: int, invalid: int} + - coverage: {fully_covered: int, partially_covered: int, not_covered: int} + """ + status_counts = {"valid": 0, "valid_inspected": 0, "invalid": 0} + coverage_counts = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs_list: + # Status distribution + status = str(need.get("status", "")) + if status in status_counts: + status_counts[status] += 1 + + # Test coverage (based on verifies_back links) + has_fully = bool(need.get("fully_verifies_back")) + has_partially = bool(need.get("partially_verifies_back")) + + if has_fully: + coverage_counts["fully_covered"] += 1 + elif has_partially: + coverage_counts["partially_covered"] += 1 + else: + coverage_counts["not_covered"] += 1 + + return {"status": status_counts, "coverage": coverage_counts} + + +def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: + """Calculate per-component verification metrics for verification reports. + + Looks for needs that have belongs_to links to components and calculates + status/coverage metrics for requirements and architecture elements. + """ + verification_metrics: dict[str, dict] = {} + + # Find all components by looking at belongs_to links + component_ids: set[str] = set() + for need in all_needs.values(): + belongs_to = need.get("belongs_to") + if belongs_to: + if isinstance(belongs_to, list): + component_ids.update(belongs_to) + elif isinstance(belongs_to, str): + component_ids.add(belongs_to) + + # Calculate metrics per component + for component_id in component_ids: + # Requirements (comp_req with satisfied_by pointing to this component) + reqs = [ + n + for n in all_needs.values() + if n.get("type") == "comp_req" + and component_id in n.get("satisfied_by", []) + ] + req_metrics = calculate_status_and_coverage_metrics(reqs) + + # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) + arch = [ + n + for n in all_needs.values() + if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") + and component_id in n.get("belongs_to", []) + ] + arch_metrics = calculate_status_and_coverage_metrics(arch) + + verification_metrics[component_id] = { + "requirements": req_metrics, + "architecture": arch_metrics, + } + + return verification_metrics + + def calculate_full_need_metrics(app: Sphinx, include_external: bool): """ Calculate all tracked metrics for requirements and tests. @@ -188,6 +266,7 @@ def calculate_full_need_metrics(app: Sphinx, include_external: bool): "overall_metrics": overall_metrics, "metrics_by_type": metrics_by_type, "tests": test_metrics, + "verification_metrics": calculate_verification_metrics(all_needs), } # Save the metrics in a Global Variable to enable access from other parts. # Not a great solution but it is needed, as needpie filter functions for example From b7fdb52fa8c8575c1056e8fd1cb0889d6ef3eb77 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:08:06 +0000 Subject: [PATCH 02/26] feat(metrics): add version scoping to centralized verification metrics - Add version_filter parameter to calculate_status_and_coverage_metrics() implementing the same valid_from logic as the template's req_in_report_version - Update calculate_verification_metrics() to accept report_version parameter - Update filter functions to lookup version-specific metrics via verification_metrics_v{version} key with fallback to unversioned - Update template to pass module_version to filter functions This ensures centralized metrics match the version-scoped behavior of the original inline needpie filters. --- .../score_metrics/sphinx_filters.py | 28 +++++++- .../score_metrics/traceability_metrics.py | 65 ++++++++++++++++++- .../module_verification_report.need | 20 ++---- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index f927a1fca..58b2b7e49 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -257,14 +257,26 @@ def get_component_status_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" + - version: Optional version to filter by valid_from (default: no filtering) Returns [valid_count, valid_inspected_count, invalid_count]. """ results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) + version = kwargs.get("version") # Can be None or string + + # With versioning, metrics are stored under versioned keys + if version: + metrics_key = f"verification_metrics_v{version}" + else: + metrics_key = "verification_metrics" + + metrics = CALCULATED_METRICS.get(metrics_key, {}) + if not metrics: + # Fallback to unversioned metrics + metrics = CALCULATED_METRICS.get("verification_metrics", {}) - metrics = CALCULATED_METRICS.get("verification_metrics", {}) component_metrics = metrics.get(component_id, {}) category_metrics = component_metrics.get(category, {}) status = category_metrics.get("status", {}) @@ -284,14 +296,26 @@ def get_component_coverage_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" + - version: Optional version to filter by valid_from (default: no filtering) Returns [fully_covered, partially_covered, not_covered]. """ results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) + version = kwargs.get("version") # Can be None or string + + # With versioning, metrics are stored under versioned keys + if version: + metrics_key = f"verification_metrics_v{version}" + else: + metrics_key = "verification_metrics" + + metrics = CALCULATED_METRICS.get(metrics_key, {}) + if not metrics: + # Fallback to unversioned metrics + metrics = CALCULATED_METRICS.get("verification_metrics", {}) - metrics = CALCULATED_METRICS.get("verification_metrics", {}) component_metrics = metrics.get(component_id, {}) category_metrics = component_metrics.get(category, {}) coverage = category_metrics.get("coverage", {}) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 1bb91441a..35082e935 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -121,17 +121,66 @@ def calculate_test_metrics( def calculate_status_and_coverage_metrics( needs_list: list[NeedItem], + version_filter: str | None = None, + all_needs: NeedsView | None = None, ) -> dict[str, dict[str, int]]: """Calculate status distribution and test coverage for a list of needs. + Args: + needs_list: List of needs to calculate metrics for. + version_filter: Optional version to filter needs by valid_from. + all_needs: All needs view (required for version filtering of comp_req). + Returns a dict with: - status: {valid: int, valid_inspected: int, invalid: int} - coverage: {fully_covered: int, partially_covered: int, not_covered: int} """ + import re + + def parse_version(v: str) -> tuple[int, ...]: + """Parse version string like 'v1.2.3' into tuple (1, 2, 3).""" + match = re.match(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", v) + if not match: + raise ValueError(f"Invalid version: {v}") + return tuple(int(g or 0) for g in match.groups()) + + def req_in_version(need: NeedItem, version: str) -> bool: + """Check if a need is in scope for a given version.""" + if not version: + return True + + # Check direct valid_from + valid_from = need.get("valid_from") + if valid_from: + try: + return parse_version(valid_from) <= parse_version(version) + except ValueError: + return False + + # For comp_req, check derived_from feat_reqs + if need.get("type") == "comp_req" and all_needs: + derived_from = need.get("derived_from", []) + if isinstance(derived_from, str): + derived_from = [derived_from] + for feat_id in derived_from: + if feat_id in all_needs: + try: + if req_in_version(all_needs[feat_id], version): + return True + except (KeyError, ValueError): + pass + return False + + return True # No version info = always included + status_counts = {"valid": 0, "valid_inspected": 0, "invalid": 0} coverage_counts = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} for need in needs_list: + # Apply version filter + if version_filter and not req_in_version(need, version_filter): + continue + # Status distribution status = str(need.get("status", "")) if status in status_counts: @@ -151,9 +200,15 @@ def calculate_status_and_coverage_metrics( return {"status": status_counts, "coverage": coverage_counts} -def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: +def calculate_verification_metrics( + all_needs: NeedsView, report_version: str | None = None +) -> dict[str, dict]: """Calculate per-component verification metrics for verification reports. + Args: + all_needs: All needs view from Sphinx build. + report_version: Optional version to filter requirements by valid_from. + Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. """ @@ -178,7 +233,9 @@ def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: if n.get("type") == "comp_req" and component_id in n.get("satisfied_by", []) ] - req_metrics = calculate_status_and_coverage_metrics(reqs) + req_metrics = calculate_status_and_coverage_metrics( + reqs, version_filter=report_version, all_needs=all_needs + ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) arch = [ @@ -187,7 +244,9 @@ def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") and component_id in n.get("belongs_to", []) ] - arch_metrics = calculate_status_and_coverage_metrics(arch) + arch_metrics = calculate_status_and_coverage_metrics( + arch, version_filter=report_version, all_needs=all_needs + ) verification_metrics[component_id] = { "requirements": req_metrics, diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 80377f442..42442a400 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -160,10 +160,7 @@ Component Requirements Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid" and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid_inspected" and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid" and {{ component_req_filter }} + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") .. grid-item:: @@ -171,10 +168,7 @@ Component Requirements Statistics :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 and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ component_req_filter }} + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -188,10 +182,7 @@ Component Architecture Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #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 == "valid_inspected" - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "invalid" + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") .. grid-item:: @@ -199,10 +190,7 @@ Component Architecture Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and fully_verifies_back - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and partially_verifies_back and not fully_verifies_back - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and not fully_verifies_back and not partially_verifies_back + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From 15294315678445f6b1234a4bb2c3b78fba93eca9 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:12:58 +0000 Subject: [PATCH 03/26] fix(metrics): handle string-or-list for satisfied_by and belongs_to Sphinx-Needs may store single-value links as strings instead of lists. Ensure consistent list handling when checking component membership. --- .../score_metrics/traceability_metrics.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 35082e935..1f3da01f9 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -227,23 +227,29 @@ def calculate_verification_metrics( # Calculate metrics per component for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) - reqs = [ - n - for n in all_needs.values() - if n.get("type") == "comp_req" - and component_id in n.get("satisfied_by", []) - ] + reqs = [] + for n in all_needs.values(): + if n.get("type") != "comp_req": + continue + sat_by = n.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + if component_id in sat_by: + reqs.append(n) req_metrics = calculate_status_and_coverage_metrics( reqs, version_filter=report_version, all_needs=all_needs ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) - arch = [ - n - for n in all_needs.values() - if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") - and component_id in n.get("belongs_to", []) - ] + arch = [] + for n in all_needs.values(): + if n.get("type") not in ("comp_arc_sta", "comp_arc_dyn"): + continue + bel_to = n.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + if component_id in bel_to: + arch.append(n) arch_metrics = calculate_status_and_coverage_metrics( arch, version_filter=report_version, all_needs=all_needs ) From 85a9215dc686abe89c46c4a3b4ce5943380369f6 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:24:36 +0000 Subject: [PATCH 04/26] docs: document metrics.json content including verification_metrics Add a section describing the structure of metrics.json output, including the new verification_metrics section for per-component status and coverage data used by verification reports. --- docs/how-to/dashboards_and_quality_gates.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/how-to/dashboards_and_quality_gates.rst b/docs/how-to/dashboards_and_quality_gates.rst index 6a7ad9c4c..9c8d0089f 100644 --- a/docs/how-to/dashboards_and_quality_gates.rst +++ b/docs/how-to/dashboards_and_quality_gates.rst @@ -76,6 +76,21 @@ The documentation build writes ``metrics.json`` via ``score_metrics``, and the ` The dashboard charts and the CI gate both use the same computed metrics. +``metrics.json`` Content +~~~~~~~~~~~~~~~~~~~~~~~~ + +The exported file contains a versioned set of fields: + +- ``schema_version`` – schema version identifier. +- ``generated_by`` – identifier of the producer that generated this file. +- ``overall_metrics`` – requirement coverage aggregated over all requirement types. +- ``metrics_by_type`` – the same coverage numbers per requirement type. +- ``tests`` – testcase linkage statistics, including broken test references. +- ``verification_metrics`` – per-component verification status and coverage metrics + for use by verification reports. Contains status distribution (valid, valid_inspected, + invalid) and test coverage (fully_covered, partially_covered, not_covered) for + both requirements and architecture elements, organized by component ID. + Inputs for Linkage Metrics -------------------------- From b1d44f9579a177a6c76c3aeb5a954a07ef70c6c3 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:30:46 +0000 Subject: [PATCH 05/26] style: fix lint and type issues for centralized metrics - Add noqa: C901 for complex functions (inherent complexity from version filtering) - Add proper type hints for verification_metrics return type - Apply ruff-format fixes --- .../score_metrics/sphinx_filters.py | 24 +++++++++++-------- .../score_metrics/traceability_metrics.py | 8 +++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 58b2b7e49..9e7dab060 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -281,11 +281,13 @@ def get_component_status_metrics( category_metrics = component_metrics.get(category, {}) status = category_metrics.get("status", {}) - results.extend([ - status.get("valid", 0), - status.get("valid_inspected", 0), - status.get("invalid", 0), - ]) + results.extend( + [ + status.get("valid", 0), + status.get("valid_inspected", 0), + status.get("invalid", 0), + ] + ) def get_component_coverage_metrics( @@ -320,8 +322,10 @@ def get_component_coverage_metrics( category_metrics = component_metrics.get(category, {}) coverage = category_metrics.get("coverage", {}) - results.extend([ - coverage.get("fully_covered", 0), - coverage.get("partially_covered", 0), - coverage.get("not_covered", 0), - ]) + results.extend( + [ + coverage.get("fully_covered", 0), + coverage.get("partially_covered", 0), + coverage.get("not_covered", 0), + ] + ) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 1f3da01f9..5789b8bd1 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -119,7 +119,7 @@ def calculate_test_metrics( } -def calculate_status_and_coverage_metrics( +def calculate_status_and_coverage_metrics( # noqa: C901 needs_list: list[NeedItem], version_filter: str | None = None, all_needs: NeedsView | None = None, @@ -200,9 +200,9 @@ def req_in_version(need: NeedItem, version: str) -> bool: return {"status": status_counts, "coverage": coverage_counts} -def calculate_verification_metrics( +def calculate_verification_metrics( # noqa: C901 all_needs: NeedsView, report_version: str | None = None -) -> dict[str, dict]: +) -> dict[str, dict[str, dict[str, dict[str, int]]]]: """Calculate per-component verification metrics for verification reports. Args: @@ -212,7 +212,7 @@ def calculate_verification_metrics( Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. """ - verification_metrics: dict[str, dict] = {} + verification_metrics: dict[str, dict[str, dict[str, dict[str, int]]]] = {} # Find all components by looking at belongs_to links component_ids: set[str] = set() From e8d1371eaca3da8a3e81901f9e2df8b081390e01 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 14:37:32 +0000 Subject: [PATCH 06/26] feat: make version-scoping helpers public for reuse - Make parse_version, RequirementInReportVersion, and any_req_in_report_version public APIs in score_sphinx_needs_templates - Update sphinx_filters.py to use public req_in_report_version - Fix version scoping in verification report pie charts to properly filter requirements by valid_from when applies_to_module_version is set - Add helper functions to reduce complexity in filter functions - Update tests to use public API instead of vars()[...] access - Add documentation note about duplicate version logic in traceability_metrics Fixes issue where Component Requirements Status/Test Coverage pie charts showed ALL requirements instead of only those in scope for a report version. --- .../score_metrics/sphinx_filters.py | 106 ++++++++++++++---- .../score_metrics/traceability_metrics.py | 7 ++ .../score_sphinx_needs_templates/__init__.py | 28 ++++- .../tests/test_needs_templates.py | 51 ++++----- 4 files changed, 133 insertions(+), 59 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 9e7dab060..938652ddc 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -42,6 +42,9 @@ def func(needs: list[NeedItem], results: list[int], **kwargs) -> None: ... from typing import Any from score_metrics.traceability_metrics import CALCULATED_METRICS + +# Import the public version-scoping helper for filtering by valid_from +from score_sphinx_needs_templates import req_in_report_version from sphinx_needs.need_item import NeedItem @@ -249,6 +252,71 @@ def get_non_overlapping_link_metrics( results.extend([remaining, only_test, only_code, fully]) +def _is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: + """Check if a need belongs to a component for a given category.""" + need_type = str(need.get("type", "")) + + if category == "requirements": + if need_type != "comp_req": + return False + sat_by = need.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + return component_id in sat_by + + # architecture + if need_type not in ("comp_arc_sta", "comp_arc_dyn"): + return False + bel_to = need.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + return component_id in bel_to + + +def _count_status_filtered( + needs: list[Any], component_id: str, category: str, version: str +) -> dict[str, int]: + """Count status distribution with version filtering.""" + status = {"valid": 0, "valid_inspected": 0, "invalid": 0} + + for need in needs: + if not _is_need_in_component(need, component_id, category): + continue + if not req_in_report_version(need, version): + continue + + need_status = str(need.get("status", "")) + if need_status in status: + status[need_status] += 1 + + return status + + +def _count_coverage_filtered( + needs: list[Any], component_id: str, category: str, version: str +) -> dict[str, int]: + """Count test coverage with version filtering.""" + coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs: + if not _is_need_in_component(need, component_id, category): + continue + if not req_in_report_version(need, version): + continue + + has_testlink = bool(need.get("testlink")) + has_code_link = bool(need.get("source_code_link")) + + if has_testlink and has_code_link: + coverage["fully_covered"] += 1 + elif has_testlink or has_code_link: + coverage["partially_covered"] += 1 + else: + coverage["not_covered"] += 1 + + return coverage + + def get_component_status_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: @@ -264,22 +332,17 @@ def get_component_status_metrics( results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") # Can be None or string + version = kwargs.get("version") - # With versioning, metrics are stored under versioned keys + # If version is specified, filter on-the-fly using the version-scoping helper if version: - metrics_key = f"verification_metrics_v{version}" + status = _count_status_filtered(needs, component_id, category, version) else: - metrics_key = "verification_metrics" - - metrics = CALCULATED_METRICS.get(metrics_key, {}) - if not metrics: - # Fallback to unversioned metrics + # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) - - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - status = category_metrics.get("status", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + status = category_metrics.get("status", {}) results.extend( [ @@ -305,22 +368,17 @@ def get_component_coverage_metrics( results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") # Can be None or string + version = kwargs.get("version") - # With versioning, metrics are stored under versioned keys + # If version is specified, filter on-the-fly using the version-scoping helper if version: - metrics_key = f"verification_metrics_v{version}" + coverage = _count_coverage_filtered(needs, component_id, category, version) else: - metrics_key = "verification_metrics" - - metrics = CALCULATED_METRICS.get(metrics_key, {}) - if not metrics: - # Fallback to unversioned metrics + # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) - - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - coverage = category_metrics.get("coverage", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + coverage = category_metrics.get("coverage", {}) results.extend( [ diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 5789b8bd1..211ad5c04 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -134,6 +134,13 @@ def calculate_status_and_coverage_metrics( # noqa: C901 Returns a dict with: - status: {valid: int, valid_inspected: int, invalid: int} - coverage: {fully_covered: int, partially_covered: int, not_covered: int} + + Note: + The version filtering logic here duplicates ``req_in_report_version`` + from ``score_sphinx_needs_templates``. We keep it separate because + this function operates during the build phase with an explicit + ``all_needs`` parameter, while ``req_in_report_version`` relies on + Sphinx environment state during template rendering. """ import re diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 806adb609..1d37c0d6d 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -178,12 +178,14 @@ def __call__(self, need_type: str) -> list[NeedItem]: _needs_of_type_callable = _NeedsOfType() -def _parse_version(value: str) -> tuple[int, int, int]: +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``. + + Public API for use by other extensions that need version comparison. """ numbers = [int(part) for part in value.strip().lstrip("vV").split(".")] while len(numbers) < 3: @@ -191,7 +193,11 @@ def _parse_version(value: str) -> tuple[int, int, int]: return (numbers[0], numbers[1], numbers[2]) -class _RequirementInReportVersion: +# Keep private alias for backwards compatibility with internal code +_parse_version = parse_version + + +class RequirementInReportVersion: """Decide whether a requirement Need belongs to a ``report_version`` scope. ``feat_req`` (and ``stkh_req``) carry ``valid_from`` directly. ``comp_req`` @@ -205,6 +211,8 @@ class _RequirementInReportVersion: Calling with an empty/``None`` ``report_version`` always returns ``True``, which keeps unscoped reports (e.g. "latest") showing every requirement as before. + + Public API for use by other extensions that need version-scoped filtering. """ def __call__(self, need: NeedItem, report_version: str | None) -> bool: @@ -222,10 +230,14 @@ def __call__(self, need: NeedItem, report_version: str | None) -> bool: return any(self(feat_req, report_version) for feat_req in linked_feat_reqs) -_req_in_report_version_callable = _RequirementInReportVersion() +# Public instance for direct import +req_in_report_version = RequirementInReportVersion() +# Private alias for backwards compatibility with internal code +_req_in_report_version_callable = req_in_report_version -class _AnyRequirementInReportVersion: + +class AnyRequirementInReportVersion: """Decide whether a Feature/Component has any requirement in scope. Used to drop an entire Feature/Component section from the report when @@ -233,6 +245,8 @@ class _AnyRequirementInReportVersion: 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). + + Public API for use by other extensions that need version-scoped filtering. """ def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: @@ -241,7 +255,11 @@ def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: return any(_req_in_report_version_callable(req, report_version) for req in reqs) -_any_req_in_report_version_callable = _AnyRequirementInReportVersion() +# Public instance for direct import +any_req_in_report_version = AnyRequirementInReportVersion() + +# Private alias for backwards compatibility with Jinja context +_any_req_in_report_version_callable = any_req_in_report_version class _ReferencedIds: 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 bddb56735..266ddc2ed 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 @@ -31,7 +31,11 @@ def to_link_string(self) -> str: class FakeNeed(dict[str, str]): - """Need-shaped test double with independently controlled link indexes.""" + """Need-shaped test double with independently controlled link indexes. + + Note: This is a simplified test double that doesn't fully implement NeedItem. + Tests using this class with typed APIs may need type: ignore comments. + """ def __init__( self, @@ -74,53 +78,47 @@ def test_backlinks_merge_indexed_and_new_outgoing_links( 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) + assert templates.parse_version("v0.8") == (0, 8, 0) + assert templates.parse_version("v1.0.1") == (1, 0, 1) + assert templates.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 + assert templates.req_in_report_version(need, None) is True # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "") is True # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(need, "v0.7") is False # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "v0.8") is True # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "v0.9") is True # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(need, "v1.0") is False # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(comp_req, "v1.0") is False # type: ignore[reportArgumentType] def test_req_in_report_version_comp_req_inherits_via_derived_from( @@ -128,49 +126,42 @@ def test_req_in_report_version_comp_req_inherits_via_derived_from( ) -> 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 + assert templates.req_in_report_version(comp_req, "v0.7") is False # type: ignore[reportArgumentType] + assert templates.req_in_report_version(comp_req, "v0.8") is True # type: ignore[reportArgumentType] 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 + assert templates.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 + assert templates.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 + assert templates.any_req_in_report_version([out_of_scope, in_scope], "v1.0") is True # type: ignore[reportArgumentType] 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 + assert templates.any_req_in_report_version([a, b], "v1.0") is False # type: ignore[reportArgumentType] def test_tool_qualification_matrix_groups_shared_requirements( From 3e3d1e9104270ce223b53af766a91b55bf1b217c Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 15:01:36 +0000 Subject: [PATCH 07/26] fix: add missing Bazel dependency on score_sphinx_needs_templates score_metrics imports req_in_report_version from score_sphinx_needs_templates, but this dependency wasn't declared in the BUILD file, causing ModuleNotFoundError in score_metamodel tests. --- src/extensions/score_metrics/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/score_metrics/BUILD b/src/extensions/score_metrics/BUILD index c909fe7ab..c08e1ef20 100644 --- a/src/extensions/score_metrics/BUILD +++ b/src/extensions/score_metrics/BUILD @@ -39,6 +39,7 @@ py_library( # TODO: Figure out if all requirements are needed or if we can break it down a bit deps = all_requirements + [ "@score_docs_as_code//src/helper_lib", + "@score_docs_as_code//src/extensions/score_sphinx_needs_templates", ], ) From 24fcaf2ac7a4d9244521e084fe2bd73db4a19f39 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:23:17 +0000 Subject: [PATCH 08/26] move to the helpers --- .../score_metrics/sphinx_filters.py | 88 +++-------- .../score_metrics/traceability_metrics.py | 65 +++++---- src/helper_lib/verification_metrics.py | 138 ++++++++++++++++++ 3 files changed, 194 insertions(+), 97 deletions(-) create mode 100644 src/helper_lib/verification_metrics.py diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 938652ddc..cef2b09f7 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -47,6 +47,12 @@ def func(needs: list[NeedItem], results: list[int], **kwargs) -> None: ... from score_sphinx_needs_templates import req_in_report_version from sphinx_needs.need_item import NeedItem +# Import shared verification metrics helpers +from src.helper_lib.verification_metrics import ( + count_coverage_filtered, + count_status_filtered, +) + def _matches_source_selector(need: NeedItem, selector: str) -> bool: """Return whether a need matches a source selector. @@ -252,71 +258,6 @@ def get_non_overlapping_link_metrics( results.extend([remaining, only_test, only_code, fully]) -def _is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: - """Check if a need belongs to a component for a given category.""" - need_type = str(need.get("type", "")) - - if category == "requirements": - if need_type != "comp_req": - return False - sat_by = need.get("satisfied_by", []) - if isinstance(sat_by, str): - sat_by = [sat_by] - return component_id in sat_by - - # architecture - if need_type not in ("comp_arc_sta", "comp_arc_dyn"): - return False - bel_to = need.get("belongs_to", []) - if isinstance(bel_to, str): - bel_to = [bel_to] - return component_id in bel_to - - -def _count_status_filtered( - needs: list[Any], component_id: str, category: str, version: str -) -> dict[str, int]: - """Count status distribution with version filtering.""" - status = {"valid": 0, "valid_inspected": 0, "invalid": 0} - - for need in needs: - if not _is_need_in_component(need, component_id, category): - continue - if not req_in_report_version(need, version): - continue - - need_status = str(need.get("status", "")) - if need_status in status: - status[need_status] += 1 - - return status - - -def _count_coverage_filtered( - needs: list[Any], component_id: str, category: str, version: str -) -> dict[str, int]: - """Count test coverage with version filtering.""" - coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} - - for need in needs: - if not _is_need_in_component(need, component_id, category): - continue - if not req_in_report_version(need, version): - continue - - has_testlink = bool(need.get("testlink")) - has_code_link = bool(need.get("source_code_link")) - - if has_testlink and has_code_link: - coverage["fully_covered"] += 1 - elif has_testlink or has_code_link: - coverage["partially_covered"] += 1 - else: - coverage["not_covered"] += 1 - - return coverage - - def get_component_status_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: @@ -325,7 +266,7 @@ def get_component_status_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (default: no filtering) + - version: Optional version to filter by valid_from (only applied to requirements) Returns [valid_count, valid_inspected_count, invalid_count]. """ @@ -336,7 +277,9 @@ def get_component_status_metrics( # If version is specified, filter on-the-fly using the version-scoping helper if version: - status = _count_status_filtered(needs, component_id, category, version) + status = count_status_filtered( + needs, component_id, category, version, req_in_report_version + ) else: # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) @@ -356,12 +299,15 @@ def get_component_status_metrics( def get_component_coverage_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: - """Get test coverage for a component's requirements or architecture. + """Get verification coverage for a component's requirements or architecture. + + Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), + not the link coverage axis (testlink / source_code_link). Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (default: no filtering) + - version: Optional version to filter by valid_from (only applied to requirements) Returns [fully_covered, partially_covered, not_covered]. """ @@ -372,7 +318,9 @@ def get_component_coverage_metrics( # If version is specified, filter on-the-fly using the version-scoping helper if version: - coverage = _count_coverage_filtered(needs, component_id, category, version) + coverage = count_coverage_filtered( + needs, component_id, category, version, req_in_report_version + ) else: # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 211ad5c04..2001cffc1 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -28,6 +28,13 @@ from sphinx_needs.data import NeedsView, SphinxNeedsData from sphinx_needs.need_item import NeedItem +# Import shared verification metrics helpers +from src.helper_lib.verification_metrics import ( + count_coverage_filtered, + count_status_filtered, + is_need_in_component, +) + CALCULATED_METRICS: dict[str, Any] = {} @@ -136,11 +143,9 @@ def calculate_status_and_coverage_metrics( # noqa: C901 - coverage: {fully_covered: int, partially_covered: int, not_covered: int} Note: - The version filtering logic here duplicates ``req_in_report_version`` - from ``score_sphinx_needs_templates``. We keep it separate because - this function operates during the build phase with an explicit - ``all_needs`` parameter, while ``req_in_report_version`` relies on - Sphinx environment state during template rendering. + For component-specific metrics with version filtering, prefer using + the shared helpers from helper_lib.verification_metrics which handle + architecture version filtering correctly. """ import re @@ -215,6 +220,8 @@ def calculate_verification_metrics( # noqa: C901 Args: all_needs: All needs view from Sphinx build. report_version: Optional version to filter requirements by valid_from. + Note: Architecture elements are NOT version-filtered as they have + no valid_from or derived_from attributes. Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. @@ -231,34 +238,38 @@ def calculate_verification_metrics( # noqa: C901 elif isinstance(belongs_to, str): component_ids.add(belongs_to) + # Simple version filter function for use with shared helpers + def version_filter_func(need: NeedItem, version: str) -> bool: + """Simple passthrough to req_in_report_version for shared helpers.""" + from score_sphinx_needs_templates import req_in_report_version + + return req_in_report_version(need, version) + # Calculate metrics per component for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) - reqs = [] - for n in all_needs.values(): - if n.get("type") != "comp_req": - continue - sat_by = n.get("satisfied_by", []) - if isinstance(sat_by, str): - sat_by = [sat_by] - if component_id in sat_by: - reqs.append(n) - req_metrics = calculate_status_and_coverage_metrics( - reqs, version_filter=report_version, all_needs=all_needs - ) + # Version filtering is applied to requirements + reqs = [n for n in all_needs.values() if is_need_in_component(n, component_id, "requirements")] + + if report_version: + req_metrics = { + "status": count_status_filtered( + reqs, component_id, "requirements", report_version, version_filter_func + ), + "coverage": count_coverage_filtered( + reqs, component_id, "requirements", report_version, version_filter_func + ), + } + else: + req_metrics = calculate_status_and_coverage_metrics( + reqs, version_filter=None, all_needs=all_needs + ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) - arch = [] - for n in all_needs.values(): - if n.get("type") not in ("comp_arc_sta", "comp_arc_dyn"): - continue - bel_to = n.get("belongs_to", []) - if isinstance(bel_to, str): - bel_to = [bel_to] - if component_id in bel_to: - arch.append(n) + # Version filtering is NOT applied to architecture elements + arch = [n for n in all_needs.values() if is_need_in_component(n, component_id, "architecture")] arch_metrics = calculate_status_and_coverage_metrics( - arch, version_filter=report_version, all_needs=all_needs + arch, version_filter=None, all_needs=all_needs ) verification_metrics[component_id] = { diff --git a/src/helper_lib/verification_metrics.py b/src/helper_lib/verification_metrics.py new file mode 100644 index 000000000..a52fa472c --- /dev/null +++ b/src/helper_lib/verification_metrics.py @@ -0,0 +1,138 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Shared verification metrics helpers for use in both metrics calculation and filtering. + +These helpers are used by: +- traceability_metrics.py for pre-calculating verification_metrics in metrics.json +- sphinx_filters.py for on-the-fly filtering in needpie directives +""" + +from typing import Any + +from sphinx_needs.need_item import NeedItem + + +def is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: + """Check if a need belongs to a component for a given category. + + Args: + need: The need item to check. + component_id: The component ID (e.g., "comp__my_component"). + category: Either "requirements" or "architecture". + + Returns: + True if the need belongs to the component for that category. + """ + need_type = str(need.get("type", "")) + + if category == "requirements": + if need_type != "comp_req": + return False + sat_by = need.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + return component_id in sat_by + + # architecture + if need_type not in ("comp_arc_sta", "comp_arc_dyn"): + return False + bel_to = need.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + return component_id in bel_to + + +def count_status_filtered( + needs: list[Any], + component_id: str, + category: str, + version: str, + version_filter_func: Any, +) -> dict[str, int]: + """Count status distribution with version filtering. + + Args: + needs: List of need items. + component_id: The component ID. + category: Either "requirements" or "architecture". + version: Version string for filtering (only applied to requirements). + version_filter_func: Callable like req_in_report_version(need, version). + + Returns: + Dict with status counts: {valid: int, valid_inspected: int, invalid: int} + """ + status = {"valid": 0, "valid_inspected": 0, "invalid": 0} + + for need in needs: + if not is_need_in_component(need, component_id, category): + continue + + # Architecture elements should not be version-filtered + # (they have no valid_from or derived_from attributes) + if category == "requirements" and version: + if not version_filter_func(need, version): + continue + + need_status = str(need.get("status", "")) + if need_status in status: + status[need_status] += 1 + + return status + + +def count_coverage_filtered( + needs: list[Any], + component_id: str, + category: str, + version: str, + version_filter_func: Any, +) -> dict[str, int]: + """Count verification coverage with version filtering. + + Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), + not the link coverage axis (testlink / source_code_link). + + Args: + needs: List of need items. + component_id: The component ID. + category: Either "requirements" or "architecture". + version: Version string for filtering (only applied to requirements). + version_filter_func: Callable like req_in_report_version(need, version). + + Returns: + Dict with coverage counts: {fully_covered: int, partially_covered: int, not_covered: int} + """ + coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs: + if not is_need_in_component(need, component_id, category): + continue + + # Architecture elements should not be version-filtered + if category == "requirements" and version: + if not version_filter_func(need, version): + continue + + # Use verification coverage axis (not link coverage axis) + has_fully = bool(need.get("fully_verifies_back")) + has_partially = bool(need.get("partially_verifies_back")) + + if has_fully: + coverage["fully_covered"] += 1 + elif has_partially: + coverage["partially_covered"] += 1 + else: + coverage["not_covered"] += 1 + + return coverage From d2cc7eaddf662a507f7071e8002b638ace7a3d11 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:51:49 +0000 Subject: [PATCH 09/26] precommit fix --- .../score_metrics/traceability_metrics.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 2001cffc1..91ed9ee84 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -249,15 +249,27 @@ def version_filter_func(need: NeedItem, version: str) -> bool: for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) # Version filtering is applied to requirements - reqs = [n for n in all_needs.values() if is_need_in_component(n, component_id, "requirements")] + reqs = [ + n + for n in all_needs.values() + if is_need_in_component(n, component_id, "requirements") + ] if report_version: req_metrics = { "status": count_status_filtered( - reqs, component_id, "requirements", report_version, version_filter_func + reqs, + component_id, + "requirements", + report_version, + version_filter_func, ), "coverage": count_coverage_filtered( - reqs, component_id, "requirements", report_version, version_filter_func + reqs, + component_id, + "requirements", + report_version, + version_filter_func, ), } else: @@ -267,7 +279,11 @@ def version_filter_func(need: NeedItem, version: str) -> bool: # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) # Version filtering is NOT applied to architecture elements - arch = [n for n in all_needs.values() if is_need_in_component(n, component_id, "architecture")] + arch = [ + n + for n in all_needs.values() + if is_need_in_component(n, component_id, "architecture") + ] arch_metrics = calculate_status_and_coverage_metrics( arch, version_filter=None, all_needs=all_needs ) From b5b10060dbaf49bbd2d22d056a78eb722cd0cc9c Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:54:07 +0000 Subject: [PATCH 10/26] fix lint --- src/helper_lib/verification_metrics.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/helper_lib/verification_metrics.py b/src/helper_lib/verification_metrics.py index a52fa472c..9f7beb1e5 100644 --- a/src/helper_lib/verification_metrics.py +++ b/src/helper_lib/verification_metrics.py @@ -80,9 +80,12 @@ def count_status_filtered( # Architecture elements should not be version-filtered # (they have no valid_from or derived_from attributes) - if category == "requirements" and version: - if not version_filter_func(need, version): - continue + if ( + category == "requirements" + and version + and not version_filter_func(need, version) + ): + continue need_status = str(need.get("status", "")) if need_status in status: @@ -120,9 +123,13 @@ def count_coverage_filtered( continue # Architecture elements should not be version-filtered - if category == "requirements" and version: - if not version_filter_func(need, version): - continue + # (they have no valid_from or derived_from attributes) + if ( + category == "requirements" + and version + and not version_filter_func(need, version) + ): + continue # Use verification coverage axis (not link coverage axis) has_fully = bool(need.get("fully_verifies_back")) From 3620f2f4af2d1c785eccd6ad2c4660ac9c690229 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 12:20:16 +0000 Subject: [PATCH 11/26] copillot findings --- .../score_metrics/sphinx_filters.py | 28 +++++++++---------- .../score_metrics/traceability_metrics.py | 13 +++------ .../module_verification_report.need | 8 +++--- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index cef2b09f7..b567e4792 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -263,17 +263,17 @@ def get_component_status_metrics( ) -> None: """Get status distribution for a component's requirements or architecture. - Arguments via kwargs: - - component_id: The component ID (e.g., "comp__my_component") - - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (only applied to requirements) + Arguments are passed via ``arg1``, ``arg2``, ``arg3``: + - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") + - ``arg2`` – category: Either "requirements" or "architecture" + - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) Returns [valid_count, valid_inspected_count, invalid_count]. """ results.clear() - component_id = str(kwargs.get("component_id", "")) - category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") + component_id = str(kwargs.get("arg1", "")) + category = str(kwargs.get("arg2", "requirements")) + version = kwargs.get("arg3") # If version is specified, filter on-the-fly using the version-scoping helper if version: @@ -304,17 +304,17 @@ def get_component_coverage_metrics( Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), not the link coverage axis (testlink / source_code_link). - Arguments via kwargs: - - component_id: The component ID (e.g., "comp__my_component") - - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (only applied to requirements) + Arguments are passed via ``arg1``, ``arg2``, ``arg3``: + - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") + - ``arg2`` – category: Either "requirements" or "architecture" + - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) Returns [fully_covered, partially_covered, not_covered]. """ results.clear() - component_id = str(kwargs.get("component_id", "")) - category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") + component_id = str(kwargs.get("arg1", "")) + category = str(kwargs.get("arg2", "requirements")) + version = kwargs.get("arg3") # If version is specified, filter on-the-fly using the version-scoping helper if version: diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 91ed9ee84..e659312eb 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -228,15 +228,10 @@ def calculate_verification_metrics( # noqa: C901 """ verification_metrics: dict[str, dict[str, dict[str, dict[str, int]]]] = {} - # Find all components by looking at belongs_to links - component_ids: set[str] = set() - for need in all_needs.values(): - belongs_to = need.get("belongs_to") - if belongs_to: - if isinstance(belongs_to, list): - component_ids.update(belongs_to) - elif isinstance(belongs_to, str): - component_ids.add(belongs_to) + # Find all components by looking at Needs with type "comp" + component_ids: set[str] = { + n["id"] for n in all_needs.values() if n.get("type") == "comp" + } # Simple version filter function for use with shared helpers def version_filter_func(need: NeedItem, version: str) -> bool: diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 42442a400..83dcddfa2 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -160,7 +160,7 @@ Component Requirements Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") .. grid-item:: @@ -168,7 +168,7 @@ Component Requirements Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -182,7 +182,7 @@ Component Architecture Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") .. grid-item:: @@ -190,7 +190,7 @@ Component Architecture Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From 75d635f44de78ff52e8d00244e84fee5e1ed5016 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:03:15 +0000 Subject: [PATCH 12/26] feat(metrics): add verification metrics for centralized report generation - Extend traceability_metrics_schema.json with verification_metrics section defining per-component status and coverage metrics - Add calculate_status_and_coverage_metrics() and calculate_verification_metrics() to compute status distribution and test coverage per component - Add get_component_status_metrics() and get_component_coverage_metrics() filter functions for needpie directives - Integrate verification_metrics into CALCULATED_METRICS global state This provides a single source of truth for verification report metrics, enabling both CI/CD checks and template consumption via sphinx_filters. --- .../traceability_metrics_schema.json | 72 +++++++++++++++++ .../score_metrics/sphinx_filters.py | 54 +++++++++++++ .../score_metrics/traceability_metrics.py | 79 +++++++++++++++++++ 3 files changed, 205 insertions(+) diff --git a/scripts_bazel/traceability_metrics_schema.json b/scripts_bazel/traceability_metrics_schema.json index dea866a8c..5d7d6df74 100644 --- a/scripts_bazel/traceability_metrics_schema.json +++ b/scripts_bazel/traceability_metrics_schema.json @@ -40,6 +40,17 @@ }, "tests": { "$ref": "#/$defs/testMetrics" + }, + "verification_metrics": { + "type": "object", + "description": "Per-component verification status and coverage metrics for verification reports.", + "additionalProperties": { + "$ref": "#/$defs/componentVerificationMetrics" + }, + "propertyNames": { + "type": "string", + "minLength": 1 + } } }, "$defs": { @@ -134,6 +145,67 @@ "type": "number", "minimum": 0, "maximum": 100 + }, + "componentVerificationMetrics": { + "type": "object", + "description": "Verification metrics for a single component.", + "additionalProperties": false, + "properties": { + "requirements": { + "$ref": "#/$defs/statusAndCoverageMetrics" + }, + "architecture": { + "$ref": "#/$defs/statusAndCoverageMetrics" + } + } + }, + "statusAndCoverageMetrics": { + "type": "object", + "description": "Status distribution and test coverage metrics.", + "additionalProperties": false, + "properties": { + "status": { + "type": "object", + "description": "Count by status value.", + "additionalProperties": false, + "properties": { + "valid": { + "type": "integer", + "minimum": 0 + }, + "valid_inspected": { + "type": "integer", + "minimum": 0 + }, + "invalid": { + "type": "integer", + "minimum": 0 + } + } + }, + "coverage": { + "type": "object", + "description": "Test coverage by verification links.", + "additionalProperties": false, + "properties": { + "fully_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with fully_verifies_back link." + }, + "partially_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with only partially_verifies_back link." + }, + "not_covered": { + "type": "integer", + "minimum": 0, + "description": "Items with no verification links." + } + } + } + } } } } diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index f120157a9..f927a1fca 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -247,3 +247,57 @@ def get_non_overlapping_link_metrics( only_code = with_code - fully results.extend([remaining, only_test, only_code, fully]) + + +def get_component_status_metrics( + needs: list[Any], results: list[int], **kwargs: Any +) -> None: + """Get status distribution for a component's requirements or architecture. + + Arguments via kwargs: + - component_id: The component ID (e.g., "comp__my_component") + - category: Either "requirements" or "architecture" + + Returns [valid_count, valid_inspected_count, invalid_count]. + """ + results.clear() + component_id = str(kwargs.get("component_id", "")) + category = str(kwargs.get("category", "requirements")) + + metrics = CALCULATED_METRICS.get("verification_metrics", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + status = category_metrics.get("status", {}) + + results.extend([ + status.get("valid", 0), + status.get("valid_inspected", 0), + status.get("invalid", 0), + ]) + + +def get_component_coverage_metrics( + needs: list[Any], results: list[int], **kwargs: Any +) -> None: + """Get test coverage for a component's requirements or architecture. + + Arguments via kwargs: + - component_id: The component ID (e.g., "comp__my_component") + - category: Either "requirements" or "architecture" + + Returns [fully_covered, partially_covered, not_covered]. + """ + results.clear() + component_id = str(kwargs.get("component_id", "")) + category = str(kwargs.get("category", "requirements")) + + metrics = CALCULATED_METRICS.get("verification_metrics", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + coverage = category_metrics.get("coverage", {}) + + results.extend([ + coverage.get("fully_covered", 0), + coverage.get("partially_covered", 0), + coverage.get("not_covered", 0), + ]) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 07e40e0b0..1bb91441a 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -119,6 +119,84 @@ def calculate_test_metrics( } +def calculate_status_and_coverage_metrics( + needs_list: list[NeedItem], +) -> dict[str, dict[str, int]]: + """Calculate status distribution and test coverage for a list of needs. + + Returns a dict with: + - status: {valid: int, valid_inspected: int, invalid: int} + - coverage: {fully_covered: int, partially_covered: int, not_covered: int} + """ + status_counts = {"valid": 0, "valid_inspected": 0, "invalid": 0} + coverage_counts = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs_list: + # Status distribution + status = str(need.get("status", "")) + if status in status_counts: + status_counts[status] += 1 + + # Test coverage (based on verifies_back links) + has_fully = bool(need.get("fully_verifies_back")) + has_partially = bool(need.get("partially_verifies_back")) + + if has_fully: + coverage_counts["fully_covered"] += 1 + elif has_partially: + coverage_counts["partially_covered"] += 1 + else: + coverage_counts["not_covered"] += 1 + + return {"status": status_counts, "coverage": coverage_counts} + + +def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: + """Calculate per-component verification metrics for verification reports. + + Looks for needs that have belongs_to links to components and calculates + status/coverage metrics for requirements and architecture elements. + """ + verification_metrics: dict[str, dict] = {} + + # Find all components by looking at belongs_to links + component_ids: set[str] = set() + for need in all_needs.values(): + belongs_to = need.get("belongs_to") + if belongs_to: + if isinstance(belongs_to, list): + component_ids.update(belongs_to) + elif isinstance(belongs_to, str): + component_ids.add(belongs_to) + + # Calculate metrics per component + for component_id in component_ids: + # Requirements (comp_req with satisfied_by pointing to this component) + reqs = [ + n + for n in all_needs.values() + if n.get("type") == "comp_req" + and component_id in n.get("satisfied_by", []) + ] + req_metrics = calculate_status_and_coverage_metrics(reqs) + + # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) + arch = [ + n + for n in all_needs.values() + if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") + and component_id in n.get("belongs_to", []) + ] + arch_metrics = calculate_status_and_coverage_metrics(arch) + + verification_metrics[component_id] = { + "requirements": req_metrics, + "architecture": arch_metrics, + } + + return verification_metrics + + def calculate_full_need_metrics(app: Sphinx, include_external: bool): """ Calculate all tracked metrics for requirements and tests. @@ -188,6 +266,7 @@ def calculate_full_need_metrics(app: Sphinx, include_external: bool): "overall_metrics": overall_metrics, "metrics_by_type": metrics_by_type, "tests": test_metrics, + "verification_metrics": calculate_verification_metrics(all_needs), } # Save the metrics in a Global Variable to enable access from other parts. # Not a great solution but it is needed, as needpie filter functions for example From b581e4894de5d2fa4ff674e85bc5a8519b1869a4 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:08:06 +0000 Subject: [PATCH 13/26] feat(metrics): add version scoping to centralized verification metrics - Add version_filter parameter to calculate_status_and_coverage_metrics() implementing the same valid_from logic as the template's req_in_report_version - Update calculate_verification_metrics() to accept report_version parameter - Update filter functions to lookup version-specific metrics via verification_metrics_v{version} key with fallback to unversioned - Update template to pass module_version to filter functions This ensures centralized metrics match the version-scoped behavior of the original inline needpie filters. --- .../score_metrics/sphinx_filters.py | 28 +++++++- .../score_metrics/traceability_metrics.py | 65 ++++++++++++++++++- .../module_verification_report.need | 20 ++---- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index f927a1fca..58b2b7e49 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -257,14 +257,26 @@ def get_component_status_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" + - version: Optional version to filter by valid_from (default: no filtering) Returns [valid_count, valid_inspected_count, invalid_count]. """ results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) + version = kwargs.get("version") # Can be None or string + + # With versioning, metrics are stored under versioned keys + if version: + metrics_key = f"verification_metrics_v{version}" + else: + metrics_key = "verification_metrics" + + metrics = CALCULATED_METRICS.get(metrics_key, {}) + if not metrics: + # Fallback to unversioned metrics + metrics = CALCULATED_METRICS.get("verification_metrics", {}) - metrics = CALCULATED_METRICS.get("verification_metrics", {}) component_metrics = metrics.get(component_id, {}) category_metrics = component_metrics.get(category, {}) status = category_metrics.get("status", {}) @@ -284,14 +296,26 @@ def get_component_coverage_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" + - version: Optional version to filter by valid_from (default: no filtering) Returns [fully_covered, partially_covered, not_covered]. """ results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) + version = kwargs.get("version") # Can be None or string + + # With versioning, metrics are stored under versioned keys + if version: + metrics_key = f"verification_metrics_v{version}" + else: + metrics_key = "verification_metrics" + + metrics = CALCULATED_METRICS.get(metrics_key, {}) + if not metrics: + # Fallback to unversioned metrics + metrics = CALCULATED_METRICS.get("verification_metrics", {}) - metrics = CALCULATED_METRICS.get("verification_metrics", {}) component_metrics = metrics.get(component_id, {}) category_metrics = component_metrics.get(category, {}) coverage = category_metrics.get("coverage", {}) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 1bb91441a..35082e935 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -121,17 +121,66 @@ def calculate_test_metrics( def calculate_status_and_coverage_metrics( needs_list: list[NeedItem], + version_filter: str | None = None, + all_needs: NeedsView | None = None, ) -> dict[str, dict[str, int]]: """Calculate status distribution and test coverage for a list of needs. + Args: + needs_list: List of needs to calculate metrics for. + version_filter: Optional version to filter needs by valid_from. + all_needs: All needs view (required for version filtering of comp_req). + Returns a dict with: - status: {valid: int, valid_inspected: int, invalid: int} - coverage: {fully_covered: int, partially_covered: int, not_covered: int} """ + import re + + def parse_version(v: str) -> tuple[int, ...]: + """Parse version string like 'v1.2.3' into tuple (1, 2, 3).""" + match = re.match(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", v) + if not match: + raise ValueError(f"Invalid version: {v}") + return tuple(int(g or 0) for g in match.groups()) + + def req_in_version(need: NeedItem, version: str) -> bool: + """Check if a need is in scope for a given version.""" + if not version: + return True + + # Check direct valid_from + valid_from = need.get("valid_from") + if valid_from: + try: + return parse_version(valid_from) <= parse_version(version) + except ValueError: + return False + + # For comp_req, check derived_from feat_reqs + if need.get("type") == "comp_req" and all_needs: + derived_from = need.get("derived_from", []) + if isinstance(derived_from, str): + derived_from = [derived_from] + for feat_id in derived_from: + if feat_id in all_needs: + try: + if req_in_version(all_needs[feat_id], version): + return True + except (KeyError, ValueError): + pass + return False + + return True # No version info = always included + status_counts = {"valid": 0, "valid_inspected": 0, "invalid": 0} coverage_counts = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} for need in needs_list: + # Apply version filter + if version_filter and not req_in_version(need, version_filter): + continue + # Status distribution status = str(need.get("status", "")) if status in status_counts: @@ -151,9 +200,15 @@ def calculate_status_and_coverage_metrics( return {"status": status_counts, "coverage": coverage_counts} -def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: +def calculate_verification_metrics( + all_needs: NeedsView, report_version: str | None = None +) -> dict[str, dict]: """Calculate per-component verification metrics for verification reports. + Args: + all_needs: All needs view from Sphinx build. + report_version: Optional version to filter requirements by valid_from. + Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. """ @@ -178,7 +233,9 @@ def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: if n.get("type") == "comp_req" and component_id in n.get("satisfied_by", []) ] - req_metrics = calculate_status_and_coverage_metrics(reqs) + req_metrics = calculate_status_and_coverage_metrics( + reqs, version_filter=report_version, all_needs=all_needs + ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) arch = [ @@ -187,7 +244,9 @@ def calculate_verification_metrics(all_needs: NeedsView) -> dict[str, dict]: if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") and component_id in n.get("belongs_to", []) ] - arch_metrics = calculate_status_and_coverage_metrics(arch) + arch_metrics = calculate_status_and_coverage_metrics( + arch, version_filter=report_version, all_needs=all_needs + ) verification_metrics[component_id] = { "requirements": req_metrics, diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 80377f442..42442a400 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -160,10 +160,7 @@ Component Requirements Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid" and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid_inspected" and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid" and {{ component_req_filter }} + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") .. grid-item:: @@ -171,10 +168,7 @@ Component Requirements Statistics :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 and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ component_req_filter }} - type == "comp_req" and "{{ component_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ component_req_filter }} + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -188,10 +182,7 @@ Component Architecture Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #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 == "valid_inspected" - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and status == "invalid" + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") .. grid-item:: @@ -199,10 +190,7 @@ Component Architecture Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and fully_verifies_back - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and partially_verifies_back and not fully_verifies_back - type in ["comp_arc_sta", "comp_arc_dyn"] and "{{ component_id }}" in belongs_to and not fully_verifies_back and not partially_verifies_back + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From bbd03e8b897cf1d686ea5e1b4af44281eeb44eb4 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:12:58 +0000 Subject: [PATCH 14/26] fix(metrics): handle string-or-list for satisfied_by and belongs_to Sphinx-Needs may store single-value links as strings instead of lists. Ensure consistent list handling when checking component membership. --- .../score_metrics/traceability_metrics.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 35082e935..1f3da01f9 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -227,23 +227,29 @@ def calculate_verification_metrics( # Calculate metrics per component for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) - reqs = [ - n - for n in all_needs.values() - if n.get("type") == "comp_req" - and component_id in n.get("satisfied_by", []) - ] + reqs = [] + for n in all_needs.values(): + if n.get("type") != "comp_req": + continue + sat_by = n.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + if component_id in sat_by: + reqs.append(n) req_metrics = calculate_status_and_coverage_metrics( reqs, version_filter=report_version, all_needs=all_needs ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) - arch = [ - n - for n in all_needs.values() - if n.get("type") in ("comp_arc_sta", "comp_arc_dyn") - and component_id in n.get("belongs_to", []) - ] + arch = [] + for n in all_needs.values(): + if n.get("type") not in ("comp_arc_sta", "comp_arc_dyn"): + continue + bel_to = n.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + if component_id in bel_to: + arch.append(n) arch_metrics = calculate_status_and_coverage_metrics( arch, version_filter=report_version, all_needs=all_needs ) From e8513318c533b125c091ce553dc217d393236bac Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:24:36 +0000 Subject: [PATCH 15/26] docs: document metrics.json content including verification_metrics Add a section describing the structure of metrics.json output, including the new verification_metrics section for per-component status and coverage data used by verification reports. --- docs/how-to/dashboards_and_quality_gates.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/how-to/dashboards_and_quality_gates.rst b/docs/how-to/dashboards_and_quality_gates.rst index 21f828ec3..6f9dba5af 100644 --- a/docs/how-to/dashboards_and_quality_gates.rst +++ b/docs/how-to/dashboards_and_quality_gates.rst @@ -76,6 +76,21 @@ The documentation build writes ``metrics.json`` via ``score_metrics``, and the ` The dashboard charts and the CI gate both use the same computed metrics. +``metrics.json`` Content +~~~~~~~~~~~~~~~~~~~~~~~~ + +The exported file contains a versioned set of fields: + +- ``schema_version`` – schema version identifier. +- ``generated_by`` – identifier of the producer that generated this file. +- ``overall_metrics`` – requirement coverage aggregated over all requirement types. +- ``metrics_by_type`` – the same coverage numbers per requirement type. +- ``tests`` – testcase linkage statistics, including broken test references. +- ``verification_metrics`` – per-component verification status and coverage metrics + for use by verification reports. Contains status distribution (valid, valid_inspected, + invalid) and test coverage (fully_covered, partially_covered, not_covered) for + both requirements and architecture elements, organized by component ID. + Inputs for Linkage Metrics -------------------------- From be88d9307914e6085d448f12b3c80c29b70fe225 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 12:30:46 +0000 Subject: [PATCH 16/26] style: fix lint and type issues for centralized metrics - Add noqa: C901 for complex functions (inherent complexity from version filtering) - Add proper type hints for verification_metrics return type - Apply ruff-format fixes --- .../score_metrics/sphinx_filters.py | 24 +++++++++++-------- .../score_metrics/traceability_metrics.py | 8 +++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 58b2b7e49..9e7dab060 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -281,11 +281,13 @@ def get_component_status_metrics( category_metrics = component_metrics.get(category, {}) status = category_metrics.get("status", {}) - results.extend([ - status.get("valid", 0), - status.get("valid_inspected", 0), - status.get("invalid", 0), - ]) + results.extend( + [ + status.get("valid", 0), + status.get("valid_inspected", 0), + status.get("invalid", 0), + ] + ) def get_component_coverage_metrics( @@ -320,8 +322,10 @@ def get_component_coverage_metrics( category_metrics = component_metrics.get(category, {}) coverage = category_metrics.get("coverage", {}) - results.extend([ - coverage.get("fully_covered", 0), - coverage.get("partially_covered", 0), - coverage.get("not_covered", 0), - ]) + results.extend( + [ + coverage.get("fully_covered", 0), + coverage.get("partially_covered", 0), + coverage.get("not_covered", 0), + ] + ) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 1f3da01f9..5789b8bd1 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -119,7 +119,7 @@ def calculate_test_metrics( } -def calculate_status_and_coverage_metrics( +def calculate_status_and_coverage_metrics( # noqa: C901 needs_list: list[NeedItem], version_filter: str | None = None, all_needs: NeedsView | None = None, @@ -200,9 +200,9 @@ def req_in_version(need: NeedItem, version: str) -> bool: return {"status": status_counts, "coverage": coverage_counts} -def calculate_verification_metrics( +def calculate_verification_metrics( # noqa: C901 all_needs: NeedsView, report_version: str | None = None -) -> dict[str, dict]: +) -> dict[str, dict[str, dict[str, dict[str, int]]]]: """Calculate per-component verification metrics for verification reports. Args: @@ -212,7 +212,7 @@ def calculate_verification_metrics( Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. """ - verification_metrics: dict[str, dict] = {} + verification_metrics: dict[str, dict[str, dict[str, dict[str, int]]]] = {} # Find all components by looking at belongs_to links component_ids: set[str] = set() From af9e72817fdead63d0575d3ad73c9bfa935e49fa Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 14:37:32 +0000 Subject: [PATCH 17/26] feat: make version-scoping helpers public for reuse - Make parse_version, RequirementInReportVersion, and any_req_in_report_version public APIs in score_sphinx_needs_templates - Update sphinx_filters.py to use public req_in_report_version - Fix version scoping in verification report pie charts to properly filter requirements by valid_from when applies_to_module_version is set - Add helper functions to reduce complexity in filter functions - Update tests to use public API instead of vars()[...] access - Add documentation note about duplicate version logic in traceability_metrics Fixes issue where Component Requirements Status/Test Coverage pie charts showed ALL requirements instead of only those in scope for a report version. --- .../score_metrics/sphinx_filters.py | 106 ++++++++++++++---- .../score_metrics/traceability_metrics.py | 7 ++ .../score_sphinx_needs_templates/__init__.py | 28 ++++- .../tests/test_needs_templates.py | 51 ++++----- 4 files changed, 133 insertions(+), 59 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 9e7dab060..938652ddc 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -42,6 +42,9 @@ def func(needs: list[NeedItem], results: list[int], **kwargs) -> None: ... from typing import Any from score_metrics.traceability_metrics import CALCULATED_METRICS + +# Import the public version-scoping helper for filtering by valid_from +from score_sphinx_needs_templates import req_in_report_version from sphinx_needs.need_item import NeedItem @@ -249,6 +252,71 @@ def get_non_overlapping_link_metrics( results.extend([remaining, only_test, only_code, fully]) +def _is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: + """Check if a need belongs to a component for a given category.""" + need_type = str(need.get("type", "")) + + if category == "requirements": + if need_type != "comp_req": + return False + sat_by = need.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + return component_id in sat_by + + # architecture + if need_type not in ("comp_arc_sta", "comp_arc_dyn"): + return False + bel_to = need.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + return component_id in bel_to + + +def _count_status_filtered( + needs: list[Any], component_id: str, category: str, version: str +) -> dict[str, int]: + """Count status distribution with version filtering.""" + status = {"valid": 0, "valid_inspected": 0, "invalid": 0} + + for need in needs: + if not _is_need_in_component(need, component_id, category): + continue + if not req_in_report_version(need, version): + continue + + need_status = str(need.get("status", "")) + if need_status in status: + status[need_status] += 1 + + return status + + +def _count_coverage_filtered( + needs: list[Any], component_id: str, category: str, version: str +) -> dict[str, int]: + """Count test coverage with version filtering.""" + coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs: + if not _is_need_in_component(need, component_id, category): + continue + if not req_in_report_version(need, version): + continue + + has_testlink = bool(need.get("testlink")) + has_code_link = bool(need.get("source_code_link")) + + if has_testlink and has_code_link: + coverage["fully_covered"] += 1 + elif has_testlink or has_code_link: + coverage["partially_covered"] += 1 + else: + coverage["not_covered"] += 1 + + return coverage + + def get_component_status_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: @@ -264,22 +332,17 @@ def get_component_status_metrics( results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") # Can be None or string + version = kwargs.get("version") - # With versioning, metrics are stored under versioned keys + # If version is specified, filter on-the-fly using the version-scoping helper if version: - metrics_key = f"verification_metrics_v{version}" + status = _count_status_filtered(needs, component_id, category, version) else: - metrics_key = "verification_metrics" - - metrics = CALCULATED_METRICS.get(metrics_key, {}) - if not metrics: - # Fallback to unversioned metrics + # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) - - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - status = category_metrics.get("status", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + status = category_metrics.get("status", {}) results.extend( [ @@ -305,22 +368,17 @@ def get_component_coverage_metrics( results.clear() component_id = str(kwargs.get("component_id", "")) category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") # Can be None or string + version = kwargs.get("version") - # With versioning, metrics are stored under versioned keys + # If version is specified, filter on-the-fly using the version-scoping helper if version: - metrics_key = f"verification_metrics_v{version}" + coverage = _count_coverage_filtered(needs, component_id, category, version) else: - metrics_key = "verification_metrics" - - metrics = CALCULATED_METRICS.get(metrics_key, {}) - if not metrics: - # Fallback to unversioned metrics + # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) - - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - coverage = category_metrics.get("coverage", {}) + component_metrics = metrics.get(component_id, {}) + category_metrics = component_metrics.get(category, {}) + coverage = category_metrics.get("coverage", {}) results.extend( [ diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 5789b8bd1..211ad5c04 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -134,6 +134,13 @@ def calculate_status_and_coverage_metrics( # noqa: C901 Returns a dict with: - status: {valid: int, valid_inspected: int, invalid: int} - coverage: {fully_covered: int, partially_covered: int, not_covered: int} + + Note: + The version filtering logic here duplicates ``req_in_report_version`` + from ``score_sphinx_needs_templates``. We keep it separate because + this function operates during the build phase with an explicit + ``all_needs`` parameter, while ``req_in_report_version`` relies on + Sphinx environment state during template rendering. """ import re diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 806adb609..1d37c0d6d 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -178,12 +178,14 @@ def __call__(self, need_type: str) -> list[NeedItem]: _needs_of_type_callable = _NeedsOfType() -def _parse_version(value: str) -> tuple[int, int, int]: +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``. + + Public API for use by other extensions that need version comparison. """ numbers = [int(part) for part in value.strip().lstrip("vV").split(".")] while len(numbers) < 3: @@ -191,7 +193,11 @@ def _parse_version(value: str) -> tuple[int, int, int]: return (numbers[0], numbers[1], numbers[2]) -class _RequirementInReportVersion: +# Keep private alias for backwards compatibility with internal code +_parse_version = parse_version + + +class RequirementInReportVersion: """Decide whether a requirement Need belongs to a ``report_version`` scope. ``feat_req`` (and ``stkh_req``) carry ``valid_from`` directly. ``comp_req`` @@ -205,6 +211,8 @@ class _RequirementInReportVersion: Calling with an empty/``None`` ``report_version`` always returns ``True``, which keeps unscoped reports (e.g. "latest") showing every requirement as before. + + Public API for use by other extensions that need version-scoped filtering. """ def __call__(self, need: NeedItem, report_version: str | None) -> bool: @@ -222,10 +230,14 @@ def __call__(self, need: NeedItem, report_version: str | None) -> bool: return any(self(feat_req, report_version) for feat_req in linked_feat_reqs) -_req_in_report_version_callable = _RequirementInReportVersion() +# Public instance for direct import +req_in_report_version = RequirementInReportVersion() +# Private alias for backwards compatibility with internal code +_req_in_report_version_callable = req_in_report_version -class _AnyRequirementInReportVersion: + +class AnyRequirementInReportVersion: """Decide whether a Feature/Component has any requirement in scope. Used to drop an entire Feature/Component section from the report when @@ -233,6 +245,8 @@ class _AnyRequirementInReportVersion: 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). + + Public API for use by other extensions that need version-scoped filtering. """ def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: @@ -241,7 +255,11 @@ def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool: return any(_req_in_report_version_callable(req, report_version) for req in reqs) -_any_req_in_report_version_callable = _AnyRequirementInReportVersion() +# Public instance for direct import +any_req_in_report_version = AnyRequirementInReportVersion() + +# Private alias for backwards compatibility with Jinja context +_any_req_in_report_version_callable = any_req_in_report_version class _ReferencedIds: 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 bddb56735..266ddc2ed 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 @@ -31,7 +31,11 @@ def to_link_string(self) -> str: class FakeNeed(dict[str, str]): - """Need-shaped test double with independently controlled link indexes.""" + """Need-shaped test double with independently controlled link indexes. + + Note: This is a simplified test double that doesn't fully implement NeedItem. + Tests using this class with typed APIs may need type: ignore comments. + """ def __init__( self, @@ -74,53 +78,47 @@ def test_backlinks_merge_indexed_and_new_outgoing_links( 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) + assert templates.parse_version("v0.8") == (0, 8, 0) + assert templates.parse_version("v1.0.1") == (1, 0, 1) + assert templates.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 + assert templates.req_in_report_version(need, None) is True # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "") is True # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(need, "v0.7") is False # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "v0.8") is True # type: ignore[reportArgumentType] + assert templates.req_in_report_version(need, "v0.9") is True # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(need, "v1.0") is False # type: ignore[reportArgumentType] 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 + assert templates.req_in_report_version(comp_req, "v1.0") is False # type: ignore[reportArgumentType] def test_req_in_report_version_comp_req_inherits_via_derived_from( @@ -128,49 +126,42 @@ def test_req_in_report_version_comp_req_inherits_via_derived_from( ) -> 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 + assert templates.req_in_report_version(comp_req, "v0.7") is False # type: ignore[reportArgumentType] + assert templates.req_in_report_version(comp_req, "v0.8") is True # type: ignore[reportArgumentType] 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 + assert templates.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 + assert templates.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 + assert templates.any_req_in_report_version([out_of_scope, in_scope], "v1.0") is True # type: ignore[reportArgumentType] 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 + assert templates.any_req_in_report_version([a, b], "v1.0") is False # type: ignore[reportArgumentType] def test_tool_qualification_matrix_groups_shared_requirements( From 2991bf92ad1c1d8cf209c87c910236058d0cd93e Mon Sep 17 00:00:00 2001 From: FScholPer Date: Tue, 13 Oct 2026 15:01:36 +0000 Subject: [PATCH 18/26] fix: add missing Bazel dependency on score_sphinx_needs_templates score_metrics imports req_in_report_version from score_sphinx_needs_templates, but this dependency wasn't declared in the BUILD file, causing ModuleNotFoundError in score_metamodel tests. --- src/extensions/score_metrics/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/score_metrics/BUILD b/src/extensions/score_metrics/BUILD index c909fe7ab..c08e1ef20 100644 --- a/src/extensions/score_metrics/BUILD +++ b/src/extensions/score_metrics/BUILD @@ -39,6 +39,7 @@ py_library( # TODO: Figure out if all requirements are needed or if we can break it down a bit deps = all_requirements + [ "@score_docs_as_code//src/helper_lib", + "@score_docs_as_code//src/extensions/score_sphinx_needs_templates", ], ) From 24d3683915d8b89c89a574b91d1f806073a438a1 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:23:17 +0000 Subject: [PATCH 19/26] move to the helpers --- .../score_metrics/sphinx_filters.py | 88 +++-------- .../score_metrics/traceability_metrics.py | 65 +++++---- src/helper_lib/verification_metrics.py | 138 ++++++++++++++++++ 3 files changed, 194 insertions(+), 97 deletions(-) create mode 100644 src/helper_lib/verification_metrics.py diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 938652ddc..cef2b09f7 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -47,6 +47,12 @@ def func(needs: list[NeedItem], results: list[int], **kwargs) -> None: ... from score_sphinx_needs_templates import req_in_report_version from sphinx_needs.need_item import NeedItem +# Import shared verification metrics helpers +from src.helper_lib.verification_metrics import ( + count_coverage_filtered, + count_status_filtered, +) + def _matches_source_selector(need: NeedItem, selector: str) -> bool: """Return whether a need matches a source selector. @@ -252,71 +258,6 @@ def get_non_overlapping_link_metrics( results.extend([remaining, only_test, only_code, fully]) -def _is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: - """Check if a need belongs to a component for a given category.""" - need_type = str(need.get("type", "")) - - if category == "requirements": - if need_type != "comp_req": - return False - sat_by = need.get("satisfied_by", []) - if isinstance(sat_by, str): - sat_by = [sat_by] - return component_id in sat_by - - # architecture - if need_type not in ("comp_arc_sta", "comp_arc_dyn"): - return False - bel_to = need.get("belongs_to", []) - if isinstance(bel_to, str): - bel_to = [bel_to] - return component_id in bel_to - - -def _count_status_filtered( - needs: list[Any], component_id: str, category: str, version: str -) -> dict[str, int]: - """Count status distribution with version filtering.""" - status = {"valid": 0, "valid_inspected": 0, "invalid": 0} - - for need in needs: - if not _is_need_in_component(need, component_id, category): - continue - if not req_in_report_version(need, version): - continue - - need_status = str(need.get("status", "")) - if need_status in status: - status[need_status] += 1 - - return status - - -def _count_coverage_filtered( - needs: list[Any], component_id: str, category: str, version: str -) -> dict[str, int]: - """Count test coverage with version filtering.""" - coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} - - for need in needs: - if not _is_need_in_component(need, component_id, category): - continue - if not req_in_report_version(need, version): - continue - - has_testlink = bool(need.get("testlink")) - has_code_link = bool(need.get("source_code_link")) - - if has_testlink and has_code_link: - coverage["fully_covered"] += 1 - elif has_testlink or has_code_link: - coverage["partially_covered"] += 1 - else: - coverage["not_covered"] += 1 - - return coverage - - def get_component_status_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: @@ -325,7 +266,7 @@ def get_component_status_metrics( Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (default: no filtering) + - version: Optional version to filter by valid_from (only applied to requirements) Returns [valid_count, valid_inspected_count, invalid_count]. """ @@ -336,7 +277,9 @@ def get_component_status_metrics( # If version is specified, filter on-the-fly using the version-scoping helper if version: - status = _count_status_filtered(needs, component_id, category, version) + status = count_status_filtered( + needs, component_id, category, version, req_in_report_version + ) else: # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) @@ -356,12 +299,15 @@ def get_component_status_metrics( def get_component_coverage_metrics( needs: list[Any], results: list[int], **kwargs: Any ) -> None: - """Get test coverage for a component's requirements or architecture. + """Get verification coverage for a component's requirements or architecture. + + Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), + not the link coverage axis (testlink / source_code_link). Arguments via kwargs: - component_id: The component ID (e.g., "comp__my_component") - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (default: no filtering) + - version: Optional version to filter by valid_from (only applied to requirements) Returns [fully_covered, partially_covered, not_covered]. """ @@ -372,7 +318,9 @@ def get_component_coverage_metrics( # If version is specified, filter on-the-fly using the version-scoping helper if version: - coverage = _count_coverage_filtered(needs, component_id, category, version) + coverage = count_coverage_filtered( + needs, component_id, category, version, req_in_report_version + ) else: # Use pre-calculated metrics from CALCULATED_METRICS metrics = CALCULATED_METRICS.get("verification_metrics", {}) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 211ad5c04..2001cffc1 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -28,6 +28,13 @@ from sphinx_needs.data import NeedsView, SphinxNeedsData from sphinx_needs.need_item import NeedItem +# Import shared verification metrics helpers +from src.helper_lib.verification_metrics import ( + count_coverage_filtered, + count_status_filtered, + is_need_in_component, +) + CALCULATED_METRICS: dict[str, Any] = {} @@ -136,11 +143,9 @@ def calculate_status_and_coverage_metrics( # noqa: C901 - coverage: {fully_covered: int, partially_covered: int, not_covered: int} Note: - The version filtering logic here duplicates ``req_in_report_version`` - from ``score_sphinx_needs_templates``. We keep it separate because - this function operates during the build phase with an explicit - ``all_needs`` parameter, while ``req_in_report_version`` relies on - Sphinx environment state during template rendering. + For component-specific metrics with version filtering, prefer using + the shared helpers from helper_lib.verification_metrics which handle + architecture version filtering correctly. """ import re @@ -215,6 +220,8 @@ def calculate_verification_metrics( # noqa: C901 Args: all_needs: All needs view from Sphinx build. report_version: Optional version to filter requirements by valid_from. + Note: Architecture elements are NOT version-filtered as they have + no valid_from or derived_from attributes. Looks for needs that have belongs_to links to components and calculates status/coverage metrics for requirements and architecture elements. @@ -231,34 +238,38 @@ def calculate_verification_metrics( # noqa: C901 elif isinstance(belongs_to, str): component_ids.add(belongs_to) + # Simple version filter function for use with shared helpers + def version_filter_func(need: NeedItem, version: str) -> bool: + """Simple passthrough to req_in_report_version for shared helpers.""" + from score_sphinx_needs_templates import req_in_report_version + + return req_in_report_version(need, version) + # Calculate metrics per component for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) - reqs = [] - for n in all_needs.values(): - if n.get("type") != "comp_req": - continue - sat_by = n.get("satisfied_by", []) - if isinstance(sat_by, str): - sat_by = [sat_by] - if component_id in sat_by: - reqs.append(n) - req_metrics = calculate_status_and_coverage_metrics( - reqs, version_filter=report_version, all_needs=all_needs - ) + # Version filtering is applied to requirements + reqs = [n for n in all_needs.values() if is_need_in_component(n, component_id, "requirements")] + + if report_version: + req_metrics = { + "status": count_status_filtered( + reqs, component_id, "requirements", report_version, version_filter_func + ), + "coverage": count_coverage_filtered( + reqs, component_id, "requirements", report_version, version_filter_func + ), + } + else: + req_metrics = calculate_status_and_coverage_metrics( + reqs, version_filter=None, all_needs=all_needs + ) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) - arch = [] - for n in all_needs.values(): - if n.get("type") not in ("comp_arc_sta", "comp_arc_dyn"): - continue - bel_to = n.get("belongs_to", []) - if isinstance(bel_to, str): - bel_to = [bel_to] - if component_id in bel_to: - arch.append(n) + # Version filtering is NOT applied to architecture elements + arch = [n for n in all_needs.values() if is_need_in_component(n, component_id, "architecture")] arch_metrics = calculate_status_and_coverage_metrics( - arch, version_filter=report_version, all_needs=all_needs + arch, version_filter=None, all_needs=all_needs ) verification_metrics[component_id] = { diff --git a/src/helper_lib/verification_metrics.py b/src/helper_lib/verification_metrics.py new file mode 100644 index 000000000..a52fa472c --- /dev/null +++ b/src/helper_lib/verification_metrics.py @@ -0,0 +1,138 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Shared verification metrics helpers for use in both metrics calculation and filtering. + +These helpers are used by: +- traceability_metrics.py for pre-calculating verification_metrics in metrics.json +- sphinx_filters.py for on-the-fly filtering in needpie directives +""" + +from typing import Any + +from sphinx_needs.need_item import NeedItem + + +def is_need_in_component(need: NeedItem, component_id: str, category: str) -> bool: + """Check if a need belongs to a component for a given category. + + Args: + need: The need item to check. + component_id: The component ID (e.g., "comp__my_component"). + category: Either "requirements" or "architecture". + + Returns: + True if the need belongs to the component for that category. + """ + need_type = str(need.get("type", "")) + + if category == "requirements": + if need_type != "comp_req": + return False + sat_by = need.get("satisfied_by", []) + if isinstance(sat_by, str): + sat_by = [sat_by] + return component_id in sat_by + + # architecture + if need_type not in ("comp_arc_sta", "comp_arc_dyn"): + return False + bel_to = need.get("belongs_to", []) + if isinstance(bel_to, str): + bel_to = [bel_to] + return component_id in bel_to + + +def count_status_filtered( + needs: list[Any], + component_id: str, + category: str, + version: str, + version_filter_func: Any, +) -> dict[str, int]: + """Count status distribution with version filtering. + + Args: + needs: List of need items. + component_id: The component ID. + category: Either "requirements" or "architecture". + version: Version string for filtering (only applied to requirements). + version_filter_func: Callable like req_in_report_version(need, version). + + Returns: + Dict with status counts: {valid: int, valid_inspected: int, invalid: int} + """ + status = {"valid": 0, "valid_inspected": 0, "invalid": 0} + + for need in needs: + if not is_need_in_component(need, component_id, category): + continue + + # Architecture elements should not be version-filtered + # (they have no valid_from or derived_from attributes) + if category == "requirements" and version: + if not version_filter_func(need, version): + continue + + need_status = str(need.get("status", "")) + if need_status in status: + status[need_status] += 1 + + return status + + +def count_coverage_filtered( + needs: list[Any], + component_id: str, + category: str, + version: str, + version_filter_func: Any, +) -> dict[str, int]: + """Count verification coverage with version filtering. + + Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), + not the link coverage axis (testlink / source_code_link). + + Args: + needs: List of need items. + component_id: The component ID. + category: Either "requirements" or "architecture". + version: Version string for filtering (only applied to requirements). + version_filter_func: Callable like req_in_report_version(need, version). + + Returns: + Dict with coverage counts: {fully_covered: int, partially_covered: int, not_covered: int} + """ + coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} + + for need in needs: + if not is_need_in_component(need, component_id, category): + continue + + # Architecture elements should not be version-filtered + if category == "requirements" and version: + if not version_filter_func(need, version): + continue + + # Use verification coverage axis (not link coverage axis) + has_fully = bool(need.get("fully_verifies_back")) + has_partially = bool(need.get("partially_verifies_back")) + + if has_fully: + coverage["fully_covered"] += 1 + elif has_partially: + coverage["partially_covered"] += 1 + else: + coverage["not_covered"] += 1 + + return coverage From e4c1b62119c40277528e332f68d767aa8693b7c2 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:51:49 +0000 Subject: [PATCH 20/26] precommit fix --- .../score_metrics/traceability_metrics.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 2001cffc1..91ed9ee84 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -249,15 +249,27 @@ def version_filter_func(need: NeedItem, version: str) -> bool: for component_id in component_ids: # Requirements (comp_req with satisfied_by pointing to this component) # Version filtering is applied to requirements - reqs = [n for n in all_needs.values() if is_need_in_component(n, component_id, "requirements")] + reqs = [ + n + for n in all_needs.values() + if is_need_in_component(n, component_id, "requirements") + ] if report_version: req_metrics = { "status": count_status_filtered( - reqs, component_id, "requirements", report_version, version_filter_func + reqs, + component_id, + "requirements", + report_version, + version_filter_func, ), "coverage": count_coverage_filtered( - reqs, component_id, "requirements", report_version, version_filter_func + reqs, + component_id, + "requirements", + report_version, + version_filter_func, ), } else: @@ -267,7 +279,11 @@ def version_filter_func(need: NeedItem, version: str) -> bool: # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) # Version filtering is NOT applied to architecture elements - arch = [n for n in all_needs.values() if is_need_in_component(n, component_id, "architecture")] + arch = [ + n + for n in all_needs.values() + if is_need_in_component(n, component_id, "architecture") + ] arch_metrics = calculate_status_and_coverage_metrics( arch, version_filter=None, all_needs=all_needs ) From 271fc66690d022a7f02fd68acee04ac3f1faf6d7 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 11:54:07 +0000 Subject: [PATCH 21/26] fix lint --- src/helper_lib/verification_metrics.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/helper_lib/verification_metrics.py b/src/helper_lib/verification_metrics.py index a52fa472c..9f7beb1e5 100644 --- a/src/helper_lib/verification_metrics.py +++ b/src/helper_lib/verification_metrics.py @@ -80,9 +80,12 @@ def count_status_filtered( # Architecture elements should not be version-filtered # (they have no valid_from or derived_from attributes) - if category == "requirements" and version: - if not version_filter_func(need, version): - continue + if ( + category == "requirements" + and version + and not version_filter_func(need, version) + ): + continue need_status = str(need.get("status", "")) if need_status in status: @@ -120,9 +123,13 @@ def count_coverage_filtered( continue # Architecture elements should not be version-filtered - if category == "requirements" and version: - if not version_filter_func(need, version): - continue + # (they have no valid_from or derived_from attributes) + if ( + category == "requirements" + and version + and not version_filter_func(need, version) + ): + continue # Use verification coverage axis (not link coverage axis) has_fully = bool(need.get("fully_verifies_back")) From 747459b926a70d420e06381f3579068ac6dc95c8 Mon Sep 17 00:00:00 2001 From: FScholPer Date: Wed, 14 Oct 2026 12:20:16 +0000 Subject: [PATCH 22/26] copillot findings --- .../score_metrics/sphinx_filters.py | 28 +++++++++---------- .../score_metrics/traceability_metrics.py | 13 +++------ .../module_verification_report.need | 8 +++--- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index cef2b09f7..b567e4792 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -263,17 +263,17 @@ def get_component_status_metrics( ) -> None: """Get status distribution for a component's requirements or architecture. - Arguments via kwargs: - - component_id: The component ID (e.g., "comp__my_component") - - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (only applied to requirements) + Arguments are passed via ``arg1``, ``arg2``, ``arg3``: + - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") + - ``arg2`` – category: Either "requirements" or "architecture" + - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) Returns [valid_count, valid_inspected_count, invalid_count]. """ results.clear() - component_id = str(kwargs.get("component_id", "")) - category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") + component_id = str(kwargs.get("arg1", "")) + category = str(kwargs.get("arg2", "requirements")) + version = kwargs.get("arg3") # If version is specified, filter on-the-fly using the version-scoping helper if version: @@ -304,17 +304,17 @@ def get_component_coverage_metrics( Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), not the link coverage axis (testlink / source_code_link). - Arguments via kwargs: - - component_id: The component ID (e.g., "comp__my_component") - - category: Either "requirements" or "architecture" - - version: Optional version to filter by valid_from (only applied to requirements) + Arguments are passed via ``arg1``, ``arg2``, ``arg3``: + - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") + - ``arg2`` – category: Either "requirements" or "architecture" + - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) Returns [fully_covered, partially_covered, not_covered]. """ results.clear() - component_id = str(kwargs.get("component_id", "")) - category = str(kwargs.get("category", "requirements")) - version = kwargs.get("version") + component_id = str(kwargs.get("arg1", "")) + category = str(kwargs.get("arg2", "requirements")) + version = kwargs.get("arg3") # If version is specified, filter on-the-fly using the version-scoping helper if version: diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index 91ed9ee84..e659312eb 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -228,15 +228,10 @@ def calculate_verification_metrics( # noqa: C901 """ verification_metrics: dict[str, dict[str, dict[str, dict[str, int]]]] = {} - # Find all components by looking at belongs_to links - component_ids: set[str] = set() - for need in all_needs.values(): - belongs_to = need.get("belongs_to") - if belongs_to: - if isinstance(belongs_to, list): - component_ids.update(belongs_to) - elif isinstance(belongs_to, str): - component_ids.add(belongs_to) + # Find all components by looking at Needs with type "comp" + component_ids: set[str] = { + n["id"] for n in all_needs.values() if n.get("type") == "comp" + } # Simple version filter function for use with shared helpers def version_filter_func(need: NeedItem, version: str) -> bool: diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 42442a400..83dcddfa2 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -160,7 +160,7 @@ Component Requirements Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") .. grid-item:: @@ -168,7 +168,7 @@ Component Requirements Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="requirements", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -182,7 +182,7 @@ Component Architecture Statistics :labels: valid, valid_inspected, invalid :colors: #f5c518, #37a12d, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") .. grid-item:: @@ -190,7 +190,7 @@ Component Architecture Statistics :labels: fully covered, partially covered, not covered :colors: #37a12d, #f0a500, #ca2828 :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics(component_id="{{ component_id }}", category="architecture", version="{{ module_version or '' }}") + :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From 611ff5c1e0418a18b3cf5e12d8b5cb214742192c Mon Sep 17 00:00:00 2001 From: FScholPer Date: Fri, 16 Oct 2026 07:10:57 +0000 Subject: [PATCH 23/26] fix centralization --- .../score_sphinx_needs_templates/tests/test_needs_templates.py | 2 -- 1 file changed, 2 deletions(-) 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 465ddafe4..b00580225 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 @@ -199,7 +199,6 @@ def test_any_req_in_report_version_scoped_empty_list_is_excluded() -> None: ) def test_any_req_in_report_version_true_if_at_least_one_requirement_matches() -> None: """Test any req in report version true if at least one requirement matches.""" - 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") @@ -215,7 +214,6 @@ def test_any_req_in_report_version_true_if_at_least_one_requirement_matches() -> ) def test_any_req_in_report_version_false_if_no_requirement_matches() -> None: """Test any req in report version false if no requirement matches.""" - any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]() a = FakeNeed("feat_req__a") a["valid_from"] = "v2.0" b = FakeNeed("feat_req__b") From 481318ccaf3b059c4323563dd66320cbf48e0763 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 08:32:44 +0000 Subject: [PATCH 24/26] fix: restore the ASIL/QM module report and decode filter-func arguments The merge of main (a539af8) kept this branch's module_verification_report.need and thereby dropped the ASIL B/QM split merged in #946. Restore main's template; the central metrics stay available but are no longer wired into it. sphinx-needs passes filter-func arguments verbatim after splitting on ",", so the quoted arguments from the template arrived as '"comp__x"' and every pie counted 0. Strip whitespace and quotes. Arguments still must not contain a dot: sphinx-needs resolves the module with rsplit(".", 1) over the whole call, so a version such as v1.0 breaks it. Documented and covered by a test. Also drop the unused version_filter path of calculate_status_and_coverage_metrics, which duplicated req_in_report_version. --- .../score_metrics/sphinx_filters.py | 27 ++- .../tests/test_sphinx_filters.py | 104 +++++++++++- .../score_metrics/traceability_metrics.py | 64 +------ .../module_verification_report.need | 158 ++++++++++-------- 4 files changed, 218 insertions(+), 135 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index b567e4792..58e6c576b 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -54,6 +54,15 @@ def func(needs: list[NeedItem], results: list[int], **kwargs) -> None: ... ) +def _arg(kwargs: dict[str, Any], name: str, default: str = "") -> str: + """Return a filter-func argument without surrounding whitespace and quotes. + + sphinx-needs splits the argument string on ``,`` and passes the parts + verbatim, so ``f("a", "b")`` arrives as ``'"a"'`` and ``' "b"'``. + """ + return str(kwargs.get(name, default)).strip().strip("\"'") + + def _matches_source_selector(need: NeedItem, selector: str) -> bool: """Return whether a need matches a source selector. @@ -268,12 +277,15 @@ def get_component_status_metrics( - ``arg2`` – category: Either "requirements" or "architecture" - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) + sphinx-needs resolves the module path with ``rsplit(".", 1)`` over the + whole call, so no argument may contain a dot (``v1.0`` breaks the call). + Returns [valid_count, valid_inspected_count, invalid_count]. """ results.clear() - component_id = str(kwargs.get("arg1", "")) - category = str(kwargs.get("arg2", "requirements")) - version = kwargs.get("arg3") + component_id = _arg(kwargs, "arg1") + category = _arg(kwargs, "arg2", "requirements") + version = _arg(kwargs, "arg3") or None # If version is specified, filter on-the-fly using the version-scoping helper if version: @@ -309,12 +321,15 @@ def get_component_coverage_metrics( - ``arg2`` – category: Either "requirements" or "architecture" - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) + sphinx-needs resolves the module path with ``rsplit(".", 1)`` over the + whole call, so no argument may contain a dot (``v1.0`` breaks the call). + Returns [fully_covered, partially_covered, not_covered]. """ results.clear() - component_id = str(kwargs.get("arg1", "")) - category = str(kwargs.get("arg2", "requirements")) - version = kwargs.get("arg3") + component_id = _arg(kwargs, "arg1") + category = _arg(kwargs, "arg2", "requirements") + version = _arg(kwargs, "arg3") or None # If version is specified, filter on-the-fly using the version-scoping helper if version: diff --git a/src/extensions/score_metrics/tests/test_sphinx_filters.py b/src/extensions/score_metrics/tests/test_sphinx_filters.py index e42fdf845..0eb00635a 100644 --- a/src/extensions/score_metrics/tests/test_sphinx_filters.py +++ b/src/extensions/score_metrics/tests/test_sphinx_filters.py @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -from typing import cast +from typing import Any, cast import pytest @@ -21,8 +21,11 @@ generic_pie_items_by_tag, generic_pie_linked_items, ) +from score_metrics.traceability_metrics import calculate_verification_metrics from score_pytest.attribute_plugin import add_test_properties +from sphinx_needs.exceptions import NeedsInvalidFilter from sphinx_needs.need_item import NeedItem +from sphinx_needs.utils import check_and_get_external_filter_func @add_test_properties( @@ -252,3 +255,102 @@ def test_get_metrics_with_custom_type_total_considered_empty_kwargs_raises_index with pytest.raises(IndexError): sphinx_filters.get_metrics_with_first_value_total(needs=[], results=results) + + +_COMPONENT_NEEDS = cast( + list[NeedItem], + [ + { + "id": "comp_req__x__in", + "type": "comp_req", + "satisfied_by": ["comp__x"], + "status": "valid", + "valid_from": "v1", + "fully_verifies_back": ["tc__a"], + "partially_verifies_back": [], + }, + { + "id": "comp_req__x__later", + "type": "comp_req", + "satisfied_by": ["comp__x"], + "status": "invalid", + "valid_from": "v2", + "fully_verifies_back": [], + "partially_verifies_back": [], + }, + { + "id": "comp_arc_sta__x__a", + "type": "comp_arc_sta", + "belongs_to": ["comp__x"], + "status": "valid", + "fully_verifies_back": [], + "partially_verifies_back": ["tc__b"], + }, + ], +) + + +def _call_like_needpie(signature: str, needs: list[NeedItem]) -> list[int]: + """Parse and call a filter-func exactly the way ``needpie`` does.""" + ff = check_and_get_external_filter_func(f"score_metrics.sphinx_filters.{signature}") + assert ff is not None + args = ff.args.split(",") if ff.args else [] + results: list[int] = [] + ff.func( + needs=needs, + results=results, + **{f"arg{index + 1}": arg for index, arg in enumerate(args)}, + ) + return results + + +@add_test_properties( + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_component_metrics_accept_quoted_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Quoted, space-separated arguments as written in a template are decoded.""" + all_needs = {n["id"]: n for n in _COMPONENT_NEEDS} + all_needs["comp__x"] = cast(NeedItem, {"id": "comp__x", "type": "comp"}) + monkeypatch.setattr( + sphinx_filters, + "CALCULATED_METRICS", + {"verification_metrics": calculate_verification_metrics(cast(Any, all_needs))}, + ) + + status = 'get_component_status_metrics("comp__x", "requirements", "")' + coverage = 'get_component_coverage_metrics("comp__x", "architecture", "")' + + assert _call_like_needpie(status, _COMPONENT_NEEDS) == [1, 0, 1] + assert _call_like_needpie(coverage, _COMPONENT_NEEDS) == [0, 1, 0] + + +@add_test_properties( + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_component_metrics_version_scopes_requirements_only() -> None: + """A version excludes later requirements but never architecture elements.""" + reqs = 'get_component_status_metrics("comp__x", "requirements", "v1")' + arcs = 'get_component_status_metrics("comp__x", "architecture", "v1")' + + assert _call_like_needpie(reqs, _COMPONENT_NEEDS) == [1, 0, 0] + assert _call_like_needpie(arcs, _COMPONENT_NEEDS) == [1, 0, 0] + + +@add_test_properties( + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_filter_func_arguments_must_not_contain_a_dot() -> None: + """sphinx-needs splits the call on its last dot, so ``v1.0`` cannot be passed.""" + with pytest.raises(NeedsInvalidFilter): + _call_like_needpie( + 'get_component_status_metrics("comp__x", "requirements", "v1.0")', + _COMPONENT_NEEDS, + ) diff --git a/src/extensions/score_metrics/traceability_metrics.py b/src/extensions/score_metrics/traceability_metrics.py index e659312eb..3dc5f0c0d 100644 --- a/src/extensions/score_metrics/traceability_metrics.py +++ b/src/extensions/score_metrics/traceability_metrics.py @@ -126,73 +126,21 @@ def calculate_test_metrics( } -def calculate_status_and_coverage_metrics( # noqa: C901 +def calculate_status_and_coverage_metrics( needs_list: list[NeedItem], - version_filter: str | None = None, - all_needs: NeedsView | None = None, ) -> dict[str, dict[str, int]]: """Calculate status distribution and test coverage for a list of needs. - Args: - needs_list: List of needs to calculate metrics for. - version_filter: Optional version to filter needs by valid_from. - all_needs: All needs view (required for version filtering of comp_req). + Version scoping is done by the callers via ``req_in_report_version``. Returns a dict with: - status: {valid: int, valid_inspected: int, invalid: int} - coverage: {fully_covered: int, partially_covered: int, not_covered: int} - - Note: - For component-specific metrics with version filtering, prefer using - the shared helpers from helper_lib.verification_metrics which handle - architecture version filtering correctly. """ - import re - - def parse_version(v: str) -> tuple[int, ...]: - """Parse version string like 'v1.2.3' into tuple (1, 2, 3).""" - match = re.match(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", v) - if not match: - raise ValueError(f"Invalid version: {v}") - return tuple(int(g or 0) for g in match.groups()) - - def req_in_version(need: NeedItem, version: str) -> bool: - """Check if a need is in scope for a given version.""" - if not version: - return True - - # Check direct valid_from - valid_from = need.get("valid_from") - if valid_from: - try: - return parse_version(valid_from) <= parse_version(version) - except ValueError: - return False - - # For comp_req, check derived_from feat_reqs - if need.get("type") == "comp_req" and all_needs: - derived_from = need.get("derived_from", []) - if isinstance(derived_from, str): - derived_from = [derived_from] - for feat_id in derived_from: - if feat_id in all_needs: - try: - if req_in_version(all_needs[feat_id], version): - return True - except (KeyError, ValueError): - pass - return False - - return True # No version info = always included - status_counts = {"valid": 0, "valid_inspected": 0, "invalid": 0} coverage_counts = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} for need in needs_list: - # Apply version filter - if version_filter and not req_in_version(need, version_filter): - continue - # Status distribution status = str(need.get("status", "")) if status in status_counts: @@ -268,9 +216,7 @@ def version_filter_func(need: NeedItem, version: str) -> bool: ), } else: - req_metrics = calculate_status_and_coverage_metrics( - reqs, version_filter=None, all_needs=all_needs - ) + req_metrics = calculate_status_and_coverage_metrics(reqs) # Architecture elements (comp_arc_sta, comp_arc_dyn with belongs_to this component) # Version filtering is NOT applied to architecture elements @@ -279,9 +225,7 @@ def version_filter_func(need: NeedItem, version: str) -> bool: for n in all_needs.values() if is_need_in_component(n, component_id, "architecture") ] - arch_metrics = calculate_status_and_coverage_metrics( - arch, version_filter=None, all_needs=all_needs - ) + arch_metrics = calculate_status_and_coverage_metrics(arch) verification_metrics[component_id] = { "requirements": req_metrics, diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 83dcddfa2..6df98553a 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -53,30 +53,8 @@ ["wp__sw_component_fmea", "FMEA"], ] %} -{#- 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 %} - -{#- 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 ``applies_to_module_version``. -#} -{% macro id_filter_list(ids) -%} -[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}] -{%- endmacro %} +{#- ``workproduct_rows``, ``id_filter_list`` and ``safety_pie_row`` are render + helpers shared with the platform report. -#} .. raw:: html @@ -102,8 +80,74 @@ .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; } + + /* Scales every chart to its column; together with the fixed three-column + raster this gives every chart the same size as in the platform report. */ + .score-pie-row img[id^="needpie-"] { + width: 100%; + height: auto; + } + /* ``.sd-row`` is a flex container without a justification of its own, so + this centres the two pies in the three-column raster. */ + .score-pie-row { justify-content: center; } + + p.rubric.safety-class-heading { + display: inline-block; margin: 1.4em 0 0.5em 0; + padding: 0.15em 0.9em; border-radius: 1em; + font-size: 0.95em; font-weight: 600; color: #ffffff; + } + p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; } + p.rubric.safety-class-heading.safety-qm { background: #6c757d; } + + /* Matches the aspect ratio of the rendered pie charts. */ + .empty-pie { + display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 0.4em; + aspect-ratio: 16 / 9; padding: 0.5em; text-align: center; + border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; + } + .empty-pie-title { font-weight: 600; } + .empty-pie-text { font-style: italic; } + + .chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; } + .chart-legend-group { + display: flex; flex-direction: column; gap: 0.3em; + flex: 1 1 20em; font-size: 0.9em; + } + .chart-legend-head { font-weight: 600; } + .chart-legend-dot { + display: inline-block; width: 0.8em; height: 0.8em; + margin-right: 0.5em; border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.25); + } +.. dropdown:: Legend — status values and chart colours + :animate: fade-in + :open: + + .. raw:: html + +
+
+ Status — ASIL B + valid_inspected — agreed and formally inspected + valid — agreed, formal inspection still outstanding + invalid — not agreed; metamodel checks are skipped +
+
+ Status — QM + valid — agreed; QM needs are not formally inspected, so this is their final state (valid_inspected counts here too) + invalid — not agreed; metamodel checks are skipped +
+
+ Test coverage — both classes + fully covered — at least one test fully verifies the need + partially covered — only partially verifying tests + not covered — no verifying test at all +
+
+ {# ===================================================================== #} {# Components #} {# ===================================================================== #} @@ -118,7 +162,7 @@ Component Overview :filter: id in [{% for component in components_in_mod %}"{{ component["id"] }}"{% if not loop.last %}, {% endif %}{% endfor %}] :columns: id as "Component";safety;security;status :style: table - :sort: id + :sort: safety {% for component in components_in_mod %} {% set component_id = component["id"] %} @@ -140,57 +184,35 @@ Component Overview {% set component_reqs_all = linked_needs(component_id, "satisfied_by_back") |selectattr("type", "eq", "comp_req") |list %} -{% set ns = namespace(req_ids=[]) %} +{% set ns = namespace(reqs=[]) %} {% for req in component_reqs_all %} {% if req_in_report_version(req, module_version) %} -{% set ns.req_ids = ns.req_ids + [req["id"]] %} +{% set ns.reqs = ns.reqs + [req] %} {% endif %} {% endfor %} -{% set component_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %} +{% set component_reqs = ns.reqs %} +{% set component_req_filter = 'id in ' ~ id_filter_list(component_reqs|map(attribute="id")|list) %} +{#- ``type`` has no "in" test in minijinja, so the two architecture types are + collected separately and concatenated. -#} +{% set component_arcs_all = linked_needs(component_id, "belongs_to_back") %} +{% set component_arcs = (component_arcs_all|selectattr("type", "eq", "comp_arc_sta")|list) + + (component_arcs_all|selectattr("type", "eq", "comp_arc_dyn")|list) %} Component Requirements Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {{ component_title }} Requirements Status - :labels: valid, valid_inspected, invalid - :colors: #f5c518, #37a12d, #ca2828 - :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") - - .. grid-item:: - - .. needpie:: {{ component_title }} Requirements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "requirements", "{{ module_version or '' }}") +{% set req_base = 'type == "comp_req" and "' ~ component_id ~ '" in satisfied_by and ' ~ component_req_filter %} +{{ safety_pie_row(component_title, "Requirements", req_base, "ASIL_B", "ASIL B", + component_reqs|selectattr("safety", "eq", "ASIL_B")|list|length) }} +{{ safety_pie_row(component_title, "Requirements", req_base, "QM", "QM", + component_reqs|selectattr("safety", "eq", "QM")|list|length) }} Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. grid:: 1 2 2 2 - :gutter: 3 - - .. grid-item:: - - .. needpie:: {{ component_title }} Architecture Elements Status - :labels: valid, valid_inspected, invalid - :colors: #f5c518, #37a12d, #ca2828 - :legend: - :filter-func: score_metrics.sphinx_filters.get_component_status_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") - - .. grid-item:: - - .. needpie:: {{ component_title }} Architecture Elements Test Coverage - :labels: fully covered, partially covered, not covered - :colors: #37a12d, #f0a500, #ca2828 - :legend: - :filter-func: score_metrics.sphinx_filters.get_component_coverage_metrics("{{ component_id }}", "architecture", "{{ module_version or '' }}") +{% set arc_base = 'type in ["comp_arc_sta", "comp_arc_dyn"] and "' ~ component_id ~ '" in belongs_to' %} +{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "ASIL_B", "ASIL B", + component_arcs|selectattr("safety", "eq", "ASIL_B")|list|length) }} +{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "QM", "QM", + component_arcs|selectattr("safety", "eq", "QM")|list|length) }} Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -206,7 +228,7 @@ verification status and the tests that (fully or partially) verify them: :style: table :columns: id;title;safety;status;testlink :colwidths: 13,22,8,10,47 - :sort: id + :sort: safety Test Coverage ^^^^^^^^^^^^^ @@ -239,7 +261,7 @@ status have not yet been inspected. :style: table :columns: id;title;safety;status;tags :colwidths: 25,30,10,15,20 - :sort: id + :sort: safety Verification & Safety Analysis Documents ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 553147b72598ce78e60f4c0e5c0cf6ad4821b633 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 08:48:50 +0000 Subject: [PATCH 25/26] feat: count the module report pies with the central filter functions The module verification report now takes its status and test-coverage numbers from the shared score_metrics filter functions instead of inline needpie filters, while keeping the ASIL B / QM split from #946. - count_status_filtered / count_coverage_filtered take an optional safety class; get_component_*_metrics accept it as arg4. QM counts valid_inspected as valid, ASIL lists valid_inspected first, matching the existing labels and colours. - The filter functions always count the needs handed in by needpie and no longer read metrics.json, so the report does not depend on it. - sphinx-needs splits the call on its last ".", so the version is passed as v1_0 and decoded back to v1.0. - safety_pie_row gets optional component/category/version arguments that switch the status and coverage pies to these filter functions. The platform report keeps its inline filters. A test renders both variants of safety_pie_row and checks that the filter functions count exactly what the inline filters count, per safety class and with a version scope. --- .../score_metrics/sphinx_filters.py | 96 ++++---- .../tests/test_sphinx_filters.py | 216 ++++++++++++------ .../score_sphinx_needs_templates/__init__.py | 39 +++- src/helper_lib/verification_metrics.py | 52 +++-- .../module_verification_report.need | 22 +- 5 files changed, 268 insertions(+), 157 deletions(-) diff --git a/src/extensions/score_metrics/sphinx_filters.py b/src/extensions/score_metrics/sphinx_filters.py index 58e6c576b..66e938ca4 100644 --- a/src/extensions/score_metrics/sphinx_filters.py +++ b/src/extensions/score_metrics/sphinx_filters.py @@ -63,6 +63,15 @@ def _arg(kwargs: dict[str, Any], name: str, default: str = "") -> str: return str(kwargs.get(name, default)).strip().strip("\"'") +def _version_arg(kwargs: dict[str, Any]) -> str | None: + """Return ``arg3`` as a version, with ``_`` turned back into ``.``. + + sphinx-needs resolves the module with ``rsplit(".", 1)`` over the whole + call, so a version such as ``v1.0`` must be passed as ``v1_0``. + """ + return _arg(kwargs, "arg3").replace("_", ".") or None + + def _matches_source_selector(need: NeedItem, selector: str) -> bool: """Return whether a need matches a source selector. @@ -272,41 +281,35 @@ def get_component_status_metrics( ) -> None: """Get status distribution for a component's requirements or architecture. - Arguments are passed via ``arg1``, ``arg2``, ``arg3``: + Arguments are passed via ``arg1`` .. ``arg4``: - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") - ``arg2`` – category: Either "requirements" or "architecture" - - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) - - sphinx-needs resolves the module path with ``rsplit(".", 1)`` over the - whole call, so no argument may contain a dot (``v1.0`` breaks the call). + - ``arg3`` – version: Optional, see :func:`_version_arg` + - ``arg4`` – safety: Optional safety class, e.g. ``ASIL_B`` or ``QM`` - Returns [valid_count, valid_inspected_count, invalid_count]. + Returns ``[valid, valid_inspected, invalid]`` without a safety class, + ``[valid_inspected, valid, invalid]`` for an ASIL class and + ``[valid, invalid]`` for ``QM``, where ``valid_inspected`` counts as + ``valid`` because QM Needs are not formally inspected. """ results.clear() - component_id = _arg(kwargs, "arg1") - category = _arg(kwargs, "arg2", "requirements") - version = _arg(kwargs, "arg3") or None - - # If version is specified, filter on-the-fly using the version-scoping helper - if version: - status = count_status_filtered( - needs, component_id, category, version, req_in_report_version - ) - else: - # Use pre-calculated metrics from CALCULATED_METRICS - metrics = CALCULATED_METRICS.get("verification_metrics", {}) - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - status = category_metrics.get("status", {}) - - results.extend( - [ - status.get("valid", 0), - status.get("valid_inspected", 0), - status.get("invalid", 0), - ] + safety = _arg(kwargs, "arg4") or None + status = count_status_filtered( + needs, + _arg(kwargs, "arg1"), + _arg(kwargs, "arg2", "requirements"), + _version_arg(kwargs), + req_in_report_version, + safety, ) + if safety == "QM": + results.extend([status["valid"] + status["valid_inspected"], status["invalid"]]) + elif safety: + results.extend([status["valid_inspected"], status["valid"], status["invalid"]]) + else: + results.extend([status["valid"], status["valid_inspected"], status["invalid"]]) + def get_component_coverage_metrics( needs: list[Any], results: list[int], **kwargs: Any @@ -316,37 +319,24 @@ def get_component_coverage_metrics( Uses the verification coverage axis (fully_verifies_back / partially_verifies_back), not the link coverage axis (testlink / source_code_link). - Arguments are passed via ``arg1``, ``arg2``, ``arg3``: - - ``arg1`` – component_id: The component ID (e.g., "comp__my_component") - - ``arg2`` – category: Either "requirements" or "architecture" - - ``arg3`` – version: Optional version to filter by valid_from (only applied to requirements) - - sphinx-needs resolves the module path with ``rsplit(".", 1)`` over the - whole call, so no argument may contain a dot (``v1.0`` breaks the call). + Arguments are the same as for :func:`get_component_status_metrics`. Returns [fully_covered, partially_covered, not_covered]. """ results.clear() - component_id = _arg(kwargs, "arg1") - category = _arg(kwargs, "arg2", "requirements") - version = _arg(kwargs, "arg3") or None - - # If version is specified, filter on-the-fly using the version-scoping helper - if version: - coverage = count_coverage_filtered( - needs, component_id, category, version, req_in_report_version - ) - else: - # Use pre-calculated metrics from CALCULATED_METRICS - metrics = CALCULATED_METRICS.get("verification_metrics", {}) - component_metrics = metrics.get(component_id, {}) - category_metrics = component_metrics.get(category, {}) - coverage = category_metrics.get("coverage", {}) + coverage = count_coverage_filtered( + needs, + _arg(kwargs, "arg1"), + _arg(kwargs, "arg2", "requirements"), + _version_arg(kwargs), + req_in_report_version, + _arg(kwargs, "arg4") or None, + ) results.extend( [ - coverage.get("fully_covered", 0), - coverage.get("partially_covered", 0), - coverage.get("not_covered", 0), + coverage["fully_covered"], + coverage["partially_covered"], + coverage["not_covered"], ] ) diff --git a/src/extensions/score_metrics/tests/test_sphinx_filters.py b/src/extensions/score_metrics/tests/test_sphinx_filters.py index 0eb00635a..12ffcb0ed 100644 --- a/src/extensions/score_metrics/tests/test_sphinx_filters.py +++ b/src/extensions/score_metrics/tests/test_sphinx_filters.py @@ -17,11 +17,11 @@ # noqa import score_metrics.sphinx_filters as sphinx_filters +import score_sphinx_needs_templates as sphinx_needs_templates from score_metrics.sphinx_filters import ( generic_pie_items_by_tag, generic_pie_linked_items, ) -from score_metrics.traceability_metrics import calculate_verification_metrics from score_pytest.attribute_plugin import add_test_properties from sphinx_needs.exceptions import NeedsInvalidFilter from sphinx_needs.need_item import NeedItem @@ -257,42 +257,77 @@ def test_get_metrics_with_custom_type_total_considered_empty_kwargs_raises_index sphinx_filters.get_metrics_with_first_value_total(needs=[], results=results) -_COMPONENT_NEEDS = cast( - list[NeedItem], - [ - { - "id": "comp_req__x__in", - "type": "comp_req", - "satisfied_by": ["comp__x"], - "status": "valid", - "valid_from": "v1", - "fully_verifies_back": ["tc__a"], - "partially_verifies_back": [], - }, - { - "id": "comp_req__x__later", - "type": "comp_req", - "satisfied_by": ["comp__x"], - "status": "invalid", - "valid_from": "v2", - "fully_verifies_back": [], - "partially_verifies_back": [], - }, - { - "id": "comp_arc_sta__x__a", - "type": "comp_arc_sta", - "belongs_to": ["comp__x"], - "status": "valid", - "fully_verifies_back": [], - "partially_verifies_back": ["tc__b"], - }, - ], -) +_PROPS = { + "partially_verifies": [], + "test_type": "interface-test", + "derivation_technique": "explorative-testing", +} + + +def _need(need_id: str, need_type: str, link: str, **fields: Any) -> NeedItem: + need = { + "id": need_id, + "type": need_type, + link: ["comp__x"], + "fully_verifies_back": [], + "partially_verifies_back": [], + **fields, + } + return cast(NeedItem, need) + + +_COMPONENT_NEEDS = [ + _need( + "r1", + "comp_req", + "satisfied_by", + safety="ASIL_B", + status="valid_inspected", + valid_from="v1.0", + fully_verifies_back=["tc__a"], + ), + _need( + "r2", + "comp_req", + "satisfied_by", + safety="ASIL_B", + status="valid", + valid_from="v1.0", + partially_verifies_back=["tc__b"], + ), + _need( + "r3", + "comp_req", + "satisfied_by", + safety="QM", + status="valid_inspected", + valid_from="v1.0", + ), + _need( + "r4", + "comp_req", + "satisfied_by", + safety="QM", + status="invalid", + valid_from="v2.0", + ), + _need( + "a1", + "comp_arc_sta", + "belongs_to", + safety="ASIL_B", + status="valid", + partially_verifies_back=["tc__c"], + ), + _need("a2", "comp_arc_dyn", "belongs_to", safety="QM", status="valid"), +] def _call_like_needpie(signature: str, needs: list[NeedItem]) -> list[int]: """Parse and call a filter-func exactly the way ``needpie`` does.""" - ff = check_and_get_external_filter_func(f"score_metrics.sphinx_filters.{signature}") + if not signature.startswith("score_metrics."): + signature = f"score_metrics.sphinx_filters.{signature}" + ff = check_and_get_external_filter_func(signature) assert ff is not None args = ff.args.split(",") if ff.args else [] results: list[int] = [] @@ -304,53 +339,98 @@ def _call_like_needpie(signature: str, needs: list[NeedItem]) -> list[int]: return results -@add_test_properties( - partially_verifies=[], - test_type="interface-test", - derivation_technique="explorative-testing", -) -def test_component_metrics_accept_quoted_arguments( - monkeypatch: pytest.MonkeyPatch, -) -> None: +@add_test_properties(**_PROPS) +def test_component_metrics_accept_quoted_arguments() -> None: """Quoted, space-separated arguments as written in a template are decoded.""" - all_needs = {n["id"]: n for n in _COMPONENT_NEEDS} - all_needs["comp__x"] = cast(NeedItem, {"id": "comp__x", "type": "comp"}) - monkeypatch.setattr( - sphinx_filters, - "CALCULATED_METRICS", - {"verification_metrics": calculate_verification_metrics(cast(Any, all_needs))}, - ) + status = 'get_component_status_metrics("comp__x", "requirements", "", "")' + coverage = 'get_component_coverage_metrics("comp__x", "architecture")' - status = 'get_component_status_metrics("comp__x", "requirements", "")' - coverage = 'get_component_coverage_metrics("comp__x", "architecture", "")' + assert _call_like_needpie(status, _COMPONENT_NEEDS) == [1, 2, 1] + assert _call_like_needpie(coverage, _COMPONENT_NEEDS) == [0, 1, 1] - assert _call_like_needpie(status, _COMPONENT_NEEDS) == [1, 0, 1] - assert _call_like_needpie(coverage, _COMPONENT_NEEDS) == [0, 1, 0] +@add_test_properties(**_PROPS) +def test_component_metrics_split_by_safety_class() -> None: + """ASIL lists valid_inspected first; QM counts valid_inspected as valid.""" + asil = "get_component_status_metrics(comp__x, requirements, , ASIL_B)" + qm = "get_component_status_metrics(comp__x, requirements, , QM)" + qm_coverage = "get_component_coverage_metrics(comp__x, requirements, , QM)" -@add_test_properties( - partially_verifies=[], - test_type="interface-test", - derivation_technique="explorative-testing", -) + assert _call_like_needpie(asil, _COMPONENT_NEEDS) == [1, 1, 0] + assert _call_like_needpie(qm, _COMPONENT_NEEDS) == [1, 1] + assert _call_like_needpie(qm_coverage, _COMPONENT_NEEDS) == [0, 0, 2] + + +@add_test_properties(**_PROPS) def test_component_metrics_version_scopes_requirements_only() -> None: - """A version excludes later requirements but never architecture elements.""" - reqs = 'get_component_status_metrics("comp__x", "requirements", "v1")' - arcs = 'get_component_status_metrics("comp__x", "architecture", "v1")' + """``v1_0`` means v1.0; it excludes later requirements, never architecture.""" + reqs = "get_component_status_metrics(comp__x, requirements, v1_0, QM)" + arcs = "get_component_status_metrics(comp__x, architecture, v1_0, QM)" - assert _call_like_needpie(reqs, _COMPONENT_NEEDS) == [1, 0, 0] - assert _call_like_needpie(arcs, _COMPONENT_NEEDS) == [1, 0, 0] + assert _call_like_needpie(reqs, _COMPONENT_NEEDS) == [1, 0] + assert _call_like_needpie(arcs, _COMPONENT_NEEDS) == [1, 0] -@add_test_properties( - partially_verifies=[], - test_type="interface-test", - derivation_technique="explorative-testing", -) +@add_test_properties(**_PROPS) def test_filter_func_arguments_must_not_contain_a_dot() -> None: """sphinx-needs splits the call on its last dot, so ``v1.0`` cannot be passed.""" with pytest.raises(NeedsInvalidFilter): _call_like_needpie( - 'get_component_status_metrics("comp__x", "requirements", "v1.0")', + "get_component_status_metrics(comp__x, requirements, v1.0, QM)", _COMPONENT_NEEDS, ) + + +_DEFAULTS = {"satisfied_by": [], "belongs_to": []} + + +def _pie_counts(rst: str, needs: list[NeedItem]) -> list[list[int]]: + """Evaluate every needpie in a ``safety_pie_row`` block like sphinx-needs.""" + pies: list[list[int]] = [] + for block in rst.split(".. needpie::")[1:]: + body = [line.strip() for line in block.splitlines()[1:]] + func = next((line for line in body if line.startswith(":filter-func:")), None) + if func: + pies.append(_call_like_needpie(func.split(" ", 1)[1], needs)) + continue + filters = [line for line in body if line and not line.startswith(":")] + filters = ( + filters[: filters.index(".. grid-item::")] + if ".. grid-item::" in filters + else filters + ) + pies.append( + [sum(bool(eval(f, {}, {**_DEFAULTS, **n})) for n in needs) for f in filters] # noqa: S307 + ) + return pies + + +@add_test_properties(**_PROPS) +def test_safety_pie_row_filter_funcs_match_inline_filters() -> None: + """The central filter functions count exactly what the inline filters count.""" + row = sphinx_needs_templates._safety_pie_row_callable + in_scope = ["r1", "r2", "r3"] + req_base = f'type == "comp_req" and "comp__x" in satisfied_by and id in {in_scope}' + arc_base = 'type in ["comp_arc_sta", "comp_arc_dyn"] and "comp__x" in belongs_to' + + for safety in ("ASIL_B", "QM"): + for base, category, version in ( + (req_base, "requirements", "v1.0"), + (arc_base, "architecture", None), + ): + inline = row("C", "S", base, safety, safety, 1) + central = row( + "C", + "S", + "", + safety, + safety, + 1, + component="comp__x", + category=category, + version=version, + ) + assert ":filter-func:" in central + assert _pie_counts(central, _COMPONENT_NEEDS) == _pie_counts( + inline, _COMPONENT_NEEDS + ) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 67760242c..322c4a290 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -374,6 +374,10 @@ class _SafetyPieRow: count through Sphinx-Needs filters, and the two can disagree on links carrying a ``[version==N]`` selector. + With ``component`` and ``category`` the status and coverage pies are + counted by the shared ``score_metrics`` filter functions instead of the + inline ``base`` filters; ``version`` then scopes the requirements. + This is a render helper instead of a Jinja macro because the MiniJinja environment has no template loader, so macros cannot be shared between templates. @@ -390,6 +394,9 @@ def __call__( with_reference: bool = False, referenced: list[str] | None = None, missing: list[str] | None = None, + component: str | None = None, + category: str | None = None, + version: str | None = None, ) -> str: scope = f'{base} and safety == "{safety}"' # (slice label, status condition) per pie slice; green "done" comes first. @@ -440,14 +447,28 @@ def grid_item(name: str, pie: list[str]) -> None: ) lines.extend(f" {line}".rstrip() for line in pie) + def counted_by(func: str) -> list[str]: + # sphinx-needs splits the call on its last ".", so encode the version. + encoded_version = (version or "").replace(".", "_") + return [ + f":filter-func: score_metrics.sphinx_filters.{func}" + f"({component}, {category}, {encoded_version}, {safety})" + ] + grid_item( "Status", [ f":labels: {', '.join(label for label, _ in statuses)}", f":colors: {status_colors}", ":legend:", - "", - *(f"{scope} and {condition}" for _, condition in statuses), + *( + counted_by("get_component_status_metrics") + if component + else [ + "", + *(f"{scope} and {condition}" for _, condition in statuses), + ] + ), ], ) grid_item( @@ -456,10 +477,16 @@ def grid_item(name: str, pie: list[str]) -> None: ":labels: fully covered, partially covered, not covered", ":colors: #37a12d, #f0a500, #ca2828", ":legend:", - "", - f"{scope} and fully_verifies_back", - f"{scope} and partially_verifies_back and not fully_verifies_back", - f"{scope} and not fully_verifies_back and not partially_verifies_back", + *( + counted_by("get_component_coverage_metrics") + if component + else [ + "", + f"{scope} and fully_verifies_back", + f"{scope} and partially_verifies_back and not fully_verifies_back", + f"{scope} and not fully_verifies_back and not partially_verifies_back", + ] + ), ], ) if with_reference: diff --git a/src/helper_lib/verification_metrics.py b/src/helper_lib/verification_metrics.py index 9f7beb1e5..1171a6c90 100644 --- a/src/helper_lib/verification_metrics.py +++ b/src/helper_lib/verification_metrics.py @@ -53,12 +53,35 @@ def is_need_in_component(need: NeedItem, component_id: str, category: str) -> bo return component_id in bel_to +def _in_scope( + need: NeedItem, + component_id: str, + category: str, + version: str | None, + version_filter_func: Any, + safety: str | None, +) -> bool: + """Whether a need is counted for this component, category, version and safety.""" + if not is_need_in_component(need, component_id, category): + return False + if safety and need.get("safety") != safety: + return False + # Architecture elements have neither valid_from nor derived_from, so the + # version scope applies to requirements only. + return not ( + category == "requirements" + and version + and not version_filter_func(need, version) + ) + + def count_status_filtered( needs: list[Any], component_id: str, category: str, - version: str, + version: str | None, version_filter_func: Any, + safety: str | None = None, ) -> dict[str, int]: """Count status distribution with version filtering. @@ -68,6 +91,7 @@ def count_status_filtered( category: Either "requirements" or "architecture". version: Version string for filtering (only applied to requirements). version_filter_func: Callable like req_in_report_version(need, version). + safety: Optional safety class (e.g. "ASIL_B", "QM") to restrict to. Returns: Dict with status counts: {valid: int, valid_inspected: int, invalid: int} @@ -75,15 +99,8 @@ def count_status_filtered( status = {"valid": 0, "valid_inspected": 0, "invalid": 0} for need in needs: - if not is_need_in_component(need, component_id, category): - continue - - # Architecture elements should not be version-filtered - # (they have no valid_from or derived_from attributes) - if ( - category == "requirements" - and version - and not version_filter_func(need, version) + if not _in_scope( + need, component_id, category, version, version_filter_func, safety ): continue @@ -98,8 +115,9 @@ def count_coverage_filtered( needs: list[Any], component_id: str, category: str, - version: str, + version: str | None, version_filter_func: Any, + safety: str | None = None, ) -> dict[str, int]: """Count verification coverage with version filtering. @@ -112,6 +130,7 @@ def count_coverage_filtered( category: Either "requirements" or "architecture". version: Version string for filtering (only applied to requirements). version_filter_func: Callable like req_in_report_version(need, version). + safety: Optional safety class (e.g. "ASIL_B", "QM") to restrict to. Returns: Dict with coverage counts: {fully_covered: int, partially_covered: int, not_covered: int} @@ -119,15 +138,8 @@ def count_coverage_filtered( coverage = {"fully_covered": 0, "partially_covered": 0, "not_covered": 0} for need in needs: - if not is_need_in_component(need, component_id, category): - continue - - # Architecture elements should not be version-filtered - # (they have no valid_from or derived_from attributes) - if ( - category == "requirements" - and version - and not version_filter_func(need, version) + if not _in_scope( + need, component_id, category, version, version_filter_func, safety ): continue diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 6df98553a..160473087 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -200,19 +200,21 @@ Component Overview Component Requirements Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -{% set req_base = 'type == "comp_req" and "' ~ component_id ~ '" in satisfied_by and ' ~ component_req_filter %} -{{ safety_pie_row(component_title, "Requirements", req_base, "ASIL_B", "ASIL B", - component_reqs|selectattr("safety", "eq", "ASIL_B")|list|length) }} -{{ safety_pie_row(component_title, "Requirements", req_base, "QM", "QM", - component_reqs|selectattr("safety", "eq", "QM")|list|length) }} +{{ safety_pie_row(component_title, "Requirements", "", "ASIL_B", "ASIL B", + component_reqs|selectattr("safety", "eq", "ASIL_B")|list|length, + component=component_id, category="requirements", version=module_version) }} +{{ safety_pie_row(component_title, "Requirements", "", "QM", "QM", + component_reqs|selectattr("safety", "eq", "QM")|list|length, + component=component_id, category="requirements", version=module_version) }} Component Architecture Statistics ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -{% set arc_base = 'type in ["comp_arc_sta", "comp_arc_dyn"] and "' ~ component_id ~ '" in belongs_to' %} -{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "ASIL_B", "ASIL B", - component_arcs|selectattr("safety", "eq", "ASIL_B")|list|length) }} -{{ safety_pie_row(component_title, "Architecture Elements", arc_base, "QM", "QM", - component_arcs|selectattr("safety", "eq", "QM")|list|length) }} +{{ safety_pie_row(component_title, "Architecture Elements", "", "ASIL_B", "ASIL B", + component_arcs|selectattr("safety", "eq", "ASIL_B")|list|length, + component=component_id, category="architecture") }} +{{ safety_pie_row(component_title, "Architecture Elements", "", "QM", "QM", + component_arcs|selectattr("safety", "eq", "QM")|list|length, + component=component_id, category="architecture") }} Requirements Traceability ^^^^^^^^^^^^^^^^^^^^^^^^^ From e9c3a81ceedc6e2d85fcfa0a061aca669de97f85 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 09:15:50 +0000 Subject: [PATCH 26/26] test: satisfy BasedPyright in the component filter tests --- .../score_metrics/tests/test_sphinx_filters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensions/score_metrics/tests/test_sphinx_filters.py b/src/extensions/score_metrics/tests/test_sphinx_filters.py index 12ffcb0ed..5e1649eb7 100644 --- a/src/extensions/score_metrics/tests/test_sphinx_filters.py +++ b/src/extensions/score_metrics/tests/test_sphinx_filters.py @@ -257,7 +257,7 @@ def test_get_metrics_with_custom_type_total_considered_empty_kwargs_raises_index sphinx_filters.get_metrics_with_first_value_total(needs=[], results=results) -_PROPS = { +_PROPS: dict[str, Any] = { "partially_verifies": [], "test_type": "interface-test", "derivation_technique": "explorative-testing", @@ -265,7 +265,7 @@ def test_get_metrics_with_custom_type_total_considered_empty_kwargs_raises_index def _need(need_id: str, need_type: str, link: str, **fields: Any) -> NeedItem: - need = { + need: dict[str, Any] = { "id": need_id, "type": need_type, link: ["comp__x"], @@ -332,7 +332,7 @@ def _call_like_needpie(signature: str, needs: list[NeedItem]) -> list[int]: args = ff.args.split(",") if ff.args else [] results: list[int] = [] ff.func( - needs=needs, + needs=cast(Any, needs), results=results, **{f"arg{index + 1}": arg for index, arg in enumerate(args)}, ) @@ -381,7 +381,7 @@ def test_filter_func_arguments_must_not_contain_a_dot() -> None: ) -_DEFAULTS = {"satisfied_by": [], "belongs_to": []} +_DEFAULTS: dict[str, list[str]] = {"satisfied_by": [], "belongs_to": []} def _pie_counts(rst: str, needs: list[NeedItem]) -> list[list[int]]: @@ -408,7 +408,7 @@ def _pie_counts(rst: str, needs: list[NeedItem]) -> list[list[int]]: @add_test_properties(**_PROPS) def test_safety_pie_row_filter_funcs_match_inline_filters() -> None: """The central filter functions count exactly what the inline filters count.""" - row = sphinx_needs_templates._safety_pie_row_callable + row = vars(sphinx_needs_templates)["_safety_pie_row_callable"] in_scope = ["r1", "r2", "r3"] req_base = f'type == "comp_req" and "comp__x" in satisfied_by and id in {in_scope}' arc_base = 'type in ["comp_arc_sta", "comp_arc_dyn"] and "comp__x" in belongs_to'