diff --git a/docs/internals/requirements/requirements.rst b/docs/internals/requirements/requirements.rst index 52203074..f749004f 100644 --- a/docs/internals/requirements/requirements.rst +++ b/docs/internals/requirements/requirements.rst @@ -221,6 +221,19 @@ This section provides an overview of current process requirements and their clar * all architecture elements defined in :need:`tool_req__docs_arch_types`. * all safety analysis elements defined in :need:`tool_req__docs_saf_types`. + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel( + [ + "stkh_req", "feat_req", "comp_req", "aou_req", + "feat_arc_sta", "feat", "logic_arc_int", "logic_arc_int_op", "comp_arc_sta", "comp", "real_arc_int", "real_arc_int_op", + "feat_saf_fmea", "comp_saf_fmea", "feat_saf_dfa", "comp_saf_dfa", + ], + attributes=["status"], + )}} + ---------- @@ -271,6 +284,12 @@ Versioning * Tool Verification Report (doc_tool) * Change Request is also a generic document + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel(types=["document", "doc_tool"])}} + .. tool_req:: Mandatory attributes of Generic Documents :id: tool_req__docs_doc_generic_mandatory :tags: Documents @@ -287,6 +306,12 @@ Versioning * safety * realizes + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel("document", attributes=["status", "security", "safety", "realizes"])}} + .. tool_req:: Mandatory Document attributes :id: tool_req__docs_doc_attr :tags: Documents @@ -307,6 +332,11 @@ Versioning * approver * reviewer + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel(["document", "doc_tool"], attributes=["title", "author", "approver", "reviewer"])}} .. tool_req:: Document author is autofilled :id: tool_req__docs_doc_attr_author_autofill @@ -388,6 +418,16 @@ Mapping * Process requirement (gd_req) * Tool requirement (tool_req) + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel( + [ + "stkh_req", "feat_req", "comp_req", "aou_req", "gd_req", "tool_req", + ], + )}} + ------------------------- 🏷️ Attributes ------------------------- @@ -401,6 +441,13 @@ Mapping Docs-as-Code shall enforce that each stakeholder requirement (stkh_req) contains a ``rationale`` attribute. + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel(["stkh_req"], attributes=["rationale"])}} + + .. tool_req:: Enforces requirement type classification :id: tool_req__docs_req_attr_reqtype :tags: Requirements @@ -507,6 +554,18 @@ Mapping .. note:: Certain tool requirements do not have a matching process requirement. + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel( + [ + "stkh_req", "feat_req", "comp_req", "gd_req", "tool_req", "workflow" + ], + links="satisfies", + )}} + + .. tool_req:: Safety: enforce safe linking :id: tool_req__docs_common_attr_safety_link_check :tags: Common Attributes @@ -544,6 +603,16 @@ Mapping * Interface (real_arc_int) * Interface Operation (real_arc_int_op) + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel( + [ + "feat_arc_sta", "feat", "logic_arc_int", "logic_arc_int_op", "comp_arc_sta", "comp", "real_arc_int", "real_arc_int_op", + ], + )}} + -------------------------- Architecture Attributes -------------------------- @@ -564,8 +633,24 @@ Architecture Attributes * Safety * Security * Status - * UID + * ID (implicitly enforced by sphinx-needs) + + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + {{draw_metamodel( + [ + "feat_arc_sta", "feat", "logic_arc_int", "logic_arc_int_op", "comp_arc_sta", "comp", "real_arc_int", "real_arc_int_op", + ], + attributes=[ + "fulfils", + "safety", + "security", + "status", + "id", + ], + )}} ------------------------ @@ -601,6 +686,24 @@ Architecture Attributes real_arc_int comp_req ==================================== ========================================== + .. admonition:: How is it currently implemented in the current docs-as-code metamodel.yml? + + .. needuml:: + + {{draw_metamodel( + [ + "feat_req", + "comp_req", + "feat_arc_sta", + "feat_arc_dyn", + "logic_arc_int" + "comp_arc_sta", + "comp_arc_dyn", + "real_arc_int" + ], + links=["fulfils"], + attributes=False + )}} .. tool_req:: Ensure safety architecture elements link a safety requirement :id: tool_req__docs_arch_link_safety_to_req @@ -675,6 +778,7 @@ Architecture Attributes but are still defined as architectural elements, which means they have the properties of architectural elements. + 💻 Detailed Design & Code ########################## @@ -778,9 +882,9 @@ Testing Docs-as-Code shall ensure that test cases link to requirements on the correct level: - - If Partially/FullyVerifies are set in Feature Integration Test these shall link to Feature Requirements - - If Partially/FullyVerifies are set in Component Integration Test these shall link to Component Requirements - - If Partially/FullyVerifies are set in Unit Test these shall link to Component Requirements + - If Partially/FullyVerifies are set in Feature Integration Test these shall link to Feature Requirements + - If Partially/FullyVerifies are set in Component Integration Test these shall link to Component Requirements + - If Partially/FullyVerifies are set in Unit Test these shall link to Component Requirements 🧪 Tool Verification Reports @@ -890,7 +994,7 @@ Testing gd_req__saf_attr_uid, :parent_covered: YES - Docs-As-Code shall support the following need types: + Docs-As-Code shall support the following need types: * Feature FMEA (Failure Modes and Effect Analysis) -> ``feat_saf_fmea`` * Component FMEA (Failure Modes and Effect Analysis) -> ``comp_saf_fmea`` @@ -998,14 +1102,17 @@ Testing Docs-As-Code shall enforce that needs of type :need:`tool_req__docs_saf_types` have a `violates` links to at least one dynamic / static diagram according to the table. - | Source | Target | - | -- | -- | - | feat_saf_dfa | feat_arc_sta | - | comp_saf_dfa | comp_arc_sta | + +---------------+--------------+ + | Source | Target | + +===============+==============+ + | feat_saf_dfa | feat_arc_sta | + +---------------+--------------+ + | comp_saf_dfa | comp_arc_sta | + +---------------+--------------+ | feat_saf_fmea | feat_arc_dyn | + +---------------+--------------+ | comp_saf_fmea | comp_arc_dyn | - - + +---------------+--------------+ .. tool_req:: FMEA: fault id attribute :id: tool_req__docs_saf_attr_fmea_fault_id diff --git a/src/extensions/score_draw_uml_funcs/__init__.py b/src/extensions/score_draw_uml_funcs/__init__.py index 067df1ea..d4f31f5e 100644 --- a/src/extensions/score_draw_uml_funcs/__init__.py +++ b/src/extensions/score_draw_uml_funcs/__init__.py @@ -60,7 +60,11 @@ def setup(app: Sphinx) -> dict[str, object]: - app.config.needs_render_context = draw_uml_function_context + # Extend the needs_render_context with our drawing functions + app.config.needs_render_context = ( + app.config.needs_render_context or {} + ) | draw_uml_function_context + return { "version": "0.1", "parallel_read_safe": True, diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index e7eed11d..631e5ebf 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -17,9 +17,11 @@ from pathlib import Path from sphinx.application import Sphinx +from sphinx.environment import BuildEnvironment from sphinx_needs import logging from sphinx_needs.data import NeedsInfoType, NeedsView, SphinxNeedsData +from src.extensions.score_metamodel.draw_metamodel import DrawMetamodel from src.extensions.score_metamodel.external_needs import connect_external_needs from src.extensions.score_metamodel.log import CheckLogger @@ -101,13 +103,6 @@ def _run_checks(app: Sphinx, exception: Exception | None) -> None: if exception: return - # First of all postprocess the need links to convert - # type names into actual need types. - # This must be done before any checks are run. - # And it must be done after config was hashed, otherwise - # the config hash would include recusive linking between types. - postprocess_need_links(app.config.needs_types) - # Filter out external needs, as checks are only intended to be run # on internal needs. needs_all_needs = SphinxNeedsData(app.env).get_needs_view() @@ -201,13 +196,17 @@ def _resolve_linkable_types( return linkable_types -def postprocess_need_links(needs_types_list: list[ScoreNeedType]): +def postprocess_need_links( + app: Sphinx, _env: BuildEnvironment, _docnames: list[str] +) -> None: """Convert link option strings into lists of target need types. If a link value starts with '^' it is treated as a regex and left unchanged. Otherwise it is a comma-separated list of type names which are resolved to the corresponding ScoreNeedTypes. """ + needs_types_list: list[ScoreNeedType] = app.config.needs_types + for need_type in needs_types_list: try: link_dicts = ( @@ -250,11 +249,25 @@ def setup(app: Sphinx) -> dict[str, str | bool]: app.config.needs_reproducible_json = True app.config.needs_json_remove_defaults = True + # Extend the needs_render_context with our drawing functions + app.config.needs_render_context = (app.config.needs_render_context or {}) | { + "draw_metamodel": DrawMetamodel(app.config), + } + # sphinx-collections runs on default prio 500. # We need to populate the sphinx-collections config before that happens. # --> 499 _ = app.connect("config-inited", connect_external_needs, priority=499) + # Postprocess the need links to convert type names into actual need types. + # This must be done before any checks are run. + # And it must be done after config was hashed, otherwise + # the config hash would include recusive linking between types. + # Note that needs_config_writer also runs at 999. Our postprocessing must happen + # after needs_config_writer has done its job (for now). + # So needs_config_writer must be included before score_metamodel in extensions list. + _ = app.connect("env-before-read-docs", postprocess_need_links, priority=999) + discover_checks() app.add_config_value( diff --git a/src/extensions/score_metamodel/diagram.py b/src/extensions/score_metamodel/diagram.py new file mode 100644 index 00000000..2a60ea1b --- /dev/null +++ b/src/extensions/score_metamodel/diagram.py @@ -0,0 +1,265 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 +# ******************************************************************************* + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Final + +# ============================================================ +# Model (renderer-independent) +# ============================================================ + + +class Visibility(str, Enum): + PUBLIC = "+" + PRIVATE = "-" + PROTECTED = "#" + PACKAGE = "~" + + +class RelationKind(str, Enum): + ASSOCIATION = "association" + UNDIRECTED = "undirected" + INHERITANCE = "inheritance" + IMPLEMENTS = "implements" + DEPENDENCY = "dependency" + COMPOSITION = "composition" + AGGREGATION = "aggregation" + + +@dataclass +class Member: + name: str + visibility: Visibility = Visibility.PUBLIC + type_hint: str | None = None + + +@dataclass +class ClassNode: + name: str + stereotype: str | None = None + members: list[Member] = field(default_factory=list) + + +@dataclass +class Relation: + src: str + dst: str + kind: RelationKind = RelationKind.ASSOCIATION + label: str | None = None + + +@dataclass +class ClassDiagram: + """ + Renderer-agnostic AST for class diagrams. + + Intentionally minimal: + - attributes only (no methods) + - no multiplicities + - no packages / namespaces + - no notes or comments + """ + + classes: dict[str, ClassNode] = field(default_factory=dict) + relations: list[Relation] = field(default_factory=list) + + # ---- class helpers ------------------------------------------------- + + def add_class(self, name: str, *, stereotype: str | None = None) -> ClassNode: + return self._ensure_class(name, stereotype=stereotype) + + def _ensure_class(self, name: str, *, stereotype: str | None = None) -> ClassNode: + if not name: + raise ValueError("class name must not be empty") + + node = self.classes.get(name) + if node is None: + node = ClassNode(name=name, stereotype=stereotype) + self.classes[name] = node + return node + + if stereotype is not None and node.stereotype is None: + node.stereotype = stereotype + return node + + # ---- members ------------------------------------------------------- + + def add_member( + self, + class_name: str, + member_name: str, + *, + visibility: Visibility = Visibility.PUBLIC, + type_hint: str | None = None, + ) -> None: + if not member_name: + raise ValueError("member name must not be empty") + + node = self._ensure_class(class_name) + node.members.append(Member(member_name, visibility, type_hint)) + + # ---- relations ----------------------------------------------------- + + def relate( + self, + src: str, + dst: str, + *, + kind: RelationKind = RelationKind.ASSOCIATION, + label: str | None = None, + ) -> None: + if not src or not dst: + raise ValueError("relation endpoints must not be empty") + + self._ensure_class(src) + self._ensure_class(dst) + self.relations.append(Relation(src=src, dst=dst, kind=kind, label=label)) + + +# ============================================================ +# PlantUML renderer +# ============================================================ + + +class PlantUmlRenderer: + """ + Renders a ClassDiagram into PlantUML. + """ + + _ARROWS: Final[dict[RelationKind, str]] = { + RelationKind.ASSOCIATION: "-->", + RelationKind.UNDIRECTED: "--", + RelationKind.INHERITANCE: "<|--", + RelationKind.IMPLEMENTS: "<|..", + RelationKind.DEPENDENCY: "..>", + RelationKind.COMPOSITION: "*--", + RelationKind.AGGREGATION: "o--", + } + + def render(self, diagram: ClassDiagram) -> str: + """Render a ClassDiagram as PlantUML text.""" + classes = sorted(diagram.classes.values(), key=lambda c: c.name) + relations = sorted( + diagram.relations, + key=lambda r: (r.src, r.dst, r.kind.value, r.label or ""), + ) + + alias = self._AliasMap() + + lines: list[str] = [ + # "@startuml", <- added by needuml! + # "skinparam linetype ortho", + ] + + for c in classes: + cid = alias[c.name] + display = self._quote(c.name) + + if c.stereotype: + s = self._escape_stereotype(c.stereotype) + lines.append(f"class {cid} as {display} <<{s}>> {{") + else: + lines.append(f"class {cid} as {display} {{") + + for m in c.members: + if m.type_hint: + lines.append(f" {m.visibility.value} {m.name} : {m.type_hint}") + else: + lines.append(f" {m.visibility.value} {m.name}") + lines.append("}") + + for r in relations: + src = alias[r.src] + dst = alias[r.dst] + arrow = self._ARROWS[r.kind] + + if r.label: + lines.append(f"{src} {arrow} {dst} : {self._escape_label(r.label)}") + else: + lines.append(f"{src} {arrow} {dst}") + + # lines.append("@enduml") <- added by needuml! + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _quote(text: str) -> str: + """Quote a PlantUML display name.""" + return f'"{text.replace('"', r"\"")}"' + + @staticmethod + def _escape_label(text: str) -> str: + """Escape a link label for PlantUML.""" + return text.replace("\n", " ").replace("\r", " ").replace(":", r"\:") + + @staticmethod + def _escape_stereotype(text: str) -> str: + """Escape a stereotype for << >> usage.""" + return ( + text.replace("\n", " ") + .replace("\r", " ") + .replace("<<", "< <") + .replace(">>", "> >") + ) + + class _AliasMap: + """ + Dict-like mapping from class names to readable PlantUML identifiers. + + Accessing a key ensures the alias exists: + alias["My Class"] -> "My_Class" + """ + + def __init__(self) -> None: + """Create a new alias map.""" + self._map: dict[str, str] = {} + self._used: set[str] = set() + + def __getitem__(self, name: str) -> str: + """Return the alias for a class name, creating it if needed.""" + if name not in self._map: + self._map[name] = self._create_alias(name) + return self._map[name] + + # ------------------------ + # internals + # ------------------------ + + def _create_alias(self, name: str) -> str: + """Create a new readable, collision-free alias.""" + base = self._sanitize(name) + alias = base + counter = 2 + + while alias in self._used: + alias = f"{base}_{counter}" + counter += 1 + + self._used.add(alias) + return alias + + def _sanitize(self, name: str) -> str: + """Convert an arbitrary name into a readable identifier.""" + VALID_CHARS = re.compile(r"[A-Za-z0-9_]") + chars = [ch if VALID_CHARS.fullmatch(ch) else "_" for ch in name] + alias = "".join(chars).strip("_") + if not alias: + raise ValueError(f"Cannot create alias for name: {name!r}") + return alias diff --git a/src/extensions/score_metamodel/draw_metamodel.py b/src/extensions/score_metamodel/draw_metamodel.py new file mode 100644 index 00000000..ed5efcf5 --- /dev/null +++ b/src/extensions/score_metamodel/draw_metamodel.py @@ -0,0 +1,112 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 +# ******************************************************************************* + +from metamodel_types import ScoreNeedType +from sphinx.config import Config + +from .diagram import ( + ClassDiagram, + PlantUmlRenderer, + Visibility, +) + + +class DrawMetamodel: + def __init__(self, config: Config): + self._config = config + + def __repr__(self) -> str: + # avoid sphinx caching a function pointer which is different on every build + return "draw_metamodel" + + def _get_need_types(self, types: str | list[str]) -> list[ScoreNeedType]: + if isinstance(types, str): + types = [types] + + if len(types) == 0: + raise ValueError(f"No need types found for directives: {types}") + + need_types: list[ScoreNeedType] = [] + for nt in self._config.needs_types: + if nt["directive"] in types: + need_types.append(nt) + + return need_types + + def _add_attributes_to_class( + self, + diagram: ClassDiagram, + class_name: str, + nt: ScoreNeedType, + attributes: list[str], + ) -> None: + for opt, allowed_values in nt.get("mandatory_options", {}).items(): + if opt in attributes: + diagram.add_member( + class_name, + opt, + type_hint=allowed_values, + visibility=Visibility.PUBLIC, + ) + + for opt, allowed_values in nt.get("optional_options", {}).items(): + if opt in attributes: + diagram.add_member( + class_name, + opt, + type_hint=allowed_values, + visibility=Visibility.PRIVATE, + ) + + def _add_links_to_class( + self, + diagram: ClassDiagram, + class_name: str, + nt: ScoreNeedType, + links: list[str], + ) -> None: + all_links = nt.get("mandatory_links", {}) | nt.get("optional_links", {}) + + selected_links = { + k: v for k, v in all_links.items() if links == "all" or k in links + } + + for link_name, link_targets in selected_links.items(): + for target in link_targets: + target_name = target if isinstance(target, str) else target["directive"] + + diagram.relate(class_name, target_name, label=link_name) + + def __call__( + self, + types: str | list[str], + *, + attributes: list[str] | None = None, + links: list[str] | None = None, + ) -> str: + need_type_objects = self._get_need_types(types) + + diagram = ClassDiagram() + for nt in need_type_objects: + class_name = nt["directive"] + title = nt.get("title") + + diagram.add_class(class_name, stereotype=title) + + if attributes: + self._add_attributes_to_class(diagram, class_name, nt, attributes) + + if links: + self._add_links_to_class(diagram, class_name, nt, links) + + return PlantUmlRenderer().render(diagram) diff --git a/src/extensions/score_metamodel/tests/test_diagram.py b/src/extensions/score_metamodel/tests/test_diagram.py new file mode 100644 index 00000000..c9d1a456 --- /dev/null +++ b/src/extensions/score_metamodel/tests/test_diagram.py @@ -0,0 +1,79 @@ +# ******************************************************************************* +# Copyright (c) 2025 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 +# ******************************************************************************* + +from diagram import ( + ClassDiagram, + PlantUmlRenderer, + RelationKind, + Visibility, +) + + +def test_simple_class_diagram_snapshot(): + d = ClassDiagram() + d.add_class("A") + d.add_member("A", "id") + d.relate("A", "B", label="uses") + + result = PlantUmlRenderer().render(d) + + assert ( + result + == """\ +class A as "A" { + + id +} +class B as "B" { +} +A --> B : uses +""" + ) + + +def test_stereotype_and_private_member(): + d = ClassDiagram() + d.add_class("User", stereotype="Entity") + d.add_member("User", "password", visibility=Visibility.PRIVATE) + + result = PlantUmlRenderer().render(d) + + print(repr(result)) + + assert ( + result + == """\ +class User as "User" <> { + - password +} +""" + ) + + +def test_inheritance_relation(): + d = ClassDiagram() + d.relate("Base", "Derived", kind=RelationKind.INHERITANCE) + + result = PlantUmlRenderer().render(d) + + print(repr(result)) + + assert ( + result + == """\ +class Base as "Base" { +} +class Derived as "Derived" { +} +Base <|-- Derived +""" + ) diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index bba802cc..7ac4c130 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -16,6 +16,10 @@ # Extensions are loaded in this order. # e.g. plantuml MUST be loaded before sphinx-needs score_extensions = [ + # needs_config_writer must be loaded before score_metamodel + # due to conflict in priorities. + "needs_config_writer", + "score_sync_toml", "sphinxcontrib.plantuml", "score_plantuml", "sphinx_needs", @@ -27,8 +31,6 @@ "score_layout", "sphinx_collections", "sphinxcontrib.mermaid", - "needs_config_writer", - "score_sync_toml", ]