diff --git a/docs/how-to/dashboards_and_quality_gates.rst b/docs/how-to/dashboards_and_quality_gates.rst index ea0797579..418fc6228 100644 --- a/docs/how-to/dashboards_and_quality_gates.rst +++ b/docs/how-to/dashboards_and_quality_gates.rst @@ -40,20 +40,35 @@ Typical Setup For details, see :ref:`setup`. -Minimal Configuration Example ------------------------------ +Configuration +------------- + +Default Behavior (No Configuration Needed) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, ``score_metamodel`` autodiscovers requirement types from the +repository needs present in the current build. Requirement types are identified +from ``needs_types`` entries tagged with ``requirement`` or +``requirement_excl_process``. -In ``docs/conf.py``: +This is the recommended setup for most repositories. + +Optional Override for Requirement Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If a repository needs to force a specific set of requirement types, set an +explicit override in ``docs/conf.py``: .. code-block:: python score_metamodel_requirement_types = "feat_req,comp_req,aou_req" - score_metamodel_include_external_needs = False + +When this setting is provided, the explicit list is used instead of +autodiscovery. Use ``score_metamodel_include_external_needs = True`` only in repositories that intentionally aggregate requirements across module dependencies, such as -integration repositories. Use ``False`` for module repositories to gate only on -local traceability. +integration repositories. Building the Dashboard ---------------------- @@ -87,7 +102,7 @@ There are two common modes: **Module repository** -- Set ``score_metamodel_include_external_needs = False``. +- No setting needed. Local-only scope is the default. - Gate only on the needs owned by the repository itself. - Use this for per-module implementation progress and traceability. diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 7f26c9b83..8ce2be614 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -111,29 +111,38 @@ def _write_metrics_json(app: Sphinx, exception: Exception | None) -> None: return all_needs: list[Any] = list(SphinxNeedsData(app.env).get_needs_view().values()) - - raw = str(getattr(app.config, "score_metamodel_requirement_types", "tool_req")) - requirement_types = {t.strip() for t in raw.split(",") if t.strip()} or {"tool_req"} - include_not_implemented = True include_external: bool = bool( getattr(app.config, "score_metamodel_include_external_needs", False) ) + raw = str(getattr(app.config, "score_metamodel_requirement_types", "")).strip() + requirement_types = {t.strip() for t in raw.split(",") if t.strip()} + if not requirement_types: + requirement_types = _discover_requirement_types( + app, all_needs, include_external + ) + include_not_implemented = True + metrics_by_type: dict[str, Any] = {} - for req_type in sorted(requirement_types): - type_summary = compute_traceability_summary( - all_needs=all_needs, - requirement_types={req_type}, - include_not_implemented=include_not_implemented, - filtered_test_types=set(), - include_external=include_external, + if not requirement_types: + logger.info( + "No requirement types configured or discovered; writing empty metrics.json." ) - metrics_by_type[req_type] = { - "include_not_implemented": type_summary["include_not_implemented"], - "include_external": type_summary["include_external"], - "requirements": type_summary["requirements"], - "tests": type_summary["tests"], - } + else: + for req_type in sorted(requirement_types): + type_summary = compute_traceability_summary( + all_needs=all_needs, + requirement_types={req_type}, + include_not_implemented=include_not_implemented, + filtered_test_types=set(), + include_external=include_external, + ) + metrics_by_type[req_type] = { + "include_not_implemented": type_summary["include_not_implemented"], + "include_external": type_summary["include_external"], + "requirements": type_summary["requirements"], + "tests": type_summary["tests"], + } output: dict[str, Any] = { "schema_version": "1", @@ -147,6 +156,59 @@ def _write_metrics_json(app: Sphinx, exception: Exception | None) -> None: logger.info(f"Traceability metrics written to: {out_path}") +def _get_need_value(need: Any, key: str, default: Any = None) -> Any: + return need.get(key, default) + + +def _as_requirement_directive(need_type: Any) -> str | None: + if not isinstance(need_type, dict): + return None + directive = need_type.get("directive") + tags = need_type.get("tags", []) + if not isinstance(directive, str) or not isinstance(tags, list): + return None + normalized = {str(tag).strip() for tag in tags} + if "requirement_excl_process" in normalized or "requirement" in normalized: + return directive + return None + + +def _discover_requirement_types( + app: Sphinx, all_needs: list[Any], include_external: bool +) -> set[str]: + """Discover requirement directives that are both tagged and present.""" + tagged_requirements: set[str] = set() + needs_types = getattr(app.config, "needs_types", []) + for need_type in needs_types or []: + directive = _as_requirement_directive(need_type) + if directive: + tagged_requirements.add(directive) + + present_types: set[str] = set() + for need in all_needs: + is_external = bool(_get_need_value(need, "is_external", False)) + if not include_external and is_external: + continue + need_type: Any = _get_need_value(need, "type", None) + if isinstance(need_type, str): + present_types.add(need_type) + + discovered = tagged_requirements.intersection(present_types) + + if tagged_requirements and not discovered: + logger.warning( + "No requirement types discovered in current build for tagged " + "needs_types requirement directives." + ) + + if discovered: + logger.info( + "score_metamodel_requirement_types is not configured; " + f"using discovered requirement types: {', '.join(sorted(discovered))}" + ) + return discovered + + def _run_checks(app: Sphinx, exception: Exception | None) -> None: # Do not run checks if an exception occurred during build if exception: @@ -355,11 +417,12 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value( "score_metamodel_requirement_types", - "tool_req", + "", rebuild="env", description=( "Comma-separated list of need types treated as requirements for " - "traceability metrics (default: tool_req)." + "traceability metrics. If empty, requirement types are autodiscovered " + "from needs_types tags (requirement, requirement_excl_process)." ), ) diff --git a/src/extensions/score_metamodel/tests/test_traceability_metrics_json_generation.py b/src/extensions/score_metamodel/tests/test_traceability_metrics_json_generation.py index 764659874..47af209cb 100644 --- a/src/extensions/score_metamodel/tests/test_traceability_metrics_json_generation.py +++ b/src/extensions/score_metamodel/tests/test_traceability_metrics_json_generation.py @@ -38,6 +38,14 @@ def get_needs_view(self) -> dict[str, dict[str, object]]: "testlink": "", "is_external": False, }, + "LOCAL_FEAT": { + "id": "LOCAL_FEAT", + "type": "feat_req", + "implemented": "YES", + "source_code_link": "", + "testlink": "", + "is_external": False, + }, "EXT_REQ": { "id": "EXT_REQ", "type": "tool_req", @@ -46,16 +54,68 @@ def get_needs_view(self) -> dict[str, dict[str, object]]: "testlink": "", "is_external": True, }, + "EXT_FEAT": { + "id": "EXT_FEAT", + "type": "feat_req", + "implemented": "NO", + "source_code_link": "src/ext_feat.py:1", + "testlink": "", + "is_external": True, + }, + "EXT_GD": { + "id": "EXT_GD", + "type": "gd_req", + "implemented": "NO", + "source_code_link": "src/ext_gd.py:1", + "testlink": "", + "is_external": True, + }, + } + + +class _FakeNonReqNeedsData: + def __init__(self, env: object): + self._env = env + + def get_needs_view(self) -> dict[str, dict[str, object]]: + return { + "LOCAL_COMP": { + "id": "LOCAL_COMP", + "type": "comp", + "implemented": "YES", + "source_code_link": "", + "testlink": "", + "is_external": False, + }, + "LOCAL_DOC": { + "id": "LOCAL_DOC", + "type": "document", + "implemented": "YES", + "source_code_link": "", + "testlink": "", + "is_external": False, + }, } -def _app(tmp_path: Path, include_external: bool) -> SimpleNamespace: +def _app( + tmp_path: Path, + include_external: bool, + requirement_types: str = "", + needs_types: list[dict[str, object]] | None = None, +) -> SimpleNamespace: + discovered_types = needs_types or [ + {"directive": "tool_req", "tags": ["requirement_excl_process"]}, + {"directive": "feat_req", "tags": ["requirement"]}, + {"directive": "workflow", "tags": []}, + ] return SimpleNamespace( env=object(), outdir=str(tmp_path), config=SimpleNamespace( - score_metamodel_requirement_types="tool_req", + score_metamodel_requirement_types=requirement_types, score_metamodel_include_external_needs=include_external, + needs_types=discovered_types, ), ) @@ -94,3 +154,184 @@ def test_write_metrics_json_can_include_external( assert metrics["include_external"] is True assert metrics["requirements"]["total"] == 2 + + +def test_explicit_requirement_types_disable_autodiscovery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=False, + requirement_types="tool_req", + ), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert set(payload["metrics_by_type"].keys()) == {"tool_req"} + + +def test_write_metrics_json_autodiscovers_when_types_unset( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app(tmp_path, include_external=False, requirement_types=""), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert payload["schema_version"] == "1" + assert set(payload["metrics_by_type"].keys()) == {"feat_req", "tool_req"} + + +def test_autodiscovery_excludes_tagged_types_not_present_in_needs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=False, + requirement_types="", + needs_types=[ + {"directive": "tool_req", "tags": ["requirement_excl_process"]}, + {"directive": "feat_req", "tags": ["requirement"]}, + {"directive": "aou_req", "tags": ["requirement"]}, + ], + ), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert set(payload["metrics_by_type"].keys()) == {"feat_req", "tool_req"} + + +def test_write_metrics_json_empty_when_no_types_configured_or_discovered( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNonReqNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=False, + requirement_types="", + needs_types=[{"directive": "workflow", "tags": []}], + ), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert payload["schema_version"] == "1" + assert payload["metrics_by_type"] == {} + + +def test_autodiscovery_without_tagged_requirement_types_is_empty( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=False, + requirement_types="", + needs_types=[{"directive": "workflow", "tags": []}], + ), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert payload["metrics_by_type"] == {} + + +def test_autodiscovery_respects_include_external_scope( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=True, + requirement_types="", + needs_types=[ + {"directive": "tool_req", "tags": ["requirement_excl_process"]}, + {"directive": "feat_req", "tags": ["requirement"]}, + {"directive": "gd_req", "tags": ["requirement"]}, + ], + ), + ), + None, + ) + + payload = json.loads((tmp_path / "metrics.json").read_text(encoding="utf-8")) + assert set(payload["metrics_by_type"].keys()) == {"feat_req", "gd_req", "tool_req"} + + +@pytest.mark.parametrize( + ("requirement_types", "include_external", "should_exist", "expected_totals"), + [ + ("tool_req", False, True, {"tool_req": 1}), + ("feat_req,tool_req", False, True, {"feat_req": 1, "tool_req": 1}), + ("", False, True, {"feat_req": 1, "tool_req": 1}), + (" ", False, True, {"feat_req": 1, "tool_req": 1}), + ("tool_req", True, True, {"tool_req": 2}), + ], +) +def test_write_metrics_json_settings_matrix( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + requirement_types: str, + include_external: bool, + should_exist: bool, + expected_totals: dict[str, int], +) -> None: + monkeypatch.setattr(metamodel_init, "SphinxNeedsData", _FakeNeedsData) + + metamodel_init._write_metrics_json( + cast( + Sphinx, + _app( + tmp_path, + include_external=include_external, + requirement_types=requirement_types, + ), + ), + None, + ) + + metrics_file = tmp_path / "metrics.json" + assert metrics_file.exists() is should_exist + if not should_exist: + return + + payload = json.loads(metrics_file.read_text(encoding="utf-8")) + by_type = payload["metrics_by_type"] + assert set(by_type.keys()) == set(expected_totals.keys()) + + for req_type, expected_total in expected_totals.items(): + assert by_type[req_type]["requirements"]["total"] == expected_total