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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions src/extensions/score_metamodel/docs/generate_metamodel_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import sys
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, replace
from hashlib import sha256
from pathlib import Path
from typing import Any, cast

Expand Down Expand Up @@ -78,6 +79,22 @@ def _split_targets(targets: str) -> list[str]:
return [target.strip() for target in targets.split(",") if target.strip()]


def _mermaid_identifier(name: str) -> str:
"""Return a Mermaid-safe identifier for a metamodel type name.

Metamodel type names are also directive names and may contain hyphens.
Mermaid class declarations accept those names, but Mermaid's ``style``
statements do not accept a hyphenated target. A short stable digest
distinguishes names that normalize to the same identifier, while the
original name remains the displayed class label.
"""
normalized = name.replace("-", "_")
if normalized == name:
return name
suffix = sha256(name.encode("utf-8")).hexdigest()[:8]
return f"{normalized}_{suffix}"


@dataclass(frozen=True)
class NeedLink:
"""A link definition belonging to one need type."""
Expand Down Expand Up @@ -209,6 +226,7 @@ class MermaidNode:
"""A generic class-diagram node."""

name: str
identifier: str
attributes: tuple[str, ...] = ()
optional_attributes: tuple[str, ...] = ()
color: str | None = None
Expand Down Expand Up @@ -284,8 +302,13 @@ def render(self, diagram: MermaidDiagram) -> str:
def _class_declarations(self, diagram: MermaidDiagram) -> list[str]:
lines: list[str] = []
for node in sorted(diagram.nodes, key=lambda item: item.name):
label = (
f'["{self._escape_label(node.name)}"]'
if node.identifier != node.name
else ""
)
if node.attributes or node.optional_attributes:
lines.append(f"class {node.name} {{")
lines.append(f"class {node.identifier}{label} {{")
lines.extend(f" +{attribute}" for attribute in sorted(node.attributes))
# Mermaid's trailing '*' member classifier renders the complete
# attribute line in italics. Keep the textual marker as part
Expand All @@ -298,29 +321,32 @@ def _class_declarations(self, diagram: MermaidDiagram) -> list[str]:
)
lines.append("}")
else:
lines.append(f"class {node.name}")
lines.append(f"class {node.identifier}{label}")
return lines

def _edge_declarations(self, diagram: MermaidDiagram) -> list[str]:
lines: list[str] = []
seen: set[tuple[str, str, str]] = set()
identifiers = {node.name: node.identifier for node in diagram.nodes}
for edge in diagram.edges:
key = (edge.source, edge.target, edge.label)
if key in seen:
continue
seen.add(key)
arrow = "..>" if edge.optional else "-->"
lines.append(f"{edge.source} {arrow} {edge.target} : {edge.label}")
source = identifiers.get(edge.source, edge.source)
target = identifiers.get(edge.target, edge.target)
lines.append(f"{source} {arrow} {target} : {edge.label}")
return lines

def _style_declarations(self, diagram: MermaidDiagram) -> list[str]:
lines = [
f"style {node.name} fill:{node.color},stroke:#666,color:#000"
f"style {node.identifier} fill:{node.color},stroke:#666,color:#000"
for node in sorted(diagram.nodes, key=lambda item: item.name)
if node.color and node.style_override is None
]
lines.extend(
f"style {node.name} {node.style_override}"
f"style {node.identifier} {node.style_override}"
for node in sorted(diagram.nodes, key=lambda item: item.name)
if node.style_override
)
Expand All @@ -331,12 +357,17 @@ def _link_declarations(self, diagram: MermaidDiagram) -> list[str]:
for node in sorted(diagram.nodes, key=lambda item: item.name):
if node.href is None:
continue
line = f'click {node.name} href "{node.href}"'
line = f'click {node.identifier} href "{node.href}"'
if node.tooltip:
line += f' "{node.tooltip}"'
lines.append(line)
return lines

@staticmethod
def _escape_label(value: str) -> str:
"""Escape a type name used inside a Mermaid quoted class label."""
return value.replace("\\", "\\\\").replace('"', '\\"')


@dataclass(frozen=True)
class NeedDiagramBuilder:
Expand Down Expand Up @@ -375,6 +406,7 @@ def build(self, focal_name: str | None = None) -> MermaidDiagram:
def _node(need_type: NeedType) -> MermaidNode:
return MermaidNode(
name=need_type.name,
identifier=_mermaid_identifier(need_type.name),
attributes=tuple(need_type.mandatory_options),
optional_attributes=tuple(need_type.optional_options),
color=need_type.color,
Expand Down
49 changes: 42 additions & 7 deletions src/extensions/score_metamodel/tests/test_rules_file_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

import re
import shutil
from collections.abc import Callable
import sys
from collections.abc import Callable, Collection
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast
Expand All @@ -32,6 +33,7 @@

# Relative paths of all rst files in RST_DIR
RST_FILES = [str(f.relative_to(RST_DIR)) for f in Path(RST_DIR).rglob("*.rst")]
_NEED_DIRECTIVE_PATTERN = re.compile(r"^\s*\.\.\s+([A-Za-z][\w-]*)::")

@MaximilianSoerenPollak MaximilianSoerenPollak Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No way this does only what it should.

But I don't think it matters here, as this is just a display helper, and if the regex is wrong it wont break something important.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is our tests. so it can only break if we change the test-rst files to something incompatible.

And since this matches too much as you mentioned, there is a check for match.group(1) in need_directive_names



@pytest.fixture
Expand Down Expand Up @@ -94,17 +96,47 @@ class RstData:
metadata: dict[str, list[str] | str] = field(default_factory=dict)


def count_need_objects(rst_file: Path) -> RstData:
def count_need_objects(rst_file: Path, need_directives: Collection[str]) -> RstData:
rst_data = RstData(filename=str(rst_file.relative_to(RST_DIR)))
need_directive_names = set(need_directives) | {"needextend"}
with open(rst_file) as f:
for no, line in enumerate(f, start=1):
# Beginning of new need
# We filter for '::' as well so we ONLY get directives not comments
if line.startswith(".. ") and "::" in line:
# Nested Needs are indented below their containing Need. Parse the
# directive name so nested notes, code blocks, and other ordinary
# reStructuredText directives are not mistaken for Needs.
match = _NEED_DIRECTIVE_PATTERN.match(line)
if match and match.group(1) in need_directive_names:
rst_data.found_objects.append(no)
return rst_data


def test_count_need_objects_ignores_nested_non_need_directives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only configured Need directives count, including an explicit needextend."""
monkeypatch.setattr(sys.modules[__name__], "RST_DIR", tmp_path)
rst_file = tmp_path / "nested.rst"
rst_file.write_text(
""".. stkh_req:: Parent
:id: stkh_req__parent

.. note::
This ordinary directive must not be counted.

.. code-block:: text

This ordinary directive must not be counted either.

.. needextend:: stkh_req__parent
""",
encoding="utf-8",
)

rst_data = count_need_objects(rst_file, {"stkh_req"})

assert rst_data.found_objects == [1, 11]


def filter_warnings_by_position(
rst_data: RstData,
line_nr: int,
Expand Down Expand Up @@ -284,13 +316,16 @@ def test_rst_files(
request: pytest.FixtureRequest,
) -> None:
### Build the given rst file with Sphinx and check expected/unexpected warnings.
rst_data = count_need_objects(RST_DIR / rst_file)

# Build the documentation
app = sphinx_app_setup(RST_DIR / rst_file)
monkeypatch.chdir(app.srcdir) # Sphinx resolves paths relative to the source dir
app.build()

need_directives = {
str(need_type["directive"]) for need_type in app.config.needs_types
}
rst_data = count_need_objects(RST_DIR / rst_file, need_directives)

# Get & parse metadata needs
needs_data = SphinxNeedsData(app.env)
needs_view = needs_data.get_needs_view()
Expand Down
4 changes: 2 additions & 2 deletions src/extensions/score_sphinx_bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ def setup(app: Sphinx) -> dict[str, object]:
# Global settings
# Note: the "sub-extensions" also set their own config values

# Same as current VS Code extension
config_setdefault(app.config, "mermaid_version", "11.6.0")
# Match the current GitHub and VS Code Mermaid renderers.
config_setdefault(app.config, "mermaid_version", "11.17.2")
config_setdefault(app.config, "mermaid_d3_zoom", True)
config_setdefault(app.config, "mermaid_include_elk", True)

Expand Down
8 changes: 8 additions & 0 deletions src/extensions/score_sphinx_needs_templates/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# *******************************************************************************
load("@aspect_rules_py//py:defs.bzl", "py_library")
load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements")
load("//:score_pytest.bzl", "score_pytest")

filegroup(
name = "all_sources",
Expand All @@ -28,3 +29,10 @@ py_library(
"@score_docs_as_code//src/helper_lib",
],
)

score_pytest(
name = "unit_tests",
srcs = ["tests/test_needs_templates.py"],
deps = [":score_sphinx_needs_templates"],
pytest_config = "//:pyproject.toml",
)
5 changes: 4 additions & 1 deletion src/extensions/score_sphinx_needs_templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The extension provides:
* the shared `src/needs_templates` directory as the Sphinx-Needs template
directory;
* the `linked_needs(need_id, link_name)` helper for traversing Need links;
* the `needs_of_type(need_type)` helper for selecting Needs by type;
* support for graph-driven `post_template`s that are rendered after parallel
Need collection has been merged;
* ordinary Sphinx page navigation for sections generated by those
Expand Down Expand Up @@ -51,7 +52,9 @@ with Sphinx-Needs' `:post_template:` option. The extension then purges and
rereads the affected report page once after parallel Need collection has been
merged, so `linked_needs` can see the complete model.

The generated content should use normal reStructuredText sections instead of
Both outgoing links and backlink fields ending in ``_back`` can be traversed,
which is useful for generated testcase evidence and nested Need children. The
generated content should use normal reStructuredText sections instead of
rubrics. Sphinx-Needs parses `post_template` output after the Need and with
section matching enabled, so section IDs and the local page ToC are collected
by Sphinx itself. Ordinary `:template:` use keeps its normal Sphinx-Needs
Expand Down
Loading