Skip to content
Open
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
20 changes: 19 additions & 1 deletion .github/skills/score-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ One target bundles every diagram kind (from [`examples/seooc/design/BUILD`](../.
```starlark
architectural_design(
name = "sample_seooc_design",
static = ["static_design.puml", "index.md"],
static = ["static_design.puml", "overview_design.puml", "index.md"],
dynamic = ["dynamic_design.puml"],
public_api = ["public_api.puml", "public_api.rst"],
internal_api = ["internal_api.puml"],
Expand All @@ -339,6 +339,24 @@ architectural_design(
)
```

`static` accepts more than one `.puml` file. They are merged by entity id (the full parent-alias
dot-path) into a single architecture: re-declaring the same entity (same id) in more than one
file is allowed and merges its relations, as long as `stereotype`/element type agree everywhere
it's declared — this is how `overview_design.puml` above can bare-declare the SEooC and its
top-level component while `static_design.puml` elaborates their internals, without duplicating
every nested unit and relation in both files. A parent whose children are split across files with
no single file containing all of them is an error. Re-nesting an entity under a different parent
across files is *not* caught here (different parent ⇒ different id ⇒ a different entity) — that
class of mistake instead surfaces as a Bazel ↔ diagram mismatch (extra/missing entity) in the
`bazel_component` check below.

Each `.puml` file is parsed and resolved on its own, *before* the id-based merge above runs — a
relation may only reference an alias declared in that same file. Referencing an alias that's only
declared in another `static` file fails at PlantUML parse time (`Element Resolver:
UnresolvedReference: <alias>`), not as a Design validation error. So keep each file
self-contained: if a detail file wires up an entity's interfaces, (re-)declare those interfaces
in that same file rather than assuming they're visible from the overview file.

`static`/`dynamic` accept `.puml`, `.plantuml`, `.png`, `.svg`, `.rst`, `.md`. To combine a
diagram with prose, add both the RST/Markdown wrapper *and* the referenced `.puml` to the same
list (as `public_api.puml` + `public_api.rst` above, which overrides `public_api.puml`'s
Expand Down
1 change: 1 addition & 0 deletions bazel/rules/rules_score/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Rules SCORE for Bazel

tool_reference/specs/bazel_component
tool_reference/specs/class_design_implementation
tool_reference/specs/component_model
tool_reference/specs/component_internal_api
tool_reference/specs/component_public_api
tool_reference/specs/component_sequence
Expand Down
4 changes: 4 additions & 0 deletions bazel/rules/rules_score/docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ Bazel/C++ implementation:
- **Bazel ↔ static design** — every ``component``/``unit`` target must appear in
the static PlantUML diagram and vice versa
(:doc:`spec <tool_reference/specs/bazel_component>`).
- **Static design merge** — ``static`` may list more than one PlantUML file
(e.g. a boundary overview plus detail diagrams); they are merged by entity
id before the checks below run
(:doc:`spec <tool_reference/specs/component_model>`).
- **Static ↔ public/internal API** — interfaces referenced in the static design
must be declared by the public/internal API class diagrams
(:doc:`public API spec <tool_reference/specs/component_public_api>`,
Expand Down
43 changes: 42 additions & 1 deletion bazel/rules/rules_score/docs/rule_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ and ``fmea``.
* - ``static``
- label list
- no
- Static-view files (``.puml``, ``.rst``, ``.md``, ``.svg``, ``.png``) (default ``[]``)
- Static-view files (``.puml``, ``.rst``, ``.md``, ``.svg``, ``.png``) (default ``[]``). Multiple ``.puml`` files are allowed — e.g. a boundary overview diagram alongside a detailed one — and are merged into a single architecture, see `Multiple static PlantUML files`_ below.
* - ``dynamic``
- label list
- no
Expand All @@ -443,6 +443,47 @@ and ``fmea``.

**Generated targets:** ``<name>`` (provides ``ArchitecturalDesignInfo``; no standalone test — consistency is validated as part of ``bazel test //pkg:my_element``)

.. _multiple-static-puml-files:

Multiple static PlantUML files
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

``static`` accepts more than one ``.puml`` file. This is intended for splitting
a large architecture into a boundary **overview** diagram (the SEooC and its
public interfaces only) and one or more **detail** diagrams that elaborate the
internals, without repeating every relation and nested entity in both places.

The files are parsed independently and then merged by entity id (the full
dot-path of parent aliases). Rules for the merge:

* Re-declaring the *same* entity (identical id) in more than one file is
allowed as long as its ``stereotype`` and element type agree across files;
their relations are unioned (duplicate relations are de-duplicated). This
is what lets an overview file bare-declare a component or unit that a
detail file elaborates further.
* Re-declaring the same alias with a conflicting ``stereotype`` or element
type across files is an error.
* A parent whose children are declared across more than one file is only
allowed if a single file contains the full set of children (i.e. one file
is the "home" file and the others only add a benign subset); if the
children are genuinely split with no file containing all of them, this is
reported as an error.
* Re-nesting an entity under a *different* parent in another file is **not**
detected as a same-entity conflict, because the id encodes the parent
chain — a different parent means a different id, hence a different
entity. Such "wrong nesting" mistakes are instead caught by the existing
Bazel-vs-diagram comparison (an entity nested under the wrong parent shows
up as an extra/missing entry there).

Each ``.puml`` file is parsed and resolved **independently**, before the
merge step above ever runs. A relation (``-->``, ``..>``, etc.) may only
reference aliases declared in the *same* file — an alias declared only in
another ``static`` file is not visible yet at that point. Referencing such
an alias fails at PlantUML parse time with an ``Element Resolver:
UnresolvedReference: <alias>`` error, not as a Design validation error. Keep
every file self-contained: if a detail file wires up an entity's
interfaces, declare (or re-declare) those interfaces in that same file.

.. _rule-unit-design:

unit_design
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,70 @@ When an element needs an explicitly named, standalone binding point — for exam

@enduml

.. _overview-and-detail-diagrams:

Splitting Into an Overview and Detail Diagrams
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For a large architecture, pass more than one file to ``static``: one shared
**overview** diagram that bare-declares the SEooC boundary and its top-level
components, plus one or more **detail** diagrams that elaborate a component's
internals. Each file is parsed independently and then merged by entity id, so
the same component can appear in both without repeating its full contents:

.. code-block:: text

' overview_design.puml
@startuml overview_design

package "Safety Software SEooC Example" as safety_software_seooc_example <<SEooC>> {
component "ComponentExample" as component_example <<component>>
}

interface "SampleLibraryAPI" as SampleLibraryAPI
safety_software_seooc_example )-d- SampleLibraryAPI

@enduml

.. code-block:: text

' static_design.puml
@startuml static_design

package "Safety Software SEooC Example" as safety_software_seooc_example <<SEooC>> {
component "ComponentExample" as component_example <<component>> {
component "Unit 1" as unit_1 <<unit>>
component "Unit 2" as unit_2 <<unit>>
}
}

@enduml

.. code-block:: starlark

architectural_design(
name = "my_arch",
static = ["overview_design.puml", "static_design.puml"],
)

See ``examples/seooc/design`` for the full working pair.

**Limitations:**

- Exactly one file must be the "home" for a given parent's full set of
children — one diagram declares *all* of a parent's children; the others
may only bare-declare that parent (no children) or repeat that same full
set. Two files each declaring a different, incomplete subset of the same
parent's children is rejected as a merge error — in practice, use one
shared overview file plus one or more detail files that each own a
disjoint part of the hierarchy, not several files partially detailing the
same component.
- A relation may only reference aliases declared in the *same* file — an
overview cannot wire up a relation to an alias that only a detail file
declares. Keep each file self-contained.

See :ref:`multiple-static-puml-files` for the complete merge rules.

Bazel
~~~~~~

Expand All @@ -330,6 +394,10 @@ architectural_design
dynamic = ["sequence_design.puml"],
)

``static`` accepts more than one ``.puml`` file — see
:ref:`overview-and-detail-diagrams` above for splitting a large architecture
into an overview plus detail diagrams, and their limitations.

unit
^^^^^

Expand Down
1 change: 1 addition & 0 deletions bazel/rules/rules_score/examples/seooc/design/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ architectural_design(
],
static = [
"static_design.puml",
"overview_design.puml",
"index.md",
],
visibility = ["//visibility:public"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
' *******************************************************************************
' 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
' *******************************************************************************

@startuml overview_design

' Boundary overview: the SEooC and its public API only. Internals are
' elaborated in static_design.puml; every entity here is also declared there.

package "Safety Software SEooC Example" as safety_software_seooc_example <<SEooC>> {
component "ComponentExample" as component_example <<component>>
}

interface "SampleLibraryAPI" as SampleLibraryAPI

safety_software_seooc_example )-d- SampleLibraryAPI

@enduml
16 changes: 12 additions & 4 deletions plantuml/sphinx/clickable_plantuml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ Sphinx build lifecycle clickable_plantuml hooks
│ (per document) For each plantuml node, load its idmap.
│ For each reference entry, look up the
│ definition index (FQN first, then alias).
│ Apply proximity tiebreak on ambiguity.
│ Apply proximity, then descendant-count
│ tiebreak on ambiguity.
│ Build the URL (relative to _images/ in
│ svg_obj mode, else page-relative via
│ get_relative_uri), then append
Expand Down Expand Up @@ -133,9 +134,16 @@ Sphinx build lifecycle clickable_plantuml hooks
reference in a diagram's idmap, resolves the unique definer via the index.
When multiple diagrams define the same element, a *proximity tiebreak*
selects the definer sharing the longest common path prefix with the source
diagram. On a genuine tie, no link is emitted (safe over wrong). URLs are
built relative to `_images/` in `svg_obj` mode (else page-relative via
`app.builder.get_relative_uri()`) and percent-encoded before injection.
diagram (this is the case for a reference resolved across multiple
`static` files merged into one architecture, see the `component_model`
validator spec). If proximity still ties (e.g. candidates in the same
directory), a *descendant-count tiebreak* prefers the candidate whose
idmap elaborates more nested entries under the referenced id — this can
change which file a link points to compared to earlier releases, where a
proximity tie always meant no link. On a genuine tie after both stages, no
link is emitted (safe over wrong). URLs are built relative to `_images/`
in `svg_obj` mode (else page-relative via `app.builder.get_relative_uri()`)
and percent-encoded before injection.

4. **Incremental / parallel support** – `env-purge-doc` removes stale entries
when a document is re-read; `env-merge-info` merges state from parallel
Expand Down
85 changes: 58 additions & 27 deletions plantuml/sphinx/clickable_plantuml/clickable_plantuml.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@
3. If exactly one definer: emit the link.
4. If multiple definers: pick the one sharing the longest common workspace-
relative path prefix with the source diagram (proximity tiebreak).
On a tie: log a warning and emit no link (safe over wrong).
5. Never link a diagram to itself.
5. If proximity still ties (e.g. same directory): pick the one with the most
idmap entries nested under the referenced id (descendant-count tiebreak) —
the file that elaborates more of that id's decomposition is assumed to be
the more useful navigation target.
6. If still tied: log a warning and emit no link (safe over wrong).
7. Never link a diagram to itself.
"""

from __future__ import annotations
Expand All @@ -44,6 +48,7 @@
import os
import re
import urllib.parse
from collections.abc import Callable
from pathlib import Path, PurePosixPath
from typing import Any

Expand Down Expand Up @@ -122,39 +127,55 @@ def _common_prefix_length(path_a: str, path_b: str) -> int:
return count


def _proximity_tiebreak(source: str, candidates: list[str]) -> str | None:
"""Pick the candidate with the longest common prefix with *source*.
def _tied_top(candidates: list[str], score: Callable[[str], int]) -> tuple[int | None, list[str]]:
"""Return the max score and every candidate achieving it (``(None, [])`` if empty).

Shared by both tiebreak stages in :func:`_resolve_definer`.
"""
best_score: int | None = None
best: list[str] = []
for candidate in candidates:
candidate_score = score(candidate)
if best_score is None or candidate_score > best_score:
best_score = candidate_score
best = [candidate]
elif candidate_score == best_score:
best.append(candidate)
return best_score, best


def _proximity_score(source: str, candidate: str) -> int:
"""Common-prefix-length score used by the proximity tiebreak stage.

All inputs are canonical workspace-relative POSIX keys (guaranteed by the
exact-matching in P0-1); the assertions guard that invariant so a staging
path can never sneak into the comparison. Returns ``None`` when two or
more candidates score equally (tie → no link).
path can never sneak into the comparison.
"""
_assert_canonical_source_key(source)
best_candidate: str | None = None
best_score = -1
has_tie = False
_assert_canonical_source_key(candidate)
return _common_prefix_length(source, candidate)

for candidate in candidates:
_assert_canonical_source_key(candidate)
score = _common_prefix_length(source, candidate)
if score > best_score:
best_score = score
best_candidate = candidate
has_tie = False
elif score == best_score:
has_tie = True

if has_tie or best_candidate is None:
return None
return best_candidate

def _descendant_count(idmap_by_source: dict[str, Any], source_key: str, fqn: str) -> int:
"""Count *source_key*'s idmap entries nested under *fqn* (id starts with ``f"{fqn}."``).

Second tiebreak stage in :func:`_resolve_definer`: prefers the file that
elaborates more of that id's decomposition.
"""
idmap = idmap_by_source.get(source_key)
if idmap is None:
return 0
prefix = f"{fqn}."
entries = idmap.get("defines", []) + idmap.get("references", [])
return sum(1 for entry in entries if entry.get("id", "").startswith(prefix))


def _resolve_definer(
alias: str,
fqn: str,
source_key: str,
definition_index: dict[str, list[str]],
idmap_by_source: dict[str, Any] | None = None,
) -> str | None:
"""Return the definer source key for one reference, or ``None``.

Expand All @@ -167,9 +188,9 @@ def _resolve_definer(
while a distinct diagram elaborates it under a shared alias).
* A diagram never links to itself (self-links are dropped from both
lookups).
* A single remaining candidate wins outright; multiple candidates go
through the proximity tiebreak, and a genuine tie logs a warning and
returns ``None`` (safe over wrong).
* A single remaining candidate wins outright. Multiple candidates go
through the proximity tiebreak, then the descendant-count tiebreak;
a genuine tie after both logs a warning and returns ``None``.
"""
_assert_canonical_source_key(source_key)

Expand All @@ -184,7 +205,17 @@ def _candidates_for(key: str) -> list[str]:
return None
if len(candidates) == 1:
return candidates[0]
target = _proximity_tiebreak(source_key, candidates)

proximity_group = _tied_top(candidates, lambda c: _proximity_score(source_key, c))[1]
target: str | None = proximity_group[0] if len(proximity_group) == 1 else None

if target is None and idmap_by_source:
descendant_score, descendant_group = _tied_top(
proximity_group, lambda c: _descendant_count(idmap_by_source, c, fqn)
)
if len(descendant_group) == 1 and descendant_score > 0:
target = descendant_group[0]

if target is None:
logger.warning(
"clickable_plantuml: ambiguous definition for '%s' in '%s' — tied candidates %s; no link emitted",
Expand Down Expand Up @@ -617,7 +648,7 @@ def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> N
if not alias or alias in seen_aliases_in_node:
continue

target_source = _resolve_definer(alias, fqn, source_key, definition_index)
target_source = _resolve_definer(alias, fqn, source_key, definition_index, idmap_by_source)
if target_source is None:
continue

Expand Down
Loading
Loading