diff --git a/bazel/rules/rules_score/README.md b/bazel/rules/rules_score/README.md
index 36b58507..717b48a1 100644
--- a/bazel/rules/rules_score/README.md
+++ b/bazel/rules/rules_score/README.md
@@ -98,6 +98,25 @@ architectural_design(
Diagrams in `public_api` are classified separately so their lobster items flow
through `public_api_lobster_files` for failure-mode traceability.
+`static_view` is an optional additional section for component diagrams that
+present a partial view of the static architecture (e.g. a diagram scoped to a
+subsystem). Diagrams passed to `static_view` are parsed like `static`, but are
+never used to define the units/components validated against the Bazel
+component graph. Instead, every component/unit defined in a `static_view`
+diagram must also be defined, under the same parent, in `static`: it may only
+contain a subset of the units/components of the matching `static` diagram.
+**`bazel build`** fails if a `static_view` diagram introduces a
+component/unit that is not present in `static`.
+
+The `static_view` section can be used for creating additional diagrams that
+provide a view onto the architecture which make the design easier to view / understand.
+E.g. you can create a diagram which shows a subset of components as showing all
+components in one view may be too "busy". It can also be useful when showing the
+interfaces between components. Adding all the interfaces in the diagrams in the
+`static` view may result in too many interface lines which is not readable. Instead,
+a view can be created with a subset of components and only the interfaces between these
+chosen components can be shown.
+
---
## `unit`
diff --git a/bazel/rules/rules_score/docs/rule_reference.rst b/bazel/rules/rules_score/docs/rule_reference.rst
index ff28409a..9255eaf8 100644
--- a/bazel/rules/rules_score/docs/rule_reference.rst
+++ b/bazel/rules/rules_score/docs/rule_reference.rst
@@ -390,15 +390,16 @@ Example glossary source (``.rst``):
architectural_design
~~~~~~~~~~~~~~~~~~~~
-Bundles static, dynamic, public-API, and internal-API architecture views into a
-single target. Provides ``ArchitecturalDesignInfo`` consumed by ``dependable_element``
-and ``fmea``.
+Bundles static, dynamic, static-view, public-API, and internal-API architecture
+views into a single target. Provides ``ArchitecturalDesignInfo`` consumed by
+``dependable_element`` and ``fmea``.
.. code-block:: python
architectural_design(
name = "arch",
static = ["docs/static_design.puml"],
+ static_view = ["docs/subsystem_view.puml"],
dynamic = ["docs/sequence.puml"],
public_api = ["docs/public_api.puml"],
internal_api = ["docs/internal_api.puml"],
@@ -420,6 +421,10 @@ and ``fmea``.
- label list
- no
- Static-view files (``.puml``, ``.rst``, ``.md``, ``.svg``, ``.png``) (default ``[]``)
+ * - ``static_view``
+ - label list
+ - no
+ - Component diagrams (``.puml``, ``.plantuml``) that present a partial view of the static architecture. These can be used to create smaller diagrams which highlight a subset of all components / units to improve readability / understandability. Components and units defined in a static view must also be defined under the same parent in ``static`` (default ``[]``)
* - ``dynamic``
- label list
- no
diff --git a/bazel/rules/rules_score/private/architectural_design.bzl b/bazel/rules/rules_score/private/architectural_design.bzl
index 38a9c197..21f69728 100644
--- a/bazel/rules/rules_score/private/architectural_design.bzl
+++ b/bazel/rules/rules_score/private/architectural_design.bzl
@@ -23,7 +23,7 @@ to produce FlatBuffers binary representations of the parsed diagrams.
"""
load("//bazel/rules/rules_score:providers.bzl", "ArchitecturalDesignInfo", "SphinxSourcesInfo")
-load("//bazel/rules/rules_score/private:puml_utils.bzl", "make_puml_rst_wrappers")
+load("//bazel/rules/rules_score/private:puml_utils.bzl", "make_puml_rst_navigation")
load("//bazel/rules/rules_score/private:validation.bzl", "PROFILES", "VALIDATION_ATTRS", "run_validation")
load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_log_level")
@@ -31,7 +31,38 @@ load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_l
# Private Rule Implementation
# ============================================================================
-def _run_puml_parser(ctx, puml_file):
+def _disambiguated_stems(files):
+ """Compute a unique output stem (no directory, no extension) for every
+ .puml/.plantuml file in `files`.
+
+ All diagrams of one architectural_design target share a flat output
+ directory (keyed by ctx.label.name), so two files with the same
+ basename but different source directories (e.g. two `for_impl_apis.puml`
+ files under different subpackages) would otherwise collide on the same
+ generated output path. When a basename is unique, the plain stem is kept
+ unchanged (preserving existing filenames/titles); only colliding
+ basenames are disambiguated, using the file's package-relative directory.
+
+ Args:
+ files: Iterable of File objects (non-.puml/.plantuml entries ignored).
+ Returns:
+ Dict from File.path to a unique stem string.
+ """
+ puml_files = [f for f in files if f.extension in ("puml", "plantuml")]
+ basename_counts = {}
+ for f in puml_files:
+ basename_counts[f.basename] = basename_counts.get(f.basename, 0) + 1
+
+ stems = {}
+ for f in puml_files:
+ stem = f.basename.rsplit(".", 1)[0]
+ if basename_counts[f.basename] > 1:
+ dir_part = f.short_path.rsplit("/", 1)[0] if "/" in f.short_path else ""
+ stem = "{}__{}".format(dir_part.replace("/", "_"), stem)
+ stems[f.path] = stem
+ return stems
+
+def _run_puml_parser(ctx, puml_file, file_stem, exclude_from_definitions = False):
"""Run the PlantUML parser on a single .puml file to produce a FlatBuffers binary,
a lobster traceability file, and an idmap sidecar.
@@ -39,6 +70,12 @@ def _run_puml_parser(ctx, puml_file):
FlatBuffers schema (each diagram type uses its own root_type).
Lobster output is produced in-process for component diagrams.
+ When the input file basename is not unique across all diagrams being
+ parsed by this target (see _disambiguated_stems), a symlink with a
+ disambiguated name is created and passed to puml_cli. This ensures
+ puml_cli produces outputs with unique names even when two source
+ diagrams share the same basename but live in different directories.
+
``--source-name`` is passed as ``puml_file.short_path`` so the ``source``
field embedded in the fbs/lobster/idmap outputs is a stable,
workspace-relative path. This is required by the `clickable_plantuml`
@@ -51,10 +88,15 @@ def _run_puml_parser(ctx, puml_file):
Args:
ctx: Rule context
puml_file: The .puml File object to parse
+ file_stem: Unique output stem for this file (see _disambiguated_stems).
+ exclude_from_definitions: When True, passes ``--exclude-from-definitions``
+ so this diagram's ``defines`` are never used by `clickable_plantuml`
+ to resolve another diagram's reference (used for `static_view`
+ diagrams, which are only a partial/subset view of `static`).
Returns:
Tuple of (fbs_output, lobster_output, idmap_output) declared output Files.
"""
- file_stem = puml_file.basename.rsplit(".", 1)[0]
+ ext = puml_file.extension
fbs_output = ctx.actions.declare_file(
"{}/{}.fbs.bin".format(ctx.label.name, file_stem),
)
@@ -65,35 +107,50 @@ def _run_puml_parser(ctx, puml_file):
"{}/{}.idmap.json".format(ctx.label.name, file_stem),
)
+ # Create a symlink to the input file with the disambiguated name so that
+ # puml_cli's output filenames (which are based on input basename) match
+ # the declared output files.
+ input_symlink = ctx.actions.declare_file(
+ "_puml_inputs/{}.{}".format(file_stem, ext),
+ )
+ ctx.actions.symlink(output = input_symlink, target_file = puml_file)
+
+ arguments = [
+ "--file",
+ input_symlink.path,
+ "--fbs-output-dir",
+ fbs_output.dirname,
+ "--lobster-output-dir",
+ lobster_output.dirname,
+ "--idmap-output-dir",
+ idmap_output.dirname,
+ "--source-name",
+ puml_file.short_path,
+ "--log-level",
+ get_log_level(ctx),
+ ]
+ if exclude_from_definitions:
+ arguments.append("--exclude-from-definitions")
+
ctx.actions.run(
- inputs = [puml_file],
+ inputs = [input_symlink],
outputs = [fbs_output, lobster_output, idmap_output],
executable = ctx.executable._puml_parser,
- arguments = [
- "--file",
- puml_file.path,
- "--fbs-output-dir",
- fbs_output.dirname,
- "--lobster-output-dir",
- lobster_output.dirname,
- "--idmap-output-dir",
- idmap_output.dirname,
- "--source-name",
- puml_file.short_path,
- "--log-level",
- get_log_level(ctx),
- ],
+ arguments = arguments,
progress_message = "Parsing PlantUML diagram: %s" % puml_file.short_path,
)
return fbs_output, lobster_output, idmap_output
-def _parse_puml_diagrams(ctx, files):
+def _parse_puml_diagrams(ctx, files, stems, exclude_from_definitions = False):
"""Run the PlantUML parser on all .puml/.plantuml files in a list.
Args:
ctx: Rule context
files: List of File objects
+ stems: Dict from File.path to unique output stem (see _disambiguated_stems).
+ exclude_from_definitions: Forwarded to `_run_puml_parser` for every file
+ (see there); set for `static_view` diagrams.
Returns:
Tuple of (fbs_outputs, lobster_outputs, idmap_outputs) lists of generated Files.
"""
@@ -102,18 +159,17 @@ def _parse_puml_diagrams(ctx, files):
idmap_outputs = []
for f in files:
if f.extension in ("puml", "plantuml"):
- fbs, lobster, idmap = _run_puml_parser(ctx, f)
+ fbs, lobster, idmap = _run_puml_parser(ctx, f, stems[f.path], exclude_from_definitions)
fbs_outputs.append(fbs)
lobster_outputs.append(lobster)
idmap_outputs.append(idmap)
return fbs_outputs, lobster_outputs, idmap_outputs
-def _colocate_puml_with_wrapper(ctx, puml_files, output_dir):
+def _colocate_puml_with_wrapper(ctx, puml_files, output_dir, stems):
"""Symlink .puml/.plantuml sources next to their generated RST wrapper.
- make_puml_rst_wrappers() declares each wrapper at
- "{output_dir}/{stem}.rst" (output_dir is this target's ctx.label.name)
- and embeds the diagram via a same-directory sibling reference
+ make_puml_rst_navigation() declares each wrapper below the source diagram's
+ relative directory and embeds the diagram via a same-directory sibling reference
(``.. uml:: {basename}``). The .puml source itself, however, usually
lives directly in this target's package -- one directory above
`output_dir` -- not nested under it. When dependable_element.bzl later
@@ -133,7 +189,11 @@ def _colocate_puml_with_wrapper(ctx, puml_files, output_dir):
puml_files: Iterable of File objects; non-.puml/.plantuml files are
passed through unchanged.
output_dir: String prefix matching the one passed to
- make_puml_rst_wrappers() (typically ctx.label.name).
+ make_puml_rst_navigation() (typically ctx.label.name).
+ stems: Dict from File.path to unique output stem (see
+ _disambiguated_stems), used instead of the plain basename so
+ files sharing a basename don't collide once flattened into
+ `output_dir`.
Returns:
List of File objects with .puml/.plantuml entries replaced by
@@ -144,14 +204,26 @@ def _colocate_puml_with_wrapper(ctx, puml_files, output_dir):
if f.extension not in ("puml", "plantuml"):
colocated.append(f)
continue
- copy = ctx.actions.declare_file(
- "{}/{}".format(output_dir, f.basename),
- )
+ relative_path = f.short_path
+ package_prefix = ctx.label.package + "/" if ctx.label.package else ""
+ if relative_path.startswith(package_prefix):
+ relative_path = relative_path[len(package_prefix):]
+
+ # Preserve directory structure for sidebar visibility, but use
+ # disambiguated stem to avoid collisions when multiple files share
+ # the same basename.
+ relative_dir = relative_path.rsplit("/", 1)[0] if "/" in relative_path else ""
+ if relative_dir:
+ output_path = "{}/{}/{}.{}".format(output_dir, relative_dir, stems[f.path], f.extension)
+ else:
+ output_path = "{}/{}.{}".format(output_dir, stems[f.path], f.extension)
+
+ copy = ctx.actions.declare_file(output_path)
ctx.actions.symlink(output = copy, target_file = f)
colocated.append(copy)
return colocated
-def _run_validation(ctx, component_fbs_files, sequence_fbs_files, public_api_fbs_files, internal_api_fbs_files):
+def _run_validation(ctx, component_fbs_files, sequence_fbs_files, public_api_fbs_files, internal_api_fbs_files, static_view_fbs_files):
"""Run the architectural-design validation profile.
Args:
@@ -160,6 +232,7 @@ def _run_validation(ctx, component_fbs_files, sequence_fbs_files, public_api_fbs
sequence_fbs_files: Sequence-diagram FlatBuffer files generated from this target's dynamic inputs.
public_api_fbs_files: List of public-API FlatBuffer files generated from this target's public_api inputs.
internal_api_fbs_files: List of internal-API FlatBuffer files generated from this target's internal_api inputs.
+ static_view_fbs_files: Component-diagram FlatBuffer files generated from this target's static_view inputs.
Returns:
Struct with file and name fields describing the validation log entry.
"""
@@ -173,8 +246,9 @@ def _run_validation(ctx, component_fbs_files, sequence_fbs_files, public_api_fbs
"sequence_diagrams": [f.path for f in sequence_fbs_files],
"public_api_diagrams": [f.path for f in public_api_fbs_files],
"internal_api_diagrams": [f.path for f in internal_api_fbs_files],
+ "static_view": [f.path for f in static_view_fbs_files],
},
- inputs = component_fbs_files + sequence_fbs_files + public_api_fbs_files + internal_api_fbs_files,
+ inputs = component_fbs_files + sequence_fbs_files + public_api_fbs_files + internal_api_fbs_files + static_view_fbs_files,
mnemonic = "ArchitecturalDesignValidate",
maturity = ctx.attr.maturity,
log_level = get_log_level(ctx),
@@ -198,18 +272,27 @@ def _architectural_design_impl(ctx):
List of providers including DefaultInfo, ArchitecturalDesignInfo, SphinxSourcesInfo
"""
+ # All diagrams of this target share a flat output directory, so compute
+ # unique stems once across every view before parsing/colocating any of
+ # them (see _disambiguated_stems). Non-.puml/.plantuml files are ignored
+ # by _disambiguated_stems and pass through unaffected.
+ all_view_files = ctx.files.static + ctx.files.dynamic + ctx.files.public_api + ctx.files.internal_api + ctx.files.static_view
+ stems = _disambiguated_stems(all_view_files)
+
# Parse each architectural view separately so each provider field carries
# the flatbuffers for its own category.
- static_fbs_list, static_lobster_list, static_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static)
- dynamic_fbs_list, dynamic_lobster_list, dynamic_idmap_list = _parse_puml_diagrams(ctx, ctx.files.dynamic)
- public_api_fbs_list, public_api_lobster_list, public_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.public_api)
- internal_api_fbs_list, _internal_api_lobster_list, internal_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.internal_api)
+ static_fbs_list, static_lobster_list, static_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static, stems)
+ dynamic_fbs_list, dynamic_lobster_list, dynamic_idmap_list = _parse_puml_diagrams(ctx, ctx.files.dynamic, stems)
+ public_api_fbs_list, public_api_lobster_list, public_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.public_api, stems)
+ internal_api_fbs_list, _internal_api_lobster_list, internal_api_idmap_list = _parse_puml_diagrams(ctx, ctx.files.internal_api, stems)
+ static_view_fbs_list, _static_view_lobster_list, static_view_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static_view, stems, exclude_from_definitions = True)
static_fbs = depset(static_fbs_list)
dynamic_fbs = depset(dynamic_fbs_list)
public_api_fbs = depset(public_api_fbs_list)
internal_api_fbs = depset(internal_api_fbs_list)
public_api_lobster = depset(public_api_lobster_list)
+ static_view_fbs = depset(static_view_fbs_list)
# Source files for SphinxSourcesInfo (sphinx documentation pipeline).
# .puml/.plantuml sources are colocated (symlinked) next to their
@@ -218,33 +301,36 @@ def _architectural_design_impl(ctx):
# dependable_element.bzl stages these files for the HTML build.
all_source_files = depset(
transitive = [
- depset(_colocate_puml_with_wrapper(ctx, ctx.files.static, ctx.label.name)),
- depset(_colocate_puml_with_wrapper(ctx, ctx.files.dynamic, ctx.label.name)),
- depset(_colocate_puml_with_wrapper(ctx, ctx.files.public_api, ctx.label.name)),
- depset(_colocate_puml_with_wrapper(ctx, ctx.files.internal_api, ctx.label.name)),
+ depset(_colocate_puml_with_wrapper(ctx, ctx.files.static, ctx.label.name, stems)),
+ depset(_colocate_puml_with_wrapper(ctx, ctx.files.dynamic, ctx.label.name, stems)),
+ depset(_colocate_puml_with_wrapper(ctx, ctx.files.public_api, ctx.label.name, stems)),
+ depset(_colocate_puml_with_wrapper(ctx, ctx.files.internal_api, ctx.label.name, stems)),
+ depset(_colocate_puml_with_wrapper(ctx, ctx.files.static_view, ctx.label.name, stems)),
],
)
- # All idmap sidecars (across static/dynamic/public_api/internal_api) are
+ # All idmap sidecars (across static/dynamic/public_api/internal_api/static_view) are
# staged into the sphinx sources so the `clickable_plantuml` extension can
# discover them (it scans `srcdir` recursively for `*.idmap.json`) and
# resolve cross-diagram links — including component diagrams linking to
# the class diagrams that elaborate their public/internal API interfaces.
all_idmap_files = depset(
- static_idmap_list + dynamic_idmap_list + public_api_idmap_list + internal_api_idmap_list,
+ static_idmap_list + dynamic_idmap_list + public_api_idmap_list + internal_api_idmap_list + static_view_idmap_list,
)
sphinx_files = depset(
transitive = [all_idmap_files, all_source_files],
)
- # Generate a thin RST wrapper for every .puml diagram so it appears as a
- # toctree entry in the dependable_element index.
- rst_wrappers = make_puml_rst_wrappers(
+ # Generate path-preserving wrappers and directory index pages. The root
+ # index is the only direct entry in the dependable_element index; nested
+ # indexes and wrappers are reached through its toctrees.
+ navigation = make_puml_rst_navigation(
ctx,
- ctx.files.static + ctx.files.dynamic + ctx.files.public_api + ctx.files.internal_api,
+ all_view_files,
ctx.label.name,
ctx.file._puml_rst_template,
+ stems = stems,
)
validation_log = _run_validation(
@@ -253,9 +339,12 @@ def _architectural_design_impl(ctx):
dynamic_fbs_list,
public_api_fbs_list,
internal_api_fbs_list,
+ static_view_fbs_list,
)
- sphinx_srcs = depset(rst_wrappers, transitive = [sphinx_files])
+ sphinx_srcs = depset([navigation.root_index])
+ sphinx_aux_srcs = depset(navigation.wrappers + navigation.indexes)
+ sphinx_deps = depset(transitive = [sphinx_files, sphinx_srcs, sphinx_aux_srcs])
return [
DefaultInfo(files = depset([validation_log.file], transitive = [all_source_files])),
@@ -264,6 +353,7 @@ def _architectural_design_impl(ctx):
dynamic = dynamic_fbs,
public_api = public_api_fbs,
internal_api = internal_api_fbs,
+ static_view = static_view_fbs,
name = ctx.label.name,
public_api_lobster_files = public_api_lobster,
validation_logs = [validation_log],
@@ -271,8 +361,8 @@ def _architectural_design_impl(ctx):
# Source diagram files + *.idmap.json sidecars for the sphinx documentation build
SphinxSourcesInfo(
srcs = sphinx_srcs,
- deps = sphinx_srcs,
- aux_srcs = depset(),
+ deps = sphinx_deps,
+ aux_srcs = sphinx_aux_srcs,
),
]
@@ -307,6 +397,16 @@ def _architectural_design_attrs():
"Classified separately so their FlatBuffers outputs are exposed via " +
"ArchitecturalDesignInfo.internal_api for downstream validation.",
),
+ "static_view": attr.label_list(
+ allow_files = [".puml", ".plantuml"],
+ mandatory = False,
+ doc = "Component diagrams that present a partial view of the static architecture. " +
+ "Parsed identically to `static`, but never used to define the units/components " +
+ "validated against the Bazel component graph. Instead, every component/unit " +
+ "defined here must also be defined, under the same parent, in `static`; " +
+ "the build fails if a `static_view` diagram introduces a component/unit that is " +
+ "not present in the `static` diagrams.",
+ ),
"maturity": attr.string(
default = "release",
values = ["release", "development"],
@@ -345,6 +445,7 @@ def architectural_design(
dynamic = [],
public_api = [],
internal_api = [],
+ static_view = [],
maturity = "release",
**kwargs):
"""Define architectural design following S-CORE process guidelines.
@@ -375,6 +476,15 @@ def architectural_design(
static/dynamic diagrams but classified separately so their
FlatBuffers outputs are exposed via ArchitecturalDesignInfo.
internal_api for downstream validation.
+ static_view: Optional list of .puml component diagrams that present a
+ partial view of the static architecture. These are parsed
+ identically to `static`, but are not used to define the
+ units/components validated against the Bazel component graph.
+ Instead, every component/unit defined in a `static_view` diagram
+ must also be defined, under the same parent, in `static`: it may
+ only contain a subset of the units/components of the matching
+ `static` diagram. The build fails if a `static_view` diagram
+ introduces a component/unit that is not present in `static`.
maturity: Maturity level of the architectural design. Use
"development" to write validation findings without failing the
Bazel action.
@@ -407,6 +517,7 @@ def architectural_design(
dynamic = dynamic,
public_api = public_api,
internal_api = internal_api,
+ static_view = static_view,
maturity = maturity,
**kwargs
)
diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl
index f49229b6..9ecc6fed 100644
--- a/bazel/rules/rules_score/private/dependable_element.bzl
+++ b/bazel/rules/rules_score/private/dependable_element.bzl
@@ -198,6 +198,14 @@ def _find_common_directory(files):
# of whether the file is a source or a generated artifact.
dirs = [paths.dirname(f.short_path) for f in files]
+ generated_dirs = [
+ paths.dirname(f.short_path)
+ for f in files
+ if not f.is_source
+ ]
+ if generated_dirs:
+ dirs = generated_dirs
+
if not dirs:
return ""
@@ -267,7 +275,7 @@ def _is_document_file(file):
"""
return file.extension in ["rst", "md"]
-def _create_artifact_symlink(ctx, artifact_name, artifact_file, relative_path):
+def _create_artifact_symlink(ctx, artifact_name, artifact_file, relative_path, path_prefix = ""):
"""Create symlink for artifact file in output directory.
Args:
@@ -275,12 +283,13 @@ def _create_artifact_symlink(ctx, artifact_name, artifact_file, relative_path):
artifact_name: Name of artifact type (e.g., "architectural_design")
artifact_file: Source file
relative_path: Relative path within artifact directory
+ path_prefix: Optional subdirectory used to disambiguate multiple providers
Returns:
Declared output file
"""
output_file = ctx.actions.declare_file(
- ctx.label.name + "/" + artifact_name + "/" + relative_path,
+ ctx.label.name + "/" + artifact_name + "/" + path_prefix + relative_path,
)
ctx.actions.symlink(
@@ -290,13 +299,14 @@ def _create_artifact_symlink(ctx, artifact_name, artifact_file, relative_path):
return output_file
-def _process_artifact_files(ctx, artifact_name, label):
+def _process_artifact_files(ctx, artifact_name, label, path_prefix = ""):
"""Process all files from a single label for a given artifact type.
Args:
ctx: Rule context
artifact_name: Name of artifact type
label: Label to process
+ path_prefix: Optional subdirectory used to disambiguate multiple providers
Returns:
Tuple of (output_files, index_references)
@@ -339,23 +349,27 @@ def _process_artifact_files(ctx, artifact_name, label):
artifact_name,
artifact_file,
relative_path,
+ path_prefix,
)
output_files.append(output_file)
# Add to toctree index only for files directly owned by this rule.
if _is_document_file(artifact_file):
- doc_path = artifact_name + "/" + relative_path
+ doc_path = artifact_name + "/" + path_prefix + relative_path
doc_ref = doc_path.removesuffix(".rst").removesuffix(".md")
index_refs.append(doc_ref)
# Process aux_srcs: symlink without adding to outer toctree index.
for artifact_file in aux_files:
+ if artifact_file.path in srcs_paths:
+ continue
relative_path = _compute_relative_path(artifact_file, common_dir)
output_file = _create_artifact_symlink(
ctx,
artifact_name,
artifact_file,
relative_path,
+ path_prefix,
)
output_files.append(output_file)
@@ -379,11 +393,13 @@ def _process_artifact_type(ctx, artifact_name):
return (output_files, index_refs)
# Process each label
- for label in attr_list:
+ use_label_subdirectories = len(attr_list) > 1
+ for index, label in enumerate(attr_list):
label_outputs, label_refs = _process_artifact_files(
ctx,
artifact_name,
label,
+ path_prefix = "source_{}/".format(index) if use_label_subdirectories else "",
)
output_files.extend(label_outputs)
index_refs.extend(label_refs)
diff --git a/bazel/rules/rules_score/private/puml_utils.bzl b/bazel/rules/rules_score/private/puml_utils.bzl
index 0a271e4a..7407905a 100644
--- a/bazel/rules/rules_score/private/puml_utils.bzl
+++ b/bazel/rules/rules_score/private/puml_utils.bzl
@@ -13,12 +13,29 @@
"""Shared helper for generating RST wrapper pages for PlantUML diagram files."""
-def make_puml_rst_wrappers(ctx, puml_files, output_dir, template, strip_prefix = "", filename_prefix = ""):
- """Generate a thin RST wrapper page for each PlantUML diagram file.
+load("@bazel_skylib//lib:paths.bzl", "paths")
+
+def _relative_source_path(file, package):
+ prefix = package + "/" if package else ""
+ if file.short_path.startswith(prefix):
+ return file.short_path[len(prefix):]
+ return file.basename
+
+def _directory_title(directory):
+ if not directory:
+ return "Architectural Design"
+ return directory.split("/")[-1].replace("_", " ").title()
+
+def make_puml_rst_navigation(ctx, puml_files, output_dir, template, strip_prefix = "", filename_prefix = "", stems = None):
+ """Generate PlantUML wrapper pages and indexes matching source directories.
The wrapper embeds the diagram via ``.. uml::`` so it appears as a
proper toctree entry while keeping the source ``.puml`` file separate.
+ When disambiguated stems are provided (for collision handling), the stems
+ are used in place of plain basenames while preserving the source directory
+ structure for navigation and sidebar visibility.
+
Args:
ctx: Rule context.
puml_files: Iterable of File objects whose extension is ``puml`` or
@@ -31,29 +48,85 @@ def make_puml_rst_wrappers(ctx, puml_files, output_dir, template, strip_prefix =
the human-readable title (e.g. ``"fta_"``).
filename_prefix: Optional prefix prepended to the output RST filename
stem (e.g. ``"detail_"``).
+ stems: Optional dict from File.path to a precomputed unique
+ stem (see architectural_design.bzl's
+ _disambiguated_stems), used instead of the plain
+ basename stem for both the output filename and the
+ embedded ``.. uml::`` reference -- needed when the
+ diagram was colocated under a disambiguated name to
+ avoid colliding with a same-named diagram elsewhere.
Returns:
- List of declared ``.rst`` output Files, one per input diagram.
+ Struct containing ``wrappers``, ``indexes``, and ``root_index``.
"""
wrappers = []
+ diagrams_by_directory = {}
+ directories = {"": True}
for f in puml_files:
if f.extension not in ("puml", "plantuml"):
continue
- stem = f.basename[:-(len(f.extension) + 1)]
- if strip_prefix and stem.startswith(strip_prefix):
- stem = stem[len(strip_prefix):]
- title = stem.replace("_", " ").title()
+ relative_path = _relative_source_path(f, ctx.label.package)
+ relative_directory = paths.dirname(relative_path)
+ if relative_directory == ".":
+ relative_directory = ""
+
+ # Use disambiguated stem for generated filenames, but keep the title
+ # based on the source basename so the sidebar does not show a path.
+ source_stem = paths.basename(relative_path)[:-(len(f.extension) + 1)]
+ stem = stems[f.path] if stems else source_stem
+ title = source_stem
+ if strip_prefix and title.startswith(strip_prefix):
+ title = title[len(strip_prefix):]
+ title = title.replace("_", " ").title()
+ wrapper_relative_path = paths.join(relative_directory, filename_prefix + stem + ".rst")
wrapper = ctx.actions.declare_file(
- "{}/{}{}.rst".format(output_dir, filename_prefix, stem),
+ "{}/{}".format(output_dir, wrapper_relative_path),
)
+
+ # For the embedded diagram filename, use disambiguated stem if available
+ basename = "{}.{}".format(stems[f.path], f.extension) if stems else f.basename
ctx.actions.expand_template(
template = template,
output = wrapper,
substitutions = {
"{title}": title,
"{underline}": "=" * len(title),
- "{basename}": f.basename,
+ "{basename}": basename,
},
)
wrappers.append(wrapper)
- return wrappers
+ diagrams_by_directory.setdefault(relative_directory, []).append(stem)
+ directory_parts = relative_directory.split("/") if relative_directory else []
+ for part_count in range(1, len(directory_parts) + 1):
+ directories["/".join(directory_parts[:part_count])] = True
+
+ indexes = []
+ for directory in sorted(directories.keys()):
+ entries = []
+ for stem in sorted(diagrams_by_directory.get(directory, [])):
+ entries.append(stem)
+ directory_prefix = directory + "/" if directory else ""
+ for child in sorted(directories.keys()):
+ child_prefix = directory_prefix
+ if child.startswith(child_prefix) and child != directory:
+ remainder = child[len(child_prefix):]
+ if "/" not in remainder:
+ entries.append(remainder + "/index")
+ index = ctx.actions.declare_file(
+ "{}/{}".format(output_dir, paths.join(directory, "index.rst")),
+ )
+ ctx.actions.write(
+ output = index,
+ content = "{}\n{}\n\n.. toctree::\n :maxdepth: 1\n\n{}\n".format(
+ _directory_title(directory),
+ "-" * len(_directory_title(directory)),
+ "\n".join([" " + entry for entry in entries]),
+ ),
+ )
+ indexes.append(index)
+
+ return struct(
+ wrappers = wrappers,
+ indexes = indexes,
+ root_index = indexes[0] if indexes else None,
+ )
diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl
index 908a725b..0f4ba7bb 100644
--- a/bazel/rules/rules_score/providers.bzl
+++ b/bazel/rules/rules_score/providers.bzl
@@ -204,6 +204,7 @@ ArchitecturalDesignInfo = provider(
"dynamic": "Depset of FlatBuffers binaries for dynamic architecture diagrams (sequence diagrams, activity diagrams, etc.)",
"public_api": "Depset of FlatBuffers binaries for public API diagrams (class diagrams, etc.)",
"internal_api": "Depset of FlatBuffers binaries for internal API diagrams (class diagrams, etc.)",
+ "static_view": "Depset of FlatBuffers binaries for static_view component diagrams (partial views of the static architecture, validated for consistency against static).",
"name": "Name of the architectural design target",
"public_api_lobster_files": "Depset of .lobster traceability files generated from public_api diagrams.",
"validation_logs": "List of validation log entries produced by this architectural design target. Each entry has file and name fields.",
diff --git a/bazel/rules/rules_score/src/sphinx_module_ext.py b/bazel/rules/rules_score/src/sphinx_module_ext.py
index b039d051..c16a56ac 100644
--- a/bazel/rules/rules_score/src/sphinx_module_ext.py
+++ b/bazel/rules/rules_score/src/sphinx_module_ext.py
@@ -23,12 +23,19 @@
"""
from pathlib import Path
+import re
from typing import Any, Dict
from bazel_sphinx_needs import load_external_needs
from sphinx_conf_helpers import init_hermetic_tools
+_DIRECTORY_INDEX_ANCHOR = re.compile(
+ r'[^>]*?)href="[^"]+/index\.html"(?P[^>]*)>(?P',
+ re.DOTALL,
+)
+
+
def init_external_needs(app: Any, config: Any) -> None:
"""
Initialize external needs configuration.
@@ -46,6 +53,44 @@ def init_external_needs(app: Any, config: Any) -> None:
config.needs_external_needs = load_external_needs(Path(app.confdir))
+def render_directory_labels_without_links(
+ app: Any,
+ pagename: str,
+ templatename: str,
+ context: Dict[str, Any],
+ doctree: Any,
+) -> None:
+ """Remove navigation links for generated directory index pages.
+
+ Directory indexes exist only to provide expandable navigation groups. The
+ sidebar should expose their names as labels, while diagram pages remain
+ normal links.
+ """
+
+ def wrap_toctree_renderer(renderer: Any) -> Any:
+ def render_without_directory_links(*args: Any, **kwargs: Any) -> str:
+ kind = args[0] if args else kwargs.get("kind")
+ if kind == "sidebar":
+ kwargs["show_nav_level"] = 100
+ kwargs["maxdepth"] = 100
+ html = renderer(*args, **kwargs)
+ return _DIRECTORY_INDEX_ANCHOR.sub(
+ lambda match: "{}".format(
+ match.group("before"),
+ match.group("after"),
+ match.group("label"),
+ ),
+ str(html),
+ )
+
+ return render_without_directory_links
+
+ for renderer_name in ("toctree", "generate_toctree_html"):
+ renderer = context.get(renderer_name)
+ if renderer is not None:
+ context[renderer_name] = wrap_toctree_renderer(renderer)
+
+
def setup(app: Any) -> Dict[str, Any]:
"""
Sphinx setup hook to register event listeners.
@@ -58,6 +103,7 @@ def setup(app: Any) -> Dict[str, Any]:
"""
app.connect("config-inited", init_external_needs)
app.connect("config-inited", init_hermetic_tools)
+ app.connect("html-page-context", render_directory_labels_without_links, priority=900)
return {
"version": "1.0",
diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD
index c8084df0..60274d79 100644
--- a/bazel/rules/rules_score/test/BUILD
+++ b/bazel/rules/rules_score/test/BUILD
@@ -447,6 +447,55 @@ dependable_element(
deps = [],
)
+# Live example of clickable_plantuml's "interface in a static_view diagram"
+# linking chain: an interface bound to a unit in a *static_view* diagram
+# (static_view_overview.puml, a partial/subset component-diagram view -
+# parsed exactly like `static`, see architectural_design's `static_view`
+# attribute) references `SvInterface`, and static_view_detail.puml (passed
+# via `public_api`) defines it. Both share the FQN `package_sv.SvInterface`.
+# `static` is intentionally omitted: the static-vs-static_view consistency
+# validator only runs when both are present (see
+# `registered_validators` in validation/core/src/profiles/architectural_design.rs),
+# so this fixture stays focused on the clickable_plantuml linking behaviour.
+# Regression-checked by :static_view_example_link_rendered_test below.
+architectural_design(
+ name = "arch_design_static_view_example",
+ public_api = ["fixtures/clickable_example/static_view_detail.puml"],
+ static_view = ["fixtures/clickable_example/static_view_overview.puml"],
+)
+
+# public_api items must be referenced by a FailureMode in the SEooC's own
+# safety analysis - same requirement as public_api_example_fmea above.
+fmea(
+ name = "static_view_example_fmea",
+ arch_design = ":arch_design_static_view_example",
+ failuremodes = ["fixtures/clickable_example/static_view_failure_modes.trlc"],
+ root_causes = ["fixtures/clickable_example/static_view_fta.puml"],
+)
+
+dependability_analysis(
+ name = "static_view_example_dependability_analysis",
+ arch_design = ":arch_design_static_view_example",
+ fmea = [":static_view_example_fmea"],
+)
+
+dependable_element(
+ name = "static_view_example_lib",
+ architectural_design = [":arch_design_static_view_example"],
+ assumptions_of_use = [":aous"],
+ components = [],
+ dependability_analysis = [":static_view_example_dependability_analysis"],
+ integrity_level = "B",
+ # Downgraded to warnings for the same reason as clickable_example_lib
+ # above: this fixture intentionally reuses "SvInterface" as both a
+ # reference and a definition, which the dependable-element validator
+ # can't distinguish from an accidental duplicate id.
+ maturity = "development",
+ requirements = [":feat_req"],
+ tests = [],
+ deps = [],
+)
+
# Live example of clickable_plantuml's "unit to class diagram" linking chain:
# the *static* architecture (unit_overview.puml, a component diagram) shows
# `unit_one` as a leaf unit (no children) - a reference - and
@@ -1074,6 +1123,20 @@ sh_test(
data = [":public_api_example_lib_index"],
)
+# Regression test: clickable_plantuml must inject a working link between an
+# interface used in a *static_view* diagram (static_view_overview.puml) and
+# its definition in the public API diagram (static_view_detail.puml) - the
+# "interface in static_view diagram links to public API" scenario.
+sh_test(
+ name = "static_view_example_link_rendered_test",
+ srcs = ["check_idmap_link.sh"],
+ args = [
+ "package_sv.SvInterface",
+ "$(rootpaths :static_view_example_lib_index)",
+ ],
+ data = [":static_view_example_lib_index"],
+)
+
# Regression test: clickable_plantuml must inject a working link between a
# unit shown in the static architecture (unit_overview.puml) and its
# elaborating class diagram (unit_class_detail.puml) - the "unit links to its
diff --git a/bazel/rules/rules_score/test/check_seooc_dep_links.sh b/bazel/rules/rules_score/test/check_seooc_dep_links.sh
index 94a8f7b2..8ccb6fa4 100755
--- a/bazel/rules/rules_score/test/check_seooc_dep_links.sh
+++ b/bazel/rules/rules_score/test/check_seooc_dep_links.sh
@@ -16,7 +16,7 @@ set -euo pipefail
index_file=""
for rel_path in "$@"; do
candidate="${TEST_SRCDIR}/${TEST_WORKSPACE}/${rel_path}"
- if [[ -f "${candidate}" && "${candidate}" == */index.rst ]]; then
+ if [[ -f "${candidate}" && "${candidate}" == */seooc_test_lib_index/index.rst ]]; then
index_file="${candidate}"
break
fi
diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_detail.puml
new file mode 100644
index 00000000..2a180ad7
--- /dev/null
+++ b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_detail.puml
@@ -0,0 +1,20 @@
+' *******************************************************************************
+' 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 SvInterface
+
+package package_sv {
+ interface "SvInterface" as SvInterface
+}
+
+@enduml
diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_failure_modes.trlc b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_failure_modes.trlc
new file mode 100644
index 00000000..3cf510f8
--- /dev/null
+++ b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_failure_modes.trlc
@@ -0,0 +1,24 @@
+/********************************************************************************
+ * 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
+ ********************************************************************************/
+package StaticViewExampleFmea
+
+import ScoreReq
+
+ScoreReq.FailureMode SvInterfaceFailure {
+ guidewords = [ScoreReq.Guideword.LossOfFunction]
+ description = "SvInterface stops responding"
+ failureeffect = "Callers never receive a response"
+ version = 1
+ safety = ScoreReq.Asil.B
+ interface = "package_sv.SvInterface"
+}
diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_fta.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_fta.puml
new file mode 100644
index 00000000..aaedd8db
--- /dev/null
+++ b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_fta.puml
@@ -0,0 +1,20 @@
+' *******************************************************************************
+' 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
+
+!include fta_metamodel.puml
+
+$TopEvent("SvInterface stops responding", "StaticViewExampleFmea.SvInterfaceFailure")
+
+@enduml
diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_overview.puml
new file mode 100644
index 00000000..adc16afb
--- /dev/null
+++ b/bazel/rules/rules_score/test/fixtures/clickable_example/static_view_overview.puml
@@ -0,0 +1,25 @@
+' *******************************************************************************
+' 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 static_view_overview
+
+package "Package Sv" as package_sv {
+ component "Component Sv" as component_sv <> {
+ component "Unit Sv" as unit_sv <>
+ }
+
+ interface "SvInterface" as SvInterface
+ unit_sv -( SvInterface
+}
+
+@enduml
diff --git a/plantuml/parser/puml_cli/src/main.rs b/plantuml/parser/puml_cli/src/main.rs
index 36b18102..73eb9ae3 100644
--- a/plantuml/parser/puml_cli/src/main.rs
+++ b/plantuml/parser/puml_cli/src/main.rs
@@ -118,6 +118,17 @@ struct Args {
#[arg(long)]
idmap_output_dir: Option,
+ /// Mark every generated idmap's `defines` as unusable by
+ /// `clickable_plantuml` for resolving other diagrams' references (see
+ /// `IdMapFile::excluded_from_definitions`). Set this when parsing
+ /// diagrams that are only a partial/subset view of the true
+ /// architecture — e.g. `architectural_design()`'s `static_view`
+ /// attribute — so such diagrams are never treated as the elaboration
+ /// site of the components/units they show. This diagram's own
+ /// `references` still resolve normally.
+ #[arg(long, default_value_t = false)]
+ exclude_from_definitions: bool,
+
/// Only meaningful for a single resolved diagram: requires exactly one
/// input file (a single `--file`, no `--folders`) and cannot be combined
/// with `--fta-output-dir` (which always processes a batch of files).
@@ -229,7 +240,12 @@ fn run() -> Result<(), Box> {
debug!("Parsing started");
for (path, content) in &preprocessed_files {
- let parsed_content = parse_puml_file(path, content, log_level, args.diagram_type)
+ let source_path = args
+ .source_name
+ .as_ref()
+ .map(|name| Rc::new(PathBuf::from(name)))
+ .unwrap_or_else(|| Rc::clone(path));
+ let parsed_content = parse_puml_file(&source_path, content, log_level, args.diagram_type)
.map_err(|e| std::io::Error::other(e.to_string()))?;
if emit_debug_json {
if let Some(ref dir) = fbs_output_dir {
@@ -296,10 +312,16 @@ fn run() -> Result<(), Box> {
Some(&source_file),
diagram_name.as_deref(),
idir,
+ args.exclude_from_definitions,
)?;
}
None => {
- write_empty_idmap_to_file(path, Some(&source_file), idir)?;
+ write_empty_idmap_to_file(
+ path,
+ Some(&source_file),
+ idir,
+ args.exclude_from_definitions,
+ )?;
}
}
}
@@ -402,6 +424,7 @@ fn run_fta(
Some(&source_file),
None,
&idir,
+ args.exclude_from_definitions,
)?;
}
@@ -989,7 +1012,7 @@ mod idmap_wiring_tests {
// Mirror the CLI dispatch for the Activity arm.
let dir = unique_dir("activity");
let source_file = source_path_for_output(&path);
- let output = write_empty_idmap_to_file(&path, Some(&source_file), &dir)
+ let output = write_empty_idmap_to_file(&path, Some(&source_file), &dir, false)
.expect("empty idmap must be written");
assert_eq!(
@@ -1044,7 +1067,7 @@ mod idmap_wiring_tests {
let dir = unique_dir("component");
let source_file = source_path_for_output(&path);
- let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir)
+ let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir, false)
.expect("component idmap must be written");
assert_eq!(
@@ -1075,7 +1098,7 @@ mod idmap_wiring_tests {
let dir = unique_dir("class");
let source_file = source_path_for_output(&path);
- let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir)
+ let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir, false)
.expect("class idmap must be written");
assert_eq!(
@@ -1107,7 +1130,7 @@ mod idmap_wiring_tests {
let dir = unique_dir("sequence");
let source_file = source_path_for_output(&path);
- let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir)
+ let output = write_idmap_to_file(idmap_model, &path, Some(&source_file), None, &dir, false)
.expect("sequence idmap must be written");
assert_eq!(
@@ -1186,6 +1209,7 @@ mod idmap_wiring_tests {
Some(&source_path_for_output(&component_path)),
None,
&dir,
+ false,
)
.expect("component idmap must be written");
let class_output = write_idmap_to_file(
@@ -1194,6 +1218,7 @@ mod idmap_wiring_tests {
Some(&source_path_for_output(&class_path)),
None,
&dir,
+ false,
)
.expect("class idmap must be written");
diff --git a/plantuml/parser/puml_idmap/src/lib.rs b/plantuml/parser/puml_idmap/src/lib.rs
index c8360081..3897dfe0 100644
--- a/plantuml/parser/puml_idmap/src/lib.rs
+++ b/plantuml/parser/puml_idmap/src/lib.rs
@@ -40,16 +40,25 @@ use std::path::{Path, PathBuf};
// ---------------------------------------------------------------------------
/// A single element entry in the idmap.
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct IdMapEntry {
/// PlantUML alias used in `url of is [[url]]` injection.
pub alias: String,
/// Fully-qualified identifier (FQN) for matching across diagrams.
pub id: String,
+ /// `true` when this define was synthesized for a namespace/package
+ /// container rather than elaborated as an entity of its own (see
+ /// `class_model_to_idmap`'s namespace synthesis). `clickable_plantuml`
+ /// only trusts a synthesized define when no non-synthesized diagram
+ /// elaborates the same element, so a namespace merely used for FQN
+ /// containment in several class diagrams never outranks or ties with
+ /// its real elaboration site (e.g. a `static` component diagram).
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub synthesized: bool,
}
/// Root structure of an `.idmap.json` file.
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Default, Serialize, Deserialize)]
pub struct IdMapFile {
/// Workspace-relative source path, e.g. `score/mw/com/proxy_detail.puml`.
pub source: String,
@@ -57,6 +66,14 @@ pub struct IdMapFile {
pub defines: Vec,
/// Elements referenced (leaf/relation endpoint) in this diagram.
pub references: Vec,
+ /// When `true`, this diagram's `defines` must never be used by
+ /// `clickable_plantuml` to resolve a reference from another diagram —
+ /// e.g. an `architectural_design()` `static_view` diagram, which is only
+ /// a partial/subset presentation of the `static` architecture and never
+ /// the true elaboration site of the components/units it shows. Its own
+ /// `references` still resolve normally against other diagrams' defines.
+ #[serde(default)]
+ pub excluded_from_definitions: bool,
}
// ---------------------------------------------------------------------------
@@ -114,6 +131,7 @@ fn comp_model_to_idmap(
let entry = IdMapEntry {
alias,
id: comp.id.clone(),
+ ..Default::default()
};
if is_define {
defines.push(entry);
@@ -139,6 +157,7 @@ fn comp_model_to_idmap(
source: source.to_string(),
defines,
references,
+ ..Default::default()
}
}
@@ -192,6 +211,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
defines.push(IdMapEntry {
alias: entity.name.clone(),
id: entity.id.clone(),
+ ..Default::default()
});
if let Some(ns) = entity.enclosing_namespace_id.as_deref() {
namespaces_with_defined_child.insert(ns);
@@ -203,6 +223,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
references.push(IdMapEntry {
alias: entity.name.clone(),
id: entity.id.clone(),
+ ..Default::default()
});
}
}
@@ -223,6 +244,15 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
// a tied co-definer and turn every real link to this namespace ambiguous.
// The alias is the namespace's own (last) path segment, matching how a
// leaf component/participant's bare alias is derived.
+ //
+ // Marked `synthesized: true` so `clickable_plantuml` can tell this
+ // incidental, per-file namespace define apart from a diagram whose actual
+ // purpose is to elaborate that element (e.g. its `static` component
+ // diagram). Several unrelated class diagrams routinely nest their
+ // entities under the same shared namespace purely to reflect FQN
+ // containment; without this flag every one of them would tie as a
+ // co-definer of that namespace and the real elaboration site would never
+ // win, silently breaking the link.
for &ns in &namespaces_with_defined_child {
if define_ids.contains(ns) || reference_ids.contains(ns) {
// An actual entity already owns this id; don't shadow it with a
@@ -234,6 +264,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
defines.push(IdMapEntry {
alias,
id: ns.to_string(),
+ synthesized: true,
});
}
@@ -273,6 +304,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
references.push(IdMapEntry {
alias,
id: endpoint.clone(),
+ ..Default::default()
});
}
}
@@ -284,6 +316,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile {
source: source.to_string(),
defines,
references,
+ ..Default::default()
}
}
@@ -304,6 +337,7 @@ fn sequence_model_to_idmap(model: &SequenceTree, source: &str) -> IdMapFile {
.map(|name| IdMapEntry {
alias: name.clone(),
id: name,
+ ..Default::default()
})
.collect();
references.sort_by(|a, b| a.id.cmp(&b.id));
@@ -312,6 +346,7 @@ fn sequence_model_to_idmap(model: &SequenceTree, source: &str) -> IdMapFile {
source: source.to_string(),
defines: Vec::new(),
references,
+ ..Default::default()
}
}
@@ -321,6 +356,7 @@ fn empty_idmap(source: &str) -> IdMapFile {
source: source.to_string(),
defines: Vec::new(),
references: Vec::new(),
+ ..Default::default()
}
}
@@ -358,6 +394,7 @@ fn fta_model_to_idmap(model: &FtaModel, source: &str) -> IdMapFile {
defines.push(IdMapEntry {
alias: node.alias.clone(),
id: node.alias.clone(),
+ ..Default::default()
});
}
NodeKind::Gate if node.gate_kind == Some(GateKind::TransferIn) => {
@@ -372,6 +409,7 @@ fn fta_model_to_idmap(model: &FtaModel, source: &str) -> IdMapFile {
references.push(IdMapEntry {
alias: node.alias.clone(),
id: node.alias.clone(),
+ ..Default::default()
});
}
_ => {} // BasicEvent, IntermediateEvent, $AndGate/$OrGate — internal, no link.
@@ -385,6 +423,7 @@ fn fta_model_to_idmap(model: &FtaModel, source: &str) -> IdMapFile {
source: source.to_string(),
defines,
references,
+ ..Default::default()
}
}
@@ -401,23 +440,31 @@ fn fta_model_to_idmap(model: &FtaModel, source: &str) -> IdMapFile {
/// provided (preferred: a stable workspace-relative path such as
/// `score/mw/com/proxy_detail.puml`), otherwise falls back to
/// `input_path.to_string_lossy()`.
+///
+/// *excluded_from_definitions* marks the resulting idmap's `defines` as
+/// unusable by `clickable_plantuml` for resolving other diagrams' references
+/// (see [`IdMapFile::excluded_from_definitions`]) — set this for diagrams
+/// that are only a partial/subset view of the true architecture, such as an
+/// `architectural_design()` `static_view` diagram.
pub fn write_idmap_to_file(
model: IdMapModel<'_>,
input_path: &Path,
source_name: Option<&str>,
diagram_name: Option<&str>,
output_dir: &Path,
+ excluded_from_definitions: bool,
) -> io::Result {
let source = source_name
.map(|s| s.to_string())
.unwrap_or_else(|| input_path.to_string_lossy().into_owned());
- let idmap = match model {
+ let mut idmap = match model {
IdMapModel::Component(m) => comp_model_to_idmap(m, &source, diagram_name),
IdMapModel::Class(m) => class_model_to_idmap(m, &source),
IdMapModel::Sequence(m) => sequence_model_to_idmap(m, &source),
IdMapModel::Fta(m) => fta_model_to_idmap(m, &source),
};
+ idmap.excluded_from_definitions = excluded_from_definitions;
write_idmap_json(input_path, output_dir, &idmap)
}
@@ -428,11 +475,13 @@ pub fn write_empty_idmap_to_file(
input_path: &Path,
source_name: Option<&str>,
output_dir: &Path,
+ excluded_from_definitions: bool,
) -> io::Result {
let source = source_name
.map(|s| s.to_string())
.unwrap_or_else(|| input_path.to_string_lossy().into_owned());
- let idmap = empty_idmap(&source);
+ let mut idmap = empty_idmap(&source);
+ idmap.excluded_from_definitions = excluded_from_definitions;
write_idmap_json(input_path, output_dir, &idmap)
}
@@ -1087,6 +1136,7 @@ mod tests {
Some("pkg/proxy.puml"),
None,
&dir,
+ false,
)
.expect("component idmap must be written");
@@ -1138,6 +1188,7 @@ mod tests {
Some("pkg/classes.puml"),
None,
&dir,
+ false,
)
.expect("class idmap must be written");
@@ -1171,6 +1222,7 @@ mod tests {
Some("pkg/seq.puml"),
None,
&dir,
+ false,
)
.expect("sequence idmap must be written");
@@ -1224,7 +1276,7 @@ mod tests {
let dir = unique_tmp_dir("empty_writer");
let input = Path::new("some/dir/activity.puml");
- let output = write_empty_idmap_to_file(input, Some("score/activity.puml"), &dir)
+ let output = write_empty_idmap_to_file(input, Some("score/activity.puml"), &dir, false)
.expect("empty idmap must be written");
assert_eq!(
@@ -1247,8 +1299,8 @@ mod tests {
let dir = unique_tmp_dir("empty_writer_fallback");
let input = Path::new("rel/dir/diagram.puml");
- let output =
- write_empty_idmap_to_file(input, None, &dir).expect("empty idmap must be written");
+ let output = write_empty_idmap_to_file(input, None, &dir, false)
+ .expect("empty idmap must be written");
let content = fs::read_to_string(&output).unwrap();
let parsed: IdMapFile = serde_json::from_str(&content).unwrap();
@@ -1257,6 +1309,29 @@ mod tests {
cleanup_tmp_dir(&dir);
}
+ #[test]
+ fn excluded_from_definitions_flag_is_written_to_disk() {
+ let dir = unique_tmp_dir("excluded_from_definitions");
+ let model = component_map(vec![component("Proxy", Some("Proxy"), None, None)]);
+ let input = Path::new("some/dir/proxy_view.puml");
+
+ let output = write_idmap_to_file(
+ IdMapModel::Component(&model),
+ input,
+ Some("pkg/proxy_view.puml"),
+ None,
+ &dir,
+ true,
+ )
+ .expect("component idmap must be written");
+
+ let parsed: IdMapFile =
+ serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap();
+ assert!(parsed.excluded_from_definitions);
+
+ cleanup_tmp_dir(&dir);
+ }
+
// ── Sequence participant traversal ─────────────────────────────────────
#[test]
diff --git a/plantuml/sphinx/clickable_plantuml/README.md b/plantuml/sphinx/clickable_plantuml/README.md
index 51f15dc8..1c6c8abf 100644
--- a/plantuml/sphinx/clickable_plantuml/README.md
+++ b/plantuml/sphinx/clickable_plantuml/README.md
@@ -226,6 +226,13 @@ They are not intended to be authored manually.
}
```
+An optional `excluded_from_definitions` boolean (set via `puml_cli
+--exclude-from-definitions`, and by `architectural_design()` automatically
+for every `static_view` diagram) marks a diagram's `defines` as unusable for
+resolving other diagrams' references — the diagram is only a partial/subset
+view of the true architecture, never its elaboration site. When absent, it
+defaults to `false`. The diagram's own `references` are unaffected.
+
## End-to-End Clickable Diagram Example
Rather than duplicating a hand-written, untested example here, this exact
@@ -286,6 +293,19 @@ matching definition in another — and are wired up the same way:
`package_pub.PublicInterface`. See `public_api_example_lib` and
`public_api_example_link_rendered_test` in
[`bazel/rules/rules_score/test/BUILD`](https://github.com/eclipse-score/tooling/blob/main/bazel/rules/rules_score/test/BUILD).
+- **Interface in a `static_view` diagram links to the public API** — a
+ `static_view` diagram (a component diagram passed via
+ architectural_design's `static_view` attribute — a partial/subset view of
+ the static architecture, parsed identically to `static` except that its
+ idmap is marked `excluded_from_definitions` so it is never treated as an
+ elaboration site) is scanned for `*.idmap.json` sidecars exactly like every
+ other architectural view, so an
+ interface bound in it is clickable the same way as one in `static`:
+ `static_view_overview.puml` shows a unit bound to `SvInterface` via a `-(`
+ port, and `static_view_detail.puml` (passed via `public_api`) defines it.
+ Both share the FQN `package_sv.SvInterface`. See `static_view_example_lib`
+ and `static_view_example_link_rendered_test` in
+ [`bazel/rules/rules_score/test/BUILD`](https://github.com/eclipse-score/tooling/blob/main/bazel/rules/rules_score/test/BUILD).
- **Static architecture unit links to its class diagram** —
`unit_overview.puml` (a component diagram) shows `unit_one` as a leaf unit
(no children, so a reference), and `unit_class_detail.puml` (a class
diff --git a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py
index 155595ec..501c3815 100644
--- a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py
+++ b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py
@@ -25,16 +25,34 @@
``[Proxy]`` box in an overview is a reference that should link to the
diagram that defines it.
+An idmap may also carry ``excluded_from_definitions: true`` (set by
+``puml_cli --exclude-from-definitions``, e.g. for an
+``architectural_design()`` ``static_view`` diagram — a partial/subset view
+of the ``static`` architecture). Such a diagram's ``defines`` are never
+added to the definition index, so references elsewhere always resolve to
+the true elaboration site (in ``static``), never to a `static_view` copy;
+the diagram's own ``references`` still resolve normally.
+
+A ``defines`` entry may also be marked ``synthesized`` (set by the PlantUML
+parser for a class diagram's namespace/package container, added purely so
+an entity's FQN reflects containment — see ``puml_idmap``). Several
+unrelated class diagrams routinely nest their entities under the same
+shared namespace; a non-synthesized definer (the diagram that actually
+elaborates that namespace, e.g. its `static` component diagram) always
+outranks any number of synthesized ones, so those incidental namespace
+wrappers never tie with, or shadow, the real elaboration site.
+
The matching algorithm:
-1. Build a *definition index*: ``{alias|id → [source_paths]}``.
+1. Build a *definition index*: ``{alias|id → [(source_path, synthesized)]}``.
2. For each reference ``(alias, id)`` in a diagram, look up the index (FQN
``id`` first, then ``alias``) to find candidate definer diagrams.
-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.
+3. Prefer non-synthesized candidates over synthesized ones (see above).
+4. If exactly one definer remains: emit the link.
+5. If multiple definers remain: 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).
+6. Never link a diagram to itself.
"""
from __future__ import annotations
@@ -154,32 +172,56 @@ def _resolve_definer(
alias: str,
fqn: str,
source_key: str,
- definition_index: dict[str, list[str]],
+ definition_index: dict[str, list[tuple[str, bool]]],
) -> str | None:
"""Return the definer source key for one reference, or ``None``.
- Resolution rules:
-
- * FQN (``id``) lookup takes precedence over the ``alias`` lookup — but
- only when it yields a definer *other than* the diagram itself; if the
- only FQN match is a self-link, the ``alias`` lookup is still tried
- rather than giving up (a diagram may re-declare its own top-level FQN
- 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).
+ Resolution rules, in order:
+
+ * A diagram never links to itself (self-links are dropped from every
+ lookup below).
+ * Non-synthesized candidates (a diagram that actually elaborates the
+ element) always outrank synthesized ones (a class diagram's namespace
+ container, added only so an entity's FQN reflects containment).
+ Several unrelated class diagrams routinely nest their entities under
+ the same shared namespace purely for containment; without this
+ preference every one of them would tie as a co-definer of that
+ namespace and the real elaboration site (e.g. its `static` component
+ diagram) would never win — even though its own FQN key has no
+ synthesized competitors, a *different* lookup key (see next point)
+ might.
+ * FQN (``id``) lookup takes precedence over the ``alias`` lookup at each
+ preference tier — but only within that tier: if the FQN lookup has no
+ non-synthesized hit, the alias lookup's non-synthesized hit (if any)
+ is preferred over settling for tied/synthesized FQN hits, since a
+ real elaboration site always wins regardless of which lookup found it.
+ * Within the first non-empty tier, a single candidate wins outright;
+ multiple candidates go through the proximity tiebreak, and a genuine
+ tie logs a warning and returns ``None`` (safe over wrong).
"""
_assert_canonical_source_key(source_key)
- def _candidates_for(key: str) -> list[str]:
+ def _candidates_for(key: str) -> list[tuple[str, bool]]:
raw = definition_index.get(key, [])
- for candidate in raw:
+ for candidate, _synthesized in raw:
_assert_canonical_source_key(candidate)
- return [c for c in raw if c != source_key]
+ return [(c, s) for c, s in raw if c != source_key]
+
+ fqn_entries = _candidates_for(fqn)
+ alias_entries = _candidates_for(alias)
+
+ def _non_synthesized(entries: list[tuple[str, bool]]) -> list[str]:
+ return [c for c, synthesized in entries if not synthesized]
+
+ def _all_sources(entries: list[tuple[str, bool]]) -> list[str]:
+ return [c for c, _synthesized in entries]
- candidates = _candidates_for(fqn) or _candidates_for(alias)
+ candidates = (
+ _non_synthesized(fqn_entries)
+ or _non_synthesized(alias_entries)
+ or _all_sources(fqn_entries)
+ or _all_sources(alias_entries)
+ )
if not candidates:
return None
if len(candidates) == 1:
@@ -248,7 +290,7 @@ def _escape_plantuml_url(url: str) -> str:
def _load_idmap_files(
source_dir: Path,
-) -> tuple[dict[str, Any], dict[str, list[str]]]:
+) -> tuple[dict[str, Any], dict[str, list[tuple[str, bool]]]]:
"""Scan *source_dir* for ``*.idmap.json`` and build the lookup indices.
The canonical key is the workspace-relative POSIX path stored in each
@@ -258,13 +300,13 @@ def _load_idmap_files(
Returns:
idmap_by_source: ``{canonical_source_key → raw idmap dict}``
- definition_index: ``{alias_or_fqn_id → [canonical_source_keys]}``
+ definition_index: ``{alias_or_fqn_id → [(canonical_source_key, synthesized)]}``
Raises:
ExtensionError: when two idmaps normalise to the same canonical key.
"""
idmap_by_source: dict[str, Any] = {}
- definition_index: dict[str, list[str]] = {}
+ definition_index: dict[str, list[tuple[str, bool]]] = {}
for json_path in sorted(source_dir.rglob("*.idmap.json")):
try:
@@ -291,13 +333,24 @@ def _load_idmap_files(
)
idmap_by_source[source_key] = data
+ # Diagrams marked `excluded_from_definitions` (e.g. an
+ # architectural_design() `static_view` diagram — a partial/subset
+ # view of the `static` architecture) never elaborate anything for
+ # linking purposes: their `defines` must not enter the global index,
+ # so a reference elsewhere can only ever resolve to the diagram that
+ # actually defines the element (in `static`). The diagram's own
+ # `references` are unaffected and still resolve normally below.
+ if data.get("excluded_from_definitions", False):
+ continue
+
for entry in data.get("defines", []):
alias = entry.get("alias", "")
fqn = entry.get("id", "")
- if alias and source_key not in definition_index.setdefault(alias, []):
- definition_index[alias].append(source_key)
- if fqn and fqn != alias and source_key not in definition_index.setdefault(fqn, []):
- definition_index[fqn].append(source_key)
+ synthesized = entry.get("synthesized", False)
+ for key in filter(None, dict.fromkeys((alias, fqn))):
+ bucket = definition_index.setdefault(key, [])
+ if source_key not in (c for c, _synthesized in bucket):
+ bucket.append((source_key, synthesized))
logger.info(
"clickable_plantuml: loaded %d idmap file(s), %d unique definition keys",
@@ -583,7 +636,7 @@ def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> N
``app.builder.get_relative_uri(docname, target_docname)``.
"""
idmap_by_source: dict[str, Any] = getattr(app.env, _ENV_IDMAP_BY_SOURCE, {})
- definition_index: dict[str, list[str]] = getattr(app.env, _ENV_DEF_INDEX, {})
+ definition_index: dict[str, list[tuple[str, bool]]] = getattr(app.env, _ENV_DEF_INDEX, {})
if app.builder.format != "html" or not idmap_by_source:
return
diff --git a/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py
index 3cee52e0..e286a07d 100644
--- a/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py
+++ b/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py
@@ -53,6 +53,7 @@ def _write_idmap(
source: str,
defines: list[dict[str, str]] | None = None,
references: list[dict[str, str]] | None = None,
+ excluded_from_definitions: bool = False,
) -> None:
directory.mkdir(parents=True, exist_ok=True)
(directory / name).write_text(
@@ -61,6 +62,7 @@ def _write_idmap(
"source": source,
"defines": defines or [],
"references": references or [],
+ "excluded_from_definitions": excluded_from_definitions,
}
),
encoding="utf-8",
@@ -90,8 +92,8 @@ def test_load_idmap_builds_source_and_definition_indices(tmp_path: Path) -> None
assert set(idmap_by_source) == {"pkg/a/proxy.puml", "pkg/b/overview.puml"}
# Both the alias and the FQN point at the definer.
- assert definition_index["Proxy"] == ["pkg/a/proxy.puml"]
- assert definition_index["pkg.Proxy"] == ["pkg/a/proxy.puml"]
+ assert definition_index["Proxy"] == [("pkg/a/proxy.puml", False)]
+ assert definition_index["pkg.Proxy"] == [("pkg/a/proxy.puml", False)]
def test_same_basename_in_different_dirs_are_distinct_keys(tmp_path: Path) -> None:
@@ -112,6 +114,59 @@ def test_duplicate_canonical_key_raises_build_error(tmp_path: Path) -> None:
_load_idmap_files(tmp_path)
+def test_excluded_from_definitions_source_is_never_a_definer(tmp_path: Path) -> None:
+ # A static_view-style diagram elaborates "Proxy" too (e.g. it shows the
+ # same nested structure as static), but is marked excluded_from_definitions.
+ _write_idmap(
+ tmp_path / "sv",
+ "static_view.idmap.json",
+ "pkg/sv/static_view.puml",
+ defines=[{"alias": "Proxy", "id": "pkg.Proxy"}],
+ excluded_from_definitions=True,
+ )
+ _write_idmap(
+ tmp_path / "static",
+ "proxy.idmap.json",
+ "pkg/static/proxy.puml",
+ defines=[{"alias": "Proxy", "id": "pkg.Proxy"}],
+ )
+
+ idmap_by_source, definition_index = _load_idmap_files(tmp_path)
+
+ # Both idmaps are still discovered (their own references still resolve)...
+ assert set(idmap_by_source) == {"pkg/sv/static_view.puml", "pkg/static/proxy.puml"}
+ # ...but only the non-excluded diagram is a candidate definer, so a
+ # reference to "Proxy" is unambiguous rather than tied between the two.
+ assert definition_index["Proxy"] == [("pkg/static/proxy.puml", False)]
+ assert definition_index["pkg.Proxy"] == [("pkg/static/proxy.puml", False)]
+
+
+def test_load_idmap_records_synthesized_flag_per_definer(tmp_path: Path) -> None:
+ # A class diagram's namespace-container define (synthesized) and a
+ # component diagram's real elaboration site both land in the index, each
+ # tagged with its own `synthesized` bit.
+ _write_idmap(
+ tmp_path / "internal_api",
+ "for_a.idmap.json",
+ "pkg/internal_api/for_a.puml",
+ defines=[{"alias": "lola_binding", "id": "mw_com.lola_binding", "synthesized": True}],
+ )
+ _write_idmap(
+ tmp_path / "static",
+ "lola_binding.idmap.json",
+ "pkg/static/lola_binding.puml",
+ defines=[{"alias": "lola_binding", "id": "lola_binding"}],
+ )
+
+ _, definition_index = _load_idmap_files(tmp_path)
+
+ assert set(definition_index["lola_binding"]) == {
+ ("pkg/internal_api/for_a.puml", True),
+ ("pkg/static/lola_binding.puml", False),
+ }
+ assert set(definition_index["mw_com.lola_binding"]) == {("pkg/internal_api/for_a.puml", True)}
+
+
# ---------------------------------------------------------------------------
# node source-key resolution (strict exact matching)
# ---------------------------------------------------------------------------
@@ -367,7 +422,7 @@ class _FakePlantumlNode(nodes.Element):
}
},
)
- setattr(env, _ENV_DEF_INDEX, {"pkg.Proxy": ["pkg/proxy.puml"]})
+ setattr(env, _ENV_DEF_INDEX, {"pkg.Proxy": [("pkg/proxy.puml", False)]})
setattr(env, _ENV_SOURCE_KEYS, frozenset({"pkg/overview.puml", "pkg/proxy.puml"}))
setattr(env, _ENV_WORKSPACE_OFFSET, "/workspace")
setattr(env, _ENV_PUML_DOCNAMES, {"pkg/proxy.puml": ("design/proxy", "proxy-section")})
@@ -399,8 +454,8 @@ def get_relative_uri(self, from_docname: str, to_docname: str) -> str:
def test_resolve_definer_prefers_fqn_over_alias() -> None:
definition_index = {
- "Proxy": ["pkg/alias_hit.puml"],
- "pkg.Proxy": ["pkg/fqn_hit.puml"],
+ "Proxy": [("pkg/alias_hit.puml", False)],
+ "pkg.Proxy": [("pkg/fqn_hit.puml", False)],
}
target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index)
@@ -410,7 +465,7 @@ def test_resolve_definer_prefers_fqn_over_alias() -> None:
def test_resolve_definer_falls_back_to_alias_when_fqn_missing() -> None:
definition_index = {
- "Proxy": ["pkg/alias_hit.puml"],
+ "Proxy": [("pkg/alias_hit.puml", False)],
}
target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index)
@@ -423,8 +478,8 @@ def test_resolve_definer_falls_back_to_alias_when_fqn_hit_is_only_self() -> None
# re-declares its own FQN); a distinct alias-based definer must still be
# found rather than giving up after the FQN lookup is filtered to empty.
definition_index = {
- "pkg.Proxy": ["pkg/src.puml"],
- "Proxy": ["pkg/alias_hit.puml"],
+ "pkg.Proxy": [("pkg/src.puml", False)],
+ "Proxy": [("pkg/alias_hit.puml", False)],
}
target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index)
@@ -434,7 +489,7 @@ def test_resolve_definer_falls_back_to_alias_when_fqn_hit_is_only_self() -> None
def test_resolve_definer_skips_self_link() -> None:
definition_index = {
- "Proxy": ["pkg/src.puml"],
+ "Proxy": [("pkg/src.puml", False)],
}
target = _resolve_definer("Proxy", "Proxy", "pkg/src.puml", definition_index)
@@ -446,7 +501,7 @@ def test_resolve_definer_tie_returns_none_and_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
definition_index = {
- "Proxy": ["a/one.puml", "b/two.puml"],
+ "Proxy": [("a/one.puml", False), ("b/two.puml", False)],
}
caplog.set_level(logging.WARNING)
@@ -456,6 +511,39 @@ def test_resolve_definer_tie_returns_none_and_warns(
assert "ambiguous definition" in caplog.text
+def test_resolve_definer_prefers_non_synthesized_over_synthesized() -> None:
+ # Several class diagrams nest an interface under the same shared
+ # namespace purely for FQN containment (synthesized definers); the
+ # diagram that actually elaborates that namespace (e.g. its `static`
+ # component diagram) must win outright rather than tying with them.
+ definition_index = {
+ "lola_binding": [
+ ("pkg/static/lola_binding.puml", False),
+ ("pkg/internal_api/for_a.puml", True),
+ ("pkg/internal_api/for_b.puml", True),
+ ],
+ }
+
+ target = _resolve_definer("lola_binding", "lola_binding", "pkg/src.puml", definition_index)
+
+ assert target == "pkg/static/lola_binding.puml"
+
+
+def test_resolve_definer_ties_among_synthesized_when_no_real_definer() -> None:
+ # With no non-synthesized definer at all, synthesized candidates are the
+ # only option; multiple synthesized definers still tie (safe over wrong).
+ definition_index = {
+ "lola_proxy": [
+ ("pkg/internal_api/for_a.puml", True),
+ ("pkg/internal_api/for_b.puml", True),
+ ],
+ }
+
+ target = _resolve_definer("lola_proxy", "lola_proxy", "pkg/src.puml", definition_index)
+
+ assert target is None
+
+
def test_common_prefix_length_requires_canonical_keys() -> None:
with pytest.raises(ValueError, match="non-canonical source key"):
_common_prefix_length("/abs/a.puml", "pkg/b.puml")
diff --git a/validation/core/BUILD b/validation/core/BUILD
index 78ee2a3c..663d547b 100644
--- a/validation/core/BUILD
+++ b/validation/core/BUILD
@@ -66,11 +66,13 @@ rust_library(
"src/validators/shared/diagram_analysis.rs",
"src/validators/shared/helpers.rs",
"src/validators/shared/mod.rs",
+ "src/validators/static_view_consistency_validator.rs",
"src/validators/test/component_internal_api_validator_test.rs",
"src/validators/test/component_public_api_validator_test.rs",
"src/validators/test/component_sequence_validator_test.rs",
"src/validators/test/fixtures.rs",
"src/validators/test/sequence_internal_api_validator_test.rs",
+ "src/validators/test/static_view_consistency_validator_test.rs",
],
crate_root = "src/lib.rs",
visibility = ["//visibility:public"],
diff --git a/validation/core/README.md b/validation/core/README.md
index 0aff1224..f162e660 100644
--- a/validation/core/README.md
+++ b/validation/core/README.md
@@ -79,6 +79,7 @@ Profile validators:
`architectural-design`:
- `validate_component_sequence`
+- `validate_static_view_consistency`
`dependable-element`:
- `validate_bazel_component`
@@ -105,10 +106,19 @@ Each profile owns its own input schema.
"component_diagrams": ["path/to/component.fbs.bin"],
"sequence_diagrams": ["path/to/sequence.fbs.bin"],
"internal_api": ["path/to/internal_api.fbs.bin"],
- "public_api": ["path/to/public_api.fbs.bin"]
+ "public_api": ["path/to/public_api.fbs.bin"],
+ "static_view": ["path/to/static_view_component.fbs.bin"]
}
```
+`static_view` are parsed component-diagram outputs representing a partial
+"view" onto the architecture in `component_diagrams` (the `static` section).
+`validate_static_view_consistency` fails if a `static_view` diagram defines a
+component/unit that is not also defined, under the same parent, in the
+`static` diagrams. A component/unit may be declared in more than one
+`static_view` diagram (e.g. overlapping views); such repetition across
+`static_view` diagrams is not treated as a duplicate-entity error.
+
`unit`:
```json
diff --git a/validation/core/src/models/component_diagram_models.rs b/validation/core/src/models/component_diagram_models.rs
index 2e06aacd..de6218b0 100644
--- a/validation/core/src/models/component_diagram_models.rs
+++ b/validation/core/src/models/component_diagram_models.rs
@@ -70,7 +70,21 @@ impl ComponentDiagramInputs {
&self,
result: &mut ValidationResult,
) -> ComponentDiagramArchitecture {
- ComponentDiagramArchitecture::from_entities(&self.entities, result)
+ ComponentDiagramArchitecture::from_entities(&self.entities, result, true)
+ }
+
+ /// Build a [`ComponentDiagramArchitecture`] index from these diagram
+ /// inputs without reporting duplicate-entity errors.
+ ///
+ /// Used for `static_view` diagrams: multiple `static_view` files may
+ /// legitimately reference the same entity (e.g. overlapping partial
+ /// views), so duplicates across those files are not errors. Consistency
+ /// with the `static` diagram is checked separately.
+ pub fn to_static_view_architecture(
+ &self,
+ result: &mut ValidationResult,
+ ) -> ComponentDiagramArchitecture {
+ ComponentDiagramArchitecture::from_entities(&self.entities, result, false)
}
}
@@ -96,14 +110,22 @@ impl ComponentDiagramArchitecture {
/// `<>` go into `seooc_set`;
/// `<>` go into `comp_set`;
/// `<>` go into `unit_set`.
- /// Duplicates (same [`EntityKey`]) are reported via `result`.
- fn from_entities(entities: &[LogicComponent], result: &mut ValidationResult) -> Self {
+ /// Duplicates (same [`EntityKey`]) are reported via `result`, unless
+ /// `report_duplicates` is `false`.
+ fn from_entities(
+ entities: &[LogicComponent],
+ result: &mut ValidationResult,
+ report_duplicates: bool,
+ ) -> Self {
// Index by raw id for parent resolution; PlantUML nesting uses id,
// not alias.
let mut id_index: BTreeMap = BTreeMap::new();
for entity in entities {
let key = entity.id.to_lowercase();
if let Some(prev) = id_index.insert(key.clone(), entity) {
+ if !report_duplicates {
+ continue;
+ }
let kind = entity_kind_name(entity);
let alias = entity.match_key();
let parent =
@@ -144,9 +166,9 @@ impl ComponentDiagramArchitecture {
let filtered_component_count = components.len();
let filtered_unit_count = units.len();
- let seooc_set = Self::build_set(&seoocs, &id_index, result);
- let comp_set = Self::build_set(&components, &id_index, result);
- let unit_set = Self::build_set(&units, &id_index, result);
+ let seooc_set = Self::build_set(&seoocs, &id_index, result, report_duplicates);
+ let comp_set = Self::build_set(&components, &id_index, result, report_duplicates);
+ let unit_set = Self::build_set(&units, &id_index, result, report_duplicates);
Self {
seooc_set,
@@ -163,6 +185,7 @@ impl ComponentDiagramArchitecture {
items: &[&LogicComponent],
id_index: &BTreeMap,
result: &mut ValidationResult,
+ report_duplicates: bool,
) -> BTreeMap {
let mut set = BTreeMap::new();
for entity in items {
@@ -195,7 +218,7 @@ impl ComponentDiagramArchitecture {
};
let key = (alias, parent_alias);
if let Some(prev) = set.insert(key.clone(), (*entity).clone()) {
- if prev.id.eq_ignore_ascii_case(&entity.id) {
+ if !report_duplicates || prev.id.eq_ignore_ascii_case(&entity.id) {
continue;
}
let kind = entity_kind_name(entity);
diff --git a/validation/core/src/models/mod.rs b/validation/core/src/models/mod.rs
index e0d31986..a4b1646d 100644
--- a/validation/core/src/models/mod.rs
+++ b/validation/core/src/models/mod.rs
@@ -19,7 +19,7 @@ mod component_diagram_models;
mod sequence_diagram_models;
mod shared;
-use shared::EntityKey;
+pub use shared::EntityKey;
#[cfg(test)]
pub use bazel_models::BazelInputEntry;
diff --git a/validation/core/src/profiles/architectural_design.rs b/validation/core/src/profiles/architectural_design.rs
index d6f032e9..26493dd2 100644
--- a/validation/core/src/profiles/architectural_design.rs
+++ b/validation/core/src/profiles/architectural_design.rs
@@ -18,7 +18,7 @@ use crate::models::{
use crate::readers::{ClassDiagramReader, ComponentDiagramReader, SequenceDiagramReader};
use crate::validators::{
validate_component_internal_api, validate_component_public_api, validate_component_sequence,
- validate_sequence_internal_api,
+ validate_sequence_internal_api, validate_static_view_consistency,
};
use crate::ValidationResult;
use serde::Deserialize;
@@ -34,6 +34,7 @@ pub struct ArchitecturalDesignInputs {
sequence_diagrams: Vec,
internal_api_diagrams: Vec,
public_api_diagrams: Vec,
+ static_view: Vec,
}
fn registered_validators<'a>(
@@ -41,6 +42,7 @@ fn registered_validators<'a>(
sequence: &'a Option,
internal_api: &'a Option,
public_api: &'a Option,
+ static_view: &'a Option,
) -> Vec> {
vec![
Box::new(move || {
@@ -63,6 +65,10 @@ fn registered_validators<'a>(
component.as_ref(),
))
}),
+ Box::new(move || {
+ let (component, static_view) = (component.as_ref()?, static_view.as_ref()?);
+ Some(validate_static_view_consistency(component, static_view))
+ }),
]
}
@@ -88,8 +94,19 @@ pub fn run(inputs: &ArchitecturalDesignInputs) -> Result {
&mut result,
|raw: ClassDiagramInputs, _result| PublicApiIndex::build_index(&raw),
)?;
+ let static_view = read_and_convert::(
+ inputs.static_view.as_slice(),
+ &mut result,
+ |raw: ComponentDiagramInputs, errs| raw.to_static_view_architecture(errs),
+ )?;
- let validators = registered_validators(&component, &sequence, &internal_api, &public_api);
+ let validators = registered_validators(
+ &component,
+ &sequence,
+ &internal_api,
+ &public_api,
+ &static_view,
+ );
let mut ran_validator = false;
for validator in validators {
diff --git a/validation/core/src/validators/mod.rs b/validation/core/src/validators/mod.rs
index 190482fc..b3c295b5 100644
--- a/validation/core/src/validators/mod.rs
+++ b/validation/core/src/validators/mod.rs
@@ -20,6 +20,7 @@ mod component_public_api_validator;
mod component_sequence_validator;
mod sequence_internal_api_validator;
mod shared;
+mod static_view_consistency_validator;
#[cfg(test)]
#[path = "test/fixtures.rs"]
@@ -31,3 +32,4 @@ pub use component_internal_api_validator::validate_component_internal_api;
pub use component_public_api_validator::validate_component_public_api;
pub use component_sequence_validator::validate_component_sequence;
pub use sequence_internal_api_validator::validate_sequence_internal_api;
+pub use static_view_consistency_validator::validate_static_view_consistency;
diff --git a/validation/core/src/validators/static_view_consistency_validator.rs b/validation/core/src/validators/static_view_consistency_validator.rs
new file mode 100644
index 00000000..bf63d3bf
--- /dev/null
+++ b/validation/core/src/validators/static_view_consistency_validator.rs
@@ -0,0 +1,167 @@
+// *******************************************************************************
+// 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
+//
+//
+// SPDX-License-Identifier: Apache-2.0
+// *******************************************************************************
+
+//! Validation: check that `static_view` component diagrams only reference
+//! components/units that are also defined in the `static` component
+//! diagrams.
+//!
+//! `static_view` diagrams are partial "views" onto the full static
+//! architecture: any component/unit they define must already be defined in
+//! `static`, and a `static_view` diagram may only include a subset of the
+//! units/components of the matching `static` component. It is not permitted
+//! to introduce components/units in `static_view` that do not exist in
+//! `static`.
+//!
+//! A component/unit may appear in more than one `static_view` diagram (e.g.
+//! overlapping views); such repetition across `static_view` diagrams is not
+//! checked for duplicates, only consistency with `static` is checked.
+
+use std::collections::BTreeMap;
+
+use crate::models::{ComponentDiagramArchitecture, EntityKey, LogicComponent};
+use crate::results::{ErrorBuilder, ErrorCategory};
+use crate::{Diagnostics, ValidationResult};
+
+/// Run static-vs-static_view component diagram consistency validation.
+pub fn validate_static_view_consistency(
+ static_diagram: &ComponentDiagramArchitecture,
+ static_view_diagram: &ComponentDiagramArchitecture,
+) -> ValidationResult {
+ StaticViewConsistencyValidator::new().run(static_diagram, static_view_diagram)
+}
+
+/// Compares a `static` [`ComponentDiagramArchitecture`] against a
+/// `static_view` [`ComponentDiagramArchitecture`], reporting any
+/// component/unit defined in `static_view` that is not also defined in
+/// `static`. A parentless static-view entry may reference a uniquely named
+/// static entity from a different architectural scope.
+struct StaticViewConsistencyValidator {
+ result: ValidationResult,
+}
+
+impl StaticViewConsistencyValidator {
+ fn new() -> Self {
+ Self {
+ result: ValidationResult::default(),
+ }
+ }
+
+ fn run(
+ mut self,
+ static_diagram: &ComponentDiagramArchitecture,
+ static_view_diagram: &ComponentDiagramArchitecture,
+ ) -> ValidationResult {
+ append_debug_log(
+ &mut self.result.diagnostics,
+ static_diagram,
+ static_view_diagram,
+ );
+ self.check_only_known_entities(
+ &static_diagram.comp_set,
+ &static_view_diagram.comp_set,
+ "component",
+ );
+ self.check_only_known_entities(
+ &static_diagram.unit_set,
+ &static_view_diagram.unit_set,
+ "unit",
+ );
+ self.result
+ }
+
+ /// Reports every entity present in `static_view_set` that is not present
+ /// in `static_set`. Parent-qualified view entries must match exactly;
+ /// parentless view entries may match a uniquely named static entity.
+ fn check_only_known_entities(
+ &mut self,
+ static_set: &BTreeMap,
+ static_view_set: &BTreeMap,
+ entity_type: &str,
+ ) {
+ for (key, entity) in static_view_set {
+ if !Self::is_known_static_entity(static_set, key) {
+ let (name, parent) = key;
+ let parent_str = parent.as_deref().unwrap_or("(top-level)");
+ self.result
+ .add_failure(Self::format_extra(entity_type, name, parent_str, entity));
+ }
+ }
+ }
+
+ fn is_known_static_entity(
+ static_set: &BTreeMap,
+ static_view_key: &EntityKey,
+ ) -> bool {
+ if static_set.contains_key(static_view_key) {
+ return true;
+ }
+
+ let (name, parent) = static_view_key;
+ parent.is_none()
+ && static_set
+ .keys()
+ .filter(|(static_name, _)| static_name == name)
+ .take(2)
+ .count()
+ == 1
+ }
+
+ fn format_extra(
+ entity_type: &str,
+ name: &str,
+ parent_str: &str,
+ entity: &LogicComponent,
+ ) -> String {
+ let (source_file, source_line) = entity.source_location.display();
+
+ ErrorBuilder::new(ErrorCategory::Design)
+ .title(format!(
+ "{entity_type} \"{name}\" in the static_view diagram is not defined in the static diagram"
+ ))
+ .field("alias", format!("\"{name}\""))
+ .field("parent", parent_str)
+ .field("static_view source file", format!("\"{source_file}\""))
+ .field("static_view source line", source_line.to_string())
+ .fix(format!(
+ "add {entity_type} \"{name}\" under \"{parent_str}\" to the static diagram, or remove it from the static_view diagram"
+ ))
+ .build()
+ }
+}
+
+fn append_debug_log(
+ diagnostics: &mut Diagnostics,
+ static_diagram: &ComponentDiagramArchitecture,
+ static_view_diagram: &ComponentDiagramArchitecture,
+) {
+ diagnostics.debug(|| "static component set:".to_string());
+ for key in static_diagram.comp_set.keys() {
+ diagnostics.debug(|| format!(" {:?}", key));
+ }
+ diagnostics.debug(|| "static unit set:".to_string());
+ for key in static_diagram.unit_set.keys() {
+ diagnostics.debug(|| format!(" {:?}", key));
+ }
+ diagnostics.debug(|| "static_view component set:".to_string());
+ for key in static_view_diagram.comp_set.keys() {
+ diagnostics.debug(|| format!(" {:?}", key));
+ }
+ diagnostics.debug(|| "static_view unit set:".to_string());
+ for key in static_view_diagram.unit_set.keys() {
+ diagnostics.debug(|| format!(" {:?}", key));
+ }
+}
+
+#[cfg(test)]
+#[path = "test/static_view_consistency_validator_test.rs"]
+mod tests;
diff --git a/validation/core/src/validators/test/static_view_consistency_validator_test.rs b/validation/core/src/validators/test/static_view_consistency_validator_test.rs
new file mode 100644
index 00000000..ef3d32c4
--- /dev/null
+++ b/validation/core/src/validators/test/static_view_consistency_validator_test.rs
@@ -0,0 +1,182 @@
+// *******************************************************************************
+// 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
+//
+//
+// SPDX-License-Identifier: Apache-2.0
+// *******************************************************************************
+
+use super::*;
+use crate::models::{ComponentDiagramInputs, ComponentType, LogicComponent};
+use crate::validators::fixtures::dummy_source_location;
+
+fn entity(
+ id: &str,
+ alias: Option<&str>,
+ parent_id: Option<&str>,
+ stereotype: Option<&str>,
+) -> LogicComponent {
+ LogicComponent {
+ id: id.to_string(),
+ name: alias.map(|s| s.to_string()),
+ alias: alias.map(|s| s.to_string()),
+ parent_id: parent_id.map(|s| s.to_string()),
+ element_type: ComponentType::Component,
+ stereotype: stereotype.map(|s| s.to_string()),
+ relations: Vec::new(),
+ source_location: dummy_source_location(),
+ }
+}
+
+fn diagram(entities: Vec) -> ComponentDiagramInputs {
+ ComponentDiagramInputs { entities }
+}
+
+fn run(
+ static_entities: Vec,
+ static_view_entities: Vec,
+) -> ValidationResult {
+ let mut result = ValidationResult::default();
+ let static_diagram = diagram(static_entities).to_diagram_architecture(&mut result);
+ let static_view_diagram =
+ diagram(static_view_entities).to_static_view_architecture(&mut result);
+ result.merge(validate_static_view_consistency(
+ &static_diagram,
+ &static_view_diagram,
+ ));
+ result
+}
+
+#[test]
+fn static_view_subset_of_static_passes() {
+ let static_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ entity("CompA.Unit2", Some("unit_2"), Some("CompA"), Some("unit")),
+ ];
+ let static_view_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ ];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(
+ result.is_empty(),
+ "Expected pass, got: {:?}",
+ result.failures
+ );
+}
+
+#[test]
+fn parentless_static_view_unit_matches_unique_static_unit() {
+ let static_entities = vec![
+ entity("MwCom", Some("mw_com"), None, Some("SEooC")),
+ entity(
+ "MwCom.BindingFactories",
+ Some("binding_factories"),
+ Some("MwCom"),
+ Some("unit"),
+ ),
+ ];
+ let static_view_entities = vec![entity(
+ "BindingFactories",
+ Some("binding_factories"),
+ None,
+ Some("unit"),
+ )];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(
+ result.is_empty(),
+ "Expected parentless static-view unit to match unique static unit, got: {:?}",
+ result.failures
+ );
+}
+
+#[test]
+fn parentless_static_view_unit_with_ambiguous_static_name_fails() {
+ let static_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompB", Some("comp_b"), None, Some("component")),
+ entity(
+ "CompA.SharedUnit",
+ Some("shared_unit"),
+ Some("CompA"),
+ Some("unit"),
+ ),
+ entity(
+ "CompB.SharedUnit",
+ Some("shared_unit"),
+ Some("CompB"),
+ Some("unit"),
+ ),
+ ];
+ let static_view_entities = vec![entity(
+ "SharedUnit",
+ Some("shared_unit"),
+ None,
+ Some("unit"),
+ )];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(result.failures.iter().any(|message| message.contains(
+ "Unit \"shared_unit\" in the static_view diagram is not defined in the static diagram"
+ )));
+}
+
+#[test]
+fn static_view_component_not_in_static_fails() {
+ let static_entities = vec![entity("CompA", Some("comp_a"), None, Some("component"))];
+ let static_view_entities = vec![entity("CompB", Some("comp_b"), None, Some("component"))];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(result.failures.iter().any(|message| message.contains(
+ "Component \"comp_b\" in the static_view diagram is not defined in the static diagram"
+ )));
+}
+
+#[test]
+fn static_view_unit_not_in_static_fails() {
+ let static_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ ];
+ let static_view_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ entity("CompA.Unit2", Some("unit_2"), Some("CompA"), Some("unit")),
+ ];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(result.failures.iter().any(|message| message.contains(
+ "Unit \"unit_2\" in the static_view diagram is not defined in the static diagram"
+ )));
+}
+
+#[test]
+fn static_view_entity_repeated_across_views_is_not_a_duplicate() {
+ // Simulates the same entity being declared in two separate static_view
+ // diagram files, which are merged before consistency checking.
+ let static_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ ];
+ let static_view_entities = vec![
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ entity("CompA", Some("comp_a"), None, Some("component")),
+ entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")),
+ ];
+
+ let result = run(static_entities, static_view_entities);
+ assert!(
+ result.is_empty(),
+ "Expected no duplicate-entity error across static_view diagrams, got: {:?}",
+ result.failures
+ );
+}