diff --git a/bazel/rules/rules_score/BUILD b/bazel/rules/rules_score/BUILD index c365f3ac..f6642338 100644 --- a/bazel/rules/rules_score/BUILD +++ b/bazel/rules/rules_score/BUILD @@ -165,6 +165,15 @@ sphinx_docs_library( strip_prefix = "validation/core/docs/specifications/", ) +# PlantUML diagrams referenced by clickable_plantuml/README.md +# (relative links like `docs/assets/clickable_plantuml_pipeline.puml`). +sphinx_docs_library( + name = "clickable_plantuml_doc_assets", + srcs = ["//plantuml/sphinx/clickable_plantuml:doc_assets"], + prefix = "bazel/rules/rules_score/docs/tool_reference/", + strip_prefix = "plantuml/sphinx/clickable_plantuml/", +) + # Source files from the standalone minimal example, mapped into the Sphinx # source tree so that general.rst can reference them via literalinclude. sphinx_docs_library( @@ -194,6 +203,7 @@ sphinx_module( ":minimal_example_srcs", ":tool_readme_ai_checker_assets", ":validation_specs", + ":clickable_plantuml_doc_assets", ], index = "docs/index.rst", renamed_srcs = { diff --git a/bazel/rules/rules_score/README.md b/bazel/rules/rules_score/README.md index 5162c104..03d72afe 100644 --- a/bazel/rules/rules_score/README.md +++ b/bazel/rules/rules_score/README.md @@ -91,8 +91,9 @@ architectural_design( **`bazel build`** — runs `puml_parser` on every `.puml` file, producing: - a `.fbs.bin` FlatBuffers binary (diagram AST) — consumed by validation/core checks - a `.lobster` traceability file (Interface elements only) — consumed by LOBSTER -- a `plantuml_links.json` — consumed by the `clickable_plantuml` Sphinx extension - a `validation.log` from the `architectural-design` validation profile +- a `.idmap.json` sidecar — consumed by the `clickable_plantuml` Sphinx extension + to resolve cross-diagram links based on element *defines/references* roles Diagrams in `public_api` are classified separately so their lobster items flow through `public_api_lobster_files` for failure-mode traceability. diff --git a/bazel/rules/rules_score/docs/_assets/tooling_chain.puml b/bazel/rules/rules_score/docs/_assets/tooling_chain.puml index 0db400a7..ce5f8b46 100644 --- a/bazel/rules/rules_score/docs/_assets/tooling_chain.puml +++ b/bazel/rules/rules_score/docs/_assets/tooling_chain.puml @@ -42,7 +42,7 @@ rectangle "sphinx_module" <> as sphinx ' ── Tools ───────────────────────────────────────────────────────────────────── rectangle "**TRLC**\ntrlc parser + trlc_rst\n(.trlc/.rsl -> .rst; TRLCRST lib)" <> as trlc rectangle "**rst_to_trlc**\n(.rst -> .trlc)" <> as r2t -rectangle "**PlantUML Parser**\nparser + linker (Rust)\n(.puml -> .fbs.bin + .lobster)" <> as puml +rectangle "**PlantUML Parser**\npuml_cli (Rust)\n(.puml -> .fbs.bin + .lobster + .idmap.json)" <> as puml rectangle "**puml_cli** (FTA mode)\ninline metamodel + extract\n(.puml -> inlined .puml +\nfta_chains.json + root_causes.lobster)" <> as fta rectangle "**fmea_assembler**\nTRLCRST page build\n(.trlc + chains -> fmea.rst)" <> as asm rectangle "**Lobster**\nlobster-trlc / -report /\n-ci-report / gtest_report" <> as lob diff --git a/bazel/rules/rules_score/docs/tooling_architecture.rst b/bazel/rules/rules_score/docs/tooling_architecture.rst index 39e5bf7f..47f242c5 100644 --- a/bazel/rules/rules_score/docs/tooling_architecture.rst +++ b/bazel/rules/rules_score/docs/tooling_architecture.rst @@ -91,13 +91,14 @@ are rendered under :doc:`tool_reference/index`. - Converts RST requirement directives (``feat_req``, ``comp_req``, …) into ``.trlc`` records so requirements can be authored in either RST or TRLC. * - **PlantUML Parser** - - ``@score_tooling//plantuml/parser:parser`` (Rust) + ``:linker`` + - ``@score_tooling//plantuml/parser:parser`` (Rust) - ``architectural_design``, ``unit_design`` - Parses ``.puml`` diagrams into a FlatBuffers AST (``.fbs.bin``, one - ``root_type`` per diagram kind) and extracts interface ``.lobster`` - items. The **linker** merges the FlatBuffers into ``plantuml_links.json`` - for the ``clickable_plantuml`` Sphinx extension. Rejects syntactically - invalid diagrams with a non-zero exit code. + ``root_type`` per diagram kind), extracts interface ``.lobster`` items, + and emits ``.idmap.json`` sidecars recording the *defines/references* + roles of each element. The ``clickable_plantuml`` Sphinx extension reads + these sidecars to resolve cross-diagram links without a separate linker + step. Rejects syntactically invalid diagrams with a non-zero exit code. * - **puml_cli (FTA mode)** - ``//plantuml/parser/puml_cli`` ``--fta-output-dir`` (Rust; FTA model in the ``puml_fta`` crate) @@ -121,6 +122,15 @@ are rendered under :doc:`tool_reference/index`. parse: an overview table, one section per failure mode (detail + inline fault tree + that chain's control measures), and trailing "Unlinked" sections so nothing is dropped. + * - **safety_analysis_tools** + - ``//bazel/rules/rules_score:safety_analysis_tools`` + (``src/safety_analysis_tools.py``, local) + - ``fmea`` + - Assembles the failure-mode-centric ``fmea.rst`` from ``fta_chains.json`` + plus the FailureMode / ControlMeasure records in one in-process TRLC + parse: an overview table, one section per failure mode (detail + inline + fault tree + that chain's control measures), and trailing "Unlinked" + sections so nothing is dropped. * - **Lobster** - ``@lobster//`` : ``lobster-trlc``, ``lobster-report``, ``lobster-ci-report``, ``lobster-html-report``, ``gtest_report``, diff --git a/bazel/rules/rules_score/examples/seooc/unit_2/docs/unit_2_class_diagram.puml b/bazel/rules/rules_score/examples/seooc/unit_2/docs/unit_2_class_diagram.puml index a8d71a1e..e9c8f6d0 100644 --- a/bazel/rules/rules_score/examples/seooc/unit_2/docs/unit_2_class_diagram.puml +++ b/bazel/rules/rules_score/examples/seooc/unit_2/docs/unit_2_class_diagram.puml @@ -13,12 +13,11 @@ @startuml unit_2_class_diagram +' Foo is owned and elaborated by unit_1 (see unit_1_class_diagram.puml); it is +' shown here only as a memberless collaborator stub, purely for context on +' Bar's composition relationship. namespace unit_1 { - class Foo <>{ - -- - + GetNumber() : uint8_t - + SetNumber(value : uint8_t) : void - } + class Foo <> } namespace unit_2 { diff --git a/bazel/rules/rules_score/private/architectural_design.bzl b/bazel/rules/rules_score/private/architectural_design.bzl index 47c62ed2..c55f15b9 100644 --- a/bazel/rules/rules_score/private/architectural_design.bzl +++ b/bazel/rules/rules_score/private/architectural_design.bzl @@ -32,18 +32,27 @@ load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_l # ============================================================================ def _run_puml_parser(ctx, puml_file): - """Run the PlantUML parser on a single .puml file to produce a FlatBuffers binary - and a lobster traceability file. + """Run the PlantUML parser on a single .puml file to produce a FlatBuffers binary, + a lobster traceability file, and an idmap sidecar. The diagram type is auto-detected by the parser and encoded in the FlatBuffers schema (each diagram type uses its own root_type). Lobster output is produced in-process for component diagrams. + ``--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` + Sphinx extension, which matches idmap ``source`` keys against paths it + derives from Sphinx's own doctree — an unstable value (e.g. a sandbox + exec-root-relative path) would silently break cross-diagram linking. + `BUILD_WORKSPACE_DIRECTORY`, which `puml_cli` otherwise falls back on, is + only set for `bazel run`, never for build actions like this one. + Args: ctx: Rule context puml_file: The .puml File object to parse Returns: - Tuple of (fbs_output, lobster_output) declared output Files. + Tuple of (fbs_output, lobster_output, idmap_output) declared output Files. """ file_stem = puml_file.basename.rsplit(".", 1)[0] fbs_output = ctx.actions.declare_file( @@ -52,10 +61,13 @@ def _run_puml_parser(ctx, puml_file): lobster_output = ctx.actions.declare_file( "{}/{}.lobster".format(ctx.label.name, file_stem), ) + idmap_output = ctx.actions.declare_file( + "{}/{}.idmap.json".format(ctx.label.name, file_stem), + ) ctx.actions.run( inputs = [puml_file], - outputs = [fbs_output, lobster_output], + outputs = [fbs_output, lobster_output, idmap_output], executable = ctx.executable._puml_parser, arguments = [ "--file", @@ -64,13 +76,17 @@ def _run_puml_parser(ctx, puml_file): 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), ], progress_message = "Parsing PlantUML diagram: %s" % puml_file.short_path, ) - return fbs_output, lobster_output + return fbs_output, lobster_output, idmap_output def _parse_puml_diagrams(ctx, files): """Run the PlantUML parser on all .puml/.plantuml files in a list. @@ -79,16 +95,18 @@ def _parse_puml_diagrams(ctx, files): ctx: Rule context files: List of File objects Returns: - Tuple of (fbs_outputs, lobster_outputs) lists of generated Files. + Tuple of (fbs_outputs, lobster_outputs, idmap_outputs) lists of generated Files. """ fbs_outputs = [] lobster_outputs = [] + idmap_outputs = [] for f in files: if f.extension in ("puml", "plantuml"): - fbs, lobster = _run_puml_parser(ctx, f) + fbs, lobster, idmap = _run_puml_parser(ctx, f) fbs_outputs.append(fbs) lobster_outputs.append(lobster) - return fbs_outputs, lobster_outputs + idmap_outputs.append(idmap) + return fbs_outputs, lobster_outputs, idmap_outputs def _run_validation(ctx, component_fbs_files, sequence_fbs_files, public_api_fbs_files, internal_api_fbs_files): """Run the architectural-design validation profile. @@ -139,10 +157,10 @@ def _architectural_design_impl(ctx): # Parse each architectural view separately so each provider field carries # the flatbuffers for its own category. - static_fbs_list, static_lobster_list = _parse_puml_diagrams(ctx, ctx.files.static) - dynamic_fbs_list, dynamic_lobster_list = _parse_puml_diagrams(ctx, ctx.files.dynamic) - public_api_fbs_list, public_api_lobster_list = _parse_puml_diagrams(ctx, ctx.files.public_api) - internal_api_fbs_list, _internal_api_lobster_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) + 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 = depset(static_fbs_list) dynamic_fbs = depset(dynamic_fbs_list) @@ -155,29 +173,17 @@ def _architectural_design_impl(ctx): transitive = [depset(ctx.files.static), depset(ctx.files.dynamic), depset(ctx.files.public_api), depset(ctx.files.internal_api)], ) - # Run the linker on all generated .fbs.bin files to produce a - # plantuml_links.json for the clickable_plantuml Sphinx extension. - all_fbs_files = static_fbs.to_list() + dynamic_fbs.to_list() + public_api_fbs.to_list() + internal_api_fbs.to_list() - plantuml_links_json = ctx.actions.declare_file( - "{}/plantuml_links.json".format(ctx.label.name), + # All idmap sidecars (across static/dynamic/public_api/internal_api) 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, ) - if all_fbs_files: - ctx.actions.run( - inputs = all_fbs_files, - outputs = [plantuml_links_json], - executable = ctx.executable._linker, - arguments = ["--fbs-files"] + [f.path for f in all_fbs_files] + ["--output", plantuml_links_json.path, "--log-level", get_log_level(ctx)], - progress_message = "Generating PlantUML links JSON for %s" % ctx.label.name, - ) - else: - ctx.actions.write( - output = plantuml_links_json, - content = '{"links":[]}', - ) sphinx_files = depset( - [plantuml_links_json], - transitive = [all_source_files], + transitive = [all_idmap_files, all_source_files], ) # Generate a thin RST wrapper for every .puml diagram so it appears as a @@ -210,7 +216,7 @@ def _architectural_design_impl(ctx): public_api_lobster_files = public_api_lobster, validation_logs = [validation_log], ), - # Source diagram files + plantuml_links.json for the sphinx documentation build + # Source diagram files + *.idmap.json sidecars for the sphinx documentation build SphinxSourcesInfo( srcs = sphinx_srcs, deps = sphinx_srcs, @@ -258,13 +264,7 @@ def _architectural_design_attrs(): default = Label("@score_tooling//plantuml/parser:parser"), executable = True, cfg = "exec", - doc = "PlantUML parser tool that generates FlatBuffers from .puml files", - ), - "_linker": attr.label( - default = Label("@score_tooling//plantuml/parser:linker"), - executable = True, - cfg = "exec", - doc = "Tool that generates plantuml_links.json from FlatBuffers diagram outputs", + doc = "PlantUML parser tool that generates FlatBuffers/lobster/idmap files from .puml files", ), "_puml_rst_template": attr.label( default = Label("//bazel/rules/rules_score:templates/puml_diagram.template.rst"), diff --git a/bazel/rules/rules_score/private/unit_design.bzl b/bazel/rules/rules_score/private/unit_design.bzl index afcf1bb1..5a5f2642 100644 --- a/bazel/rules/rules_score/private/unit_design.bzl +++ b/bazel/rules/rules_score/private/unit_design.bzl @@ -32,36 +32,60 @@ load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_l # ============================================================================ def _run_puml_parser(ctx, puml_file): - """Run the PlantUML parser on one .puml file and emit a FlatBuffers file.""" + """Run the PlantUML parser on one .puml file and emit a FlatBuffers file + and an idmap sidecar. + + ``--source-name`` is passed as ``puml_file.short_path`` (mirroring + architectural_design.bzl's ``_run_puml_parser``) so the ``source`` field + embedded in the idmap output is a stable, workspace-relative path that + the `clickable_plantuml` Sphinx extension can match against paths it + derives from Sphinx's own doctree. Without this, unit design diagrams + (e.g. a unit's class diagram) can never become the "definition" side of + a cross-diagram link, so references to that unit from architectural + design diagrams (static or dynamic) never resolve. + + Returns: + Tuple of (fbs_output, idmap_output) declared output Files. + """ file_stem = puml_file.basename.rsplit(".", 1)[0] fbs_output = ctx.actions.declare_file( "{}/{}.fbs.bin".format(ctx.label.name, file_stem), ) + idmap_output = ctx.actions.declare_file( + "{}/{}.idmap.json".format(ctx.label.name, file_stem), + ) ctx.actions.run( inputs = [puml_file], - outputs = [fbs_output], + outputs = [fbs_output, idmap_output], executable = ctx.executable._puml_parser, arguments = [ "--file", puml_file.path, "--fbs-output-dir", fbs_output.dirname, + "--idmap-output-dir", + idmap_output.dirname, + "--source-name", + puml_file.short_path, "--log-level", get_log_level(ctx), ], progress_message = "Parsing Unit Design PlantUML diagram: %s" % puml_file.short_path, ) - return fbs_output + return fbs_output, idmap_output def _parse_puml_diagrams(ctx, files): - """Run parser on all .puml/.plantuml files from a list and return fbs outputs.""" + """Run parser on all .puml/.plantuml files from a list and return fbs/idmap outputs.""" fbs_outputs = [] + idmap_outputs = [] for f in files: if f.extension in ("puml", "plantuml"): - fbs_outputs.append(_run_puml_parser(ctx, f)) - return fbs_outputs + fbs, idmap = _run_puml_parser(ctx, f) + fbs_outputs.append(fbs) + idmap_outputs.append(idmap) + return fbs_outputs, idmap_outputs def _unit_design_impl(ctx): """Implementation for unit_design rule. @@ -69,7 +93,7 @@ def _unit_design_impl(ctx): Collects unit design artifacts (RST documents and PlantUML diagrams) and provides them through the UnitDesignInfo and SphinxSourcesInfo providers. PlantUML files are passed through for Sphinx rendering and parsed into - FlatBuffers binaries. + FlatBuffers binaries and idmap sidecars. Args: ctx: Rule context @@ -82,8 +106,18 @@ def _unit_design_impl(ctx): transitive = [depset(ctx.files.static), depset(ctx.files.dynamic)], ) - static_fbs = depset(_parse_puml_diagrams(ctx, ctx.files.static)) - dynamic_fbs = depset(_parse_puml_diagrams(ctx, ctx.files.dynamic)) + static_fbs_list, static_idmap_list = _parse_puml_diagrams(ctx, ctx.files.static) + dynamic_fbs_list, dynamic_idmap_list = _parse_puml_diagrams(ctx, ctx.files.dynamic) + static_fbs = depset(static_fbs_list) + dynamic_fbs = depset(dynamic_fbs_list) + + # Stage idmap sidecars into the sphinx sources alongside the source + # diagrams so `clickable_plantuml` (which scans `srcdir` recursively for + # `*.idmap.json`) can discover them - see architectural_design.bzl's + # identical `all_idmap_files`/`sphinx_files` handling. + sphinx_srcs = depset( + transitive = [depset(static_idmap_list + dynamic_idmap_list), all_source_files], + ) return [ DefaultInfo(files = all_source_files), @@ -93,8 +127,8 @@ def _unit_design_impl(ctx): name = ctx.label.name, ), SphinxSourcesInfo( - srcs = all_source_files, - deps = all_source_files, + srcs = sphinx_srcs, + deps = sphinx_srcs, aux_srcs = depset(), ), ] diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index 7684381b..e631ecf3 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -118,6 +118,7 @@ py_binary( "@score_docs_as_code//src/extensions/score_sphinx_bundle", "@score_tooling//bazel/rules/rules_score:bazel_sphinx_needs", "@score_tooling//bazel/rules/rules_score:sphinx_module_ext", + "@score_tooling//plantuml/sphinx/clickable_plantuml", requirement("sphinx-rtd-theme"), ], ) @@ -259,6 +260,335 @@ architectural_design( static = ["fixtures/dep_seooc_lib.puml"], ) +# Live example backing the "End-to-End Clickable Diagram Example" documented +# in //plantuml/sphinx/clickable_plantuml/README.md: overview.puml references +# `Proxy`, which proxy_detail.puml defines, so clickable_plantuml should +# inject a real link between the two rendered diagrams. Regression-checked by +# :clickable_example_link_rendered_test below. +architectural_design( + name = "arch_design_clickable_example_overview", + static = ["fixtures/clickable_example/overview.puml"], +) + +architectural_design( + name = "arch_design_clickable_example_proxy_detail", + static = ["fixtures/clickable_example/proxy_detail.puml"], +) + +dependable_element( + name = "clickable_example_lib", + architectural_design = [ + ":arch_design_clickable_example_overview", + ":arch_design_clickable_example_proxy_detail", + ], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + # This fixture intentionally reuses the same top-level id ("Proxy") as + # both a reference (overview.puml) and a definition (proxy_detail.puml) + # to exercise clickable_plantuml's cross-diagram id resolution - exactly + # the scenario documented in the clickable_plantuml README. The + # dependable-element process-compliance validator doesn't understand + # define-vs-reference semantics and flags this as a duplicate id / missing + # SEooC package, so validation findings are downgraded to warnings here; + # this fixture is purely illustrative and isn't a real SEooC. + maturity = "development", + requirements = [":feat_req"], + tests = [], + deps = [], +) + +# Live example of clickable_plantuml's cross-diagram-type "interface" linking: +# interface_overview.puml (a component diagram) references `InternalInterface` +# via a `-(` binding on a unit, and interface_detail.puml (auto-detected as a +# class diagram because the interface has a method) defines it - both share +# the FQN `package_a.InternalInterface` because ids are rooted at the +# enclosing `package_a` package, not at the `@startuml` name. Mirrors +# validation/core/integration_test/component_internal_api/positive_interface_match. +# Regression-checked by :interface_example_link_rendered_test below. +architectural_design( + name = "arch_design_interface_example", + internal_api = ["fixtures/clickable_example/interface_detail.puml"], + static = ["fixtures/clickable_example/interface_overview.puml"], +) + +dependable_element( + name = "interface_example_lib", + architectural_design = [":arch_design_interface_example"], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "InternalInterface" 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 pure class-diagram linking: +# class_overview.puml references `AuditTrail` as a bare, member-less class, +# and class_detail.puml defines it (it has a method, making it the +# elaboration site). Regression-checked by :class_example_link_rendered_test +# below. +architectural_design( + name = "arch_design_class_example", + static = [ + "fixtures/clickable_example/class_overview.puml", + "fixtures/clickable_example/class_detail.puml", + ], +) + +dependable_element( + name = "class_example_lib", + architectural_design = [":arch_design_class_example"], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "AuditTrail" 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 "public API" linking chain: an +# interface bound to a unit in the *static* architecture (public_api_overview.puml, +# a component diagram) references `PublicInterface`, and public_api_detail.puml +# (also a component-style diagram, passed via architectural_design's +# `public_api` attribute, which also feeds FMEA/safety-analysis traceability - +# mirrors examples/seooc/design/public_api.puml) defines it. Both share the +# FQN `package_pub.PublicInterface`. Regression-checked by +# :public_api_example_link_rendered_test below. +architectural_design( + name = "arch_design_public_api_example", + public_api = ["fixtures/clickable_example/public_api_detail.puml"], + static = ["fixtures/clickable_example/public_api_overview.puml"], +) + +# public_api items must be referenced by a FailureMode in the SEooC's own +# safety analysis (unlike static/dynamic/internal_api) - the shared +# dependability_analysis_target/samplelibrary_safety_analysis fixture used by +# other targets in this file is deliberately empty, so this scenario gets its +# own minimal FMEA that traces to "package_pub.PublicInterface". +fmea( + name = "public_api_example_fmea", + arch_design = ":arch_design_public_api_example", + failuremodes = ["fixtures/clickable_example/public_api_failure_modes.trlc"], + root_causes = ["fixtures/clickable_example/public_api_fta.puml"], +) + +dependability_analysis( + name = "public_api_example_dependability_analysis", + arch_design = ":arch_design_public_api_example", + fmea = [":public_api_example_fmea"], +) + +dependable_element( + name = "public_api_example_lib", + architectural_design = [":arch_design_public_api_example"], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":public_api_example_dependability_analysis"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "PublicInterface" 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 +# unit_class_detail.puml (a class diagram naming a class after the same unit, +# nested in the matching namespace) defines it. Both share the FQN +# `package_u.component_u.unit_one`, since component-diagram ids and +# class-diagram namespace FQNs use the same dot-joined scheme. Regression- +# checked by :unit_example_link_rendered_test below. +architectural_design( + name = "arch_design_unit_example", + static = [ + "fixtures/clickable_example/unit_overview.puml", + "fixtures/clickable_example/unit_class_detail.puml", + ], +) + +dependable_element( + name = "unit_example_lib", + architectural_design = [":arch_design_unit_example"], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "unit_one" (well, its FQN) 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 namespace-as-define synthesis: unlike +# :unit_example above (whose class is literally named after the unit), +# namespace_unit_class_detail.puml elaborates `ns_unit` purely via the +# enclosing namespace `package_ns::component_ns::ns_unit`, nesting a +# differently-named class (`RealImpl`) inside it. class_model_to_idmap() must +# synthesize a define for the namespace FQN itself (not just for RealImpl) +# for namespace_unit_overview.puml's leaf `ns_unit` reference to resolve. +# Routed through unit_design()/unit()/component() (not a bare +# architectural_design static list) so this also regression-checks +# unit_design.bzl's idmap generation. Regression-checked by +# :namespace_unit_example_link_rendered_test below. +unit_design( + name = "namespace_unit_design", + static = ["fixtures/clickable_example/namespace_unit_class_detail.puml"], +) + +unit( + name = "namespace_unit", + testonly = True, + # RealImpl is illustrative only (mock_lib1 has no matching C++ class); + # downgrade the design-vs-implementation validation finding to a + # warning, same rationale as maturity="development" elsewhere in this + # file. + maturity = "development", + tests = [":test_unit_tests"], + unit_design = [":namespace_unit_design"], + implementation = [":mock_lib1"], +) + +component( + name = "namespace_unit_component", + testonly = True, + components = [":namespace_unit"], + requirements = [ + ":comp_req", + ":feat_req", + ], + tests = [], +) + +architectural_design( + name = "arch_design_namespace_unit_example", + static = ["fixtures/clickable_example/namespace_unit_overview.puml"], +) + +dependable_element( + name = "namespace_unit_example_lib", + testonly = True, + architectural_design = [":arch_design_namespace_unit_example"], + assumptions_of_use = [":aous"], + components = [":namespace_unit_component"], + dependability_analysis = [":dependability_analysis_target"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "ns_unit" (its FQN) as both a + # reference (static architecture) and a definition (namespace synthesis), + # which the dependable-element validator can't distinguish from an + # accidental duplicate id. + maturity = "development", + requirements = [":feat_req"], + tests = [], + deps = [], +) + +# Regression test: clickable_plantuml must inject a working link between a +# unit shown in the static architecture (namespace_unit_overview.puml) and +# its elaborating class diagram, whose only linkable element is the +# namespace itself, not a same-named class (namespace_unit_class_detail.puml, +# built via unit_design()) - the "namespace-as-define synthesis" scenario. +sh_test( + name = "namespace_unit_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "package_ns.component_ns.ns_unit", + "$(rootpaths :namespace_unit_example_lib_index)", + ], + data = [":namespace_unit_example_lib_index"], +) + +# Live example of clickable_plantuml's "dynamic to static" linking chain: the +# *dynamic* architecture (dynamic_overview.puml, a sequence diagram) shows +# participants `Dispatcher` and `Worker` - both references, since sequence +# diagrams have no defines - and dynamic_component_detail.puml (a component +# diagram in the *static* architecture) shows both as leaf units (also +# references, since neither has children of its own). Both units are +# elaborated by their own class diagrams (dynamic_dispatcher_class.puml, +# dynamic_worker_class.puml), which is where the actual click target lands - +# mirrors :unit_example above, applied to both units of a dynamic scenario. +# TaskInterface (shared by both units) is likewise only a reference in the +# component diagram; its definition lives in the *public API* +# (dynamic_public_api_detail.puml), exactly like :public_api_example above. +# Regression-checked by :dynamic_example_link_rendered_test, +# :dynamic_dispatcher_class_link_rendered_test, +# :dynamic_worker_class_link_rendered_test and +# :dynamic_public_api_link_rendered_test below. +# +# NOTE: clicking on individual *messages* in the sequence diagram (e.g. +# "Assign"/"Done") is not currently supported - sequence_model_to_idmap() +# only tracks participant names, not per-message/method labels (see +# puml_idmap/src/lib.rs). Implementing that would require the sequence +# resolver to persist message identifiers in SequenceTree first. +architectural_design( + name = "arch_design_dynamic_example", + dynamic = ["fixtures/clickable_example/dynamic_overview.puml"], + public_api = ["fixtures/clickable_example/dynamic_public_api_detail.puml"], + static = [ + "fixtures/clickable_example/dynamic_component_detail.puml", + "fixtures/clickable_example/dynamic_dispatcher_class.puml", + "fixtures/clickable_example/dynamic_worker_class.puml", + ], +) + +# public_api items must be referenced by a FailureMode in the SEooC's own +# safety analysis (see :public_api_example_fmea above for the same reasoning) +# - the shared dependability_analysis_target fixture is deliberately empty, so +# this scenario gets its own minimal FMEA that traces to "TaskInterface". +fmea( + name = "dynamic_example_fmea", + arch_design = ":arch_design_dynamic_example", + failuremodes = ["fixtures/clickable_example/dynamic_public_api_failure_modes.trlc"], + root_causes = ["fixtures/clickable_example/dynamic_public_api_fta.puml"], +) + +dependability_analysis( + name = "dynamic_example_dependability_analysis", + arch_design = ":arch_design_dynamic_example", + fmea = [":dynamic_example_fmea"], +) + +dependable_element( + name = "dynamic_example_lib", + architectural_design = [":arch_design_dynamic_example"], + assumptions_of_use = [":aous"], + components = [], + dependability_analysis = [":dynamic_example_dependability_analysis"], + integrity_level = "B", + # Downgraded to warnings for the same reason as clickable_example_lib + # above: this fixture intentionally reuses "Dispatcher"/"Worker"/ + # "TaskInterface" as both references and definitions, which the + # dependable-element validator can't distinguish from an accidental + # duplicate id. + maturity = "development", + requirements = [":feat_req"], + tests = [], + deps = [], +) + # Minimal dependable element used as a dependency target to test # the deps attribute of dependable_element (i.e. SEooC depending on another SEooC). dependable_element( @@ -572,6 +902,128 @@ sh_test( data = [":seooc_test_lib_index"], ) +# Regression test: clickable_plantuml must actually inject a working link +# between the two example diagrams in fixtures/clickable_example/ (mirrors +# //plantuml/sphinx/clickable_plantuml/README.md's documented example). +sh_test( + name = "clickable_example_link_rendered_test", + srcs = ["check_clickable_example_link.sh"], + args = ["$(rootpaths :clickable_example_lib_index)"], + data = [":clickable_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between the +# "InternalInterface" reference (interface_overview.puml, a component +# diagram) and its definition (interface_detail.puml, auto-detected as a +# class diagram) - the cross-diagram-type "interface" scenario. +sh_test( + name = "interface_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "package_a.InternalInterface", + "$(rootpaths :interface_example_lib_index)", + ], + data = [":interface_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between the +# "AuditTrail" reference (class_overview.puml) and its definition +# (class_detail.puml) - the pure class-diagram scenario. +sh_test( + name = "class_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "AuditTrail", + "$(rootpaths :class_example_lib_index)", + ], + data = [":class_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between an +# interface used in the static architecture (public_api_overview.puml) and +# its definition in the public API diagram (public_api_detail.puml) - the +# "interface in static architecture links to public API" scenario. +sh_test( + name = "public_api_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "package_pub.PublicInterface", + "$(rootpaths :public_api_example_lib_index)", + ], + data = [":public_api_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 +# class diagram" scenario. +sh_test( + name = "unit_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "package_u.component_u.unit_one", + "$(rootpaths :unit_example_lib_index)", + ], + data = [":unit_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between a +# participant shown in the dynamic architecture (dynamic_overview.puml, a +# sequence diagram) and the corresponding unit in the static architecture +# (dynamic_component_detail.puml) - the "dynamic participant links to static +# unit" scenario. +sh_test( + name = "dynamic_example_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "Worker", + "$(rootpaths :dynamic_example_lib_index)", + ], + data = [":dynamic_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between the +# "Dispatcher" unit (a reference in both dynamic_overview.puml and +# dynamic_component_detail.puml) and its elaborating class diagram +# (dynamic_dispatcher_class.puml) - the "unit links to its own class diagram" +# scenario, for the first of the two units. +sh_test( + name = "dynamic_dispatcher_class_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "Dispatcher", + "$(rootpaths :dynamic_example_lib_index)", + ], + data = [":dynamic_example_lib_index"], +) + +# Regression test: same as above, for the second unit ("Worker" links to +# dynamic_worker_class.puml). +sh_test( + name = "dynamic_worker_class_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "Worker", + "$(rootpaths :dynamic_example_lib_index)", + ], + data = [":dynamic_example_lib_index"], +) + +# Regression test: clickable_plantuml must inject a working link between +# "TaskInterface" as used in the static architecture +# (dynamic_component_detail.puml) and its definition in the public API +# (dynamic_public_api_detail.puml) - the "interface in static architecture +# links to public API" scenario, applied to the dynamic_example units. +sh_test( + name = "dynamic_public_api_link_rendered_test", + srcs = ["check_idmap_link.sh"], + args = [ + "TaskInterface", + "$(rootpaths :dynamic_example_lib_index)", + ], + data = [":dynamic_example_lib_index"], +) + # Test that sphinx_module is generated with correct providers seooc_sphinx_module_generated_test( name = "seooc_tests_sphinx_module_generated", diff --git a/bazel/rules/rules_score/test/check_clickable_example_link.sh b/bazel/rules/rules_score/test/check_clickable_example_link.sh new file mode 100755 index 00000000..797c1474 --- /dev/null +++ b/bazel/rules/rules_score/test/check_clickable_example_link.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# ******************************************************************************* +# 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 +# ******************************************************************************* +set -euo pipefail + +# Regression test for the clickable_plantuml Sphinx extension, backed by real +# Bazel-built artifacts (not just README prose). +# +# fixtures/clickable_example/overview.puml references `Proxy`, and +# fixtures/clickable_example/proxy_detail.puml defines it. clickable_plantuml +# resolves such cross-diagram references purely from the `*.idmap.json` +# sidecars produced by puml_cli/puml_idmap during the real +# :clickable_example_lib_doc Sphinx build, so asserting on their content here +# directly exercises the same parser + role-detection pipeline documented in +# //plantuml/sphinx/clickable_plantuml/README.md - independent of whether the +# external `plantuml` renderer binary itself is runnable in this sandbox +# (a pre-existing, unrelated hermeticity limitation of this test module). + +overview_idmap="" +proxy_detail_idmap="" +for rel_path in "$@"; do + candidate="${TEST_SRCDIR}/${TEST_WORKSPACE}/${rel_path}" + case "${candidate}" in + */overview.idmap.json) overview_idmap="${candidate}" ;; + */proxy_detail.idmap.json) proxy_detail_idmap="${candidate}" ;; + esac +done + +if [[ -z "${overview_idmap}" || ! -f "${overview_idmap}" ]]; then + echo "Error: could not locate overview.idmap.json among: $*" >&2 + exit 1 +fi +if [[ -z "${proxy_detail_idmap}" || ! -f "${proxy_detail_idmap}" ]]; then + echo "Error: could not locate proxy_detail.idmap.json among: $*" >&2 + exit 1 +fi + +# overview.puml must *reference* Proxy (no local definition)... +if ! grep -q '"alias": "Proxy"' "${overview_idmap}"; then + echo "Error: expected overview.idmap.json to reference 'Proxy':" >&2 + cat "${overview_idmap}" >&2 + exit 1 +fi + +# ...and proxy_detail.puml must *define* it, so clickable_plantuml has exactly +# one definer to resolve the reference to. +if ! python3 -c " +import json, sys +data = json.load(open('${proxy_detail_idmap}')) +sys.exit(0 if any(e['id'] == 'Proxy' for e in data.get('defines', [])) else 1) +"; then + echo "Error: expected proxy_detail.idmap.json to define 'Proxy':" >&2 + cat "${proxy_detail_idmap}" >&2 + exit 1 +fi + +echo "ok" diff --git a/bazel/rules/rules_score/test/check_idmap_link.sh b/bazel/rules/rules_score/test/check_idmap_link.sh new file mode 100755 index 00000000..c5299a9f --- /dev/null +++ b/bazel/rules/rules_score/test/check_idmap_link.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# ******************************************************************************* +# 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 +# ******************************************************************************* +set -euo pipefail + +# Generic regression-test helper for clickable_plantuml example fixtures. +# +# Usage: check_idmap_link.sh ... +# +# Asserts that, among the given `*.idmap.json` files (real Bazel-built +# artifacts produced by puml_cli/puml_idmap), `` appears in at +# least one file's "defines" list AND in at least one file's "references" +# list - i.e. that clickable_plantuml has exactly what it needs (a reference +# plus a matching definer) to make that element clickable, regardless of +# which diagram type (component, class, interface, ...) produced each file. + +expected_id="$1" +shift + +idmap_paths=() +for rel_path in "$@"; do + candidate="${TEST_SRCDIR}/${TEST_WORKSPACE}/${rel_path}" + if [[ -f "${candidate}" && "${candidate}" == *.idmap.json ]]; then + idmap_paths+=("${candidate}") + fi +done + +if [[ "${#idmap_paths[@]}" -eq 0 ]]; then + echo "Error: no *.idmap.json files found among: $*" >&2 + exit 1 +fi + +python3 -c " +import json, sys + +expected_id = sys.argv[1] +paths = sys.argv[2:] + +has_define = False +has_reference = False +for p in paths: + with open(p) as f: + data = json.load(f) + if any(e['id'] == expected_id for e in data.get('defines', [])): + has_define = True + if any(e['id'] == expected_id for e in data.get('references', [])): + has_reference = True + +if not has_define: + print(f'Error: no idmap.json defines id {expected_id!r}', file=sys.stderr) + sys.exit(1) +if not has_reference: + print(f'Error: no idmap.json references id {expected_id!r}', file=sys.stderr) + sys.exit(1) +print('ok') +" "${expected_id}" "${idmap_paths[@]}" diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/class_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/class_detail.puml new file mode 100644 index 00000000..9aa49e0a --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/class_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 class_detail + +class AuditTrail { + +record(event: string) +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/class_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/class_overview.puml new file mode 100644 index 00000000..f51bcf17 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/class_overview.puml @@ -0,0 +1,18 @@ +' ******************************************************************************* +' 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 class_overview + +class AuditTrail + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_component_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_component_detail.puml new file mode 100644 index 00000000..bbbc139e --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_component_detail.puml @@ -0,0 +1,26 @@ +' ******************************************************************************* +' 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 dynamic_component_detail + +component "Dispatcher" as Dispatcher <> +component "Worker" as Worker <> + +package "Dynamic Example" as dynamic_example_seooc <> + +interface "TaskInterface" as TaskInterface +Dispatcher -( TaskInterface +Worker )- TaskInterface +dynamic_example_seooc -( TaskInterface + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_dispatcher_class.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_dispatcher_class.puml new file mode 100644 index 00000000..98a049ac --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_dispatcher_class.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 dynamic_dispatcher_class + +class Dispatcher { + + Assign() +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_overview.puml new file mode 100644 index 00000000..6a28ac75 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_overview.puml @@ -0,0 +1,22 @@ +' ******************************************************************************* +' 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 dynamic_overview + +participant "Dispatcher" as Dispatcher <> +participant "Worker" as Worker <> + +Dispatcher -> Worker : Assign +Worker --> Dispatcher : Done + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_detail.puml new file mode 100644 index 00000000..cfd5be3d --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_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 TaskInterface + +interface "TaskInterface" as TaskInterface { + + AssignTask() +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_failure_modes.trlc b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_failure_modes.trlc new file mode 100644 index 00000000..a2a29a77 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_failure_modes.trlc @@ -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 + ********************************************************************************/ +package DynamicExampleFmea + +import ScoreReq + +ScoreReq.FailureMode TaskInterfaceFailure { + guidewords = [ScoreReq.Guideword.LossOfFunction] + description = "TaskInterface stops responding" + failureeffect = "Tasks are never dispatched to the Worker" + version = 1 + safety = ScoreReq.Asil.B + + interface = "TaskInterface.AssignTask" +} diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_fta.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_fta.puml new file mode 100644 index 00000000..1bce93b9 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_public_api_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("TaskInterface stops responding", "DynamicExampleFmea.TaskInterfaceFailure") + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_worker_class.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_worker_class.puml new file mode 100644 index 00000000..ec2152e4 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/dynamic_worker_class.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 dynamic_worker_class + +class Worker { + + Execute() +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/interface_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/interface_detail.puml new file mode 100644 index 00000000..44061ddf --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/interface_detail.puml @@ -0,0 +1,22 @@ +' ******************************************************************************* +' 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 interface_detail + +package package_a { + interface "InternalInterface" as InternalInterface { + + GetData() + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/interface_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/interface_overview.puml new file mode 100644 index 00000000..9a89a015 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/interface_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 interface_overview + +package "Package A" as package_a { + component "Component A" as component_a <> { + component "Unit 1" as unit_1 <> + } + + interface "InternalInterface" as InternalInterface + unit_1 -( InternalInterface +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_class_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_class_detail.puml new file mode 100644 index 00000000..00d7cca9 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_class_detail.puml @@ -0,0 +1,29 @@ +' ******************************************************************************* +' 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 +' ******************************************************************************* + +' Unlike unit_class_detail.puml (whose class is literally named after the +' unit, "unit_one"), this fixture elaborates the unit purely via the +' enclosing namespace: no class here is named "ns_unit". This exercises +' puml_idmap's namespace-as-define synthesis in class_model_to_idmap() - +' the namespace itself (not any class inside it) must become the "define" +' so namespace_unit_overview.puml's leaf `ns_unit` reference resolves. + +@startuml namespace_unit_class_detail + +namespace package_ns::component_ns::ns_unit { + class RealImpl { + + DoWork() + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_overview.puml new file mode 100644 index 00000000..d5ede666 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/namespace_unit_overview.puml @@ -0,0 +1,22 @@ +' ******************************************************************************* +' 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 namespace_unit_overview + +package "Package NS" as package_ns { + component "Component NS" as component_ns <> { + component "NS Unit" as ns_unit <> + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/overview.puml new file mode 100644 index 00000000..1bc02399 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/overview.puml @@ -0,0 +1,21 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml overview + +component Gateway +component Proxy + +Gateway --> Proxy + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/proxy_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/proxy_detail.puml new file mode 100644 index 00000000..49393a33 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/proxy_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 proxy_detail + +package Proxy { + [RequestHandler] +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_detail.puml new file mode 100644 index 00000000..7a67172e --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_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 PublicInterface + +package package_pub { + interface "PublicInterface" as PublicInterface +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_failure_modes.trlc b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_failure_modes.trlc new file mode 100644 index 00000000..85d78325 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_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 PublicApiExampleFmea + +import ScoreReq + +ScoreReq.FailureMode PublicInterfaceFailure { + guidewords = [ScoreReq.Guideword.LossOfFunction] + description = "PublicInterface stops responding" + failureeffect = "Callers never receive a response" + version = 1 + safety = ScoreReq.Asil.B + interface = "package_pub.PublicInterface" +} diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_fta.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_fta.puml new file mode 100644 index 00000000..61dd2b3d --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_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("PublicInterface stops responding", "PublicApiExampleFmea.PublicInterfaceFailure") + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_overview.puml new file mode 100644 index 00000000..190481b4 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/public_api_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 public_api_overview + +package "Package Pub" as package_pub { + component "Component Pub" as component_pub <> { + component "Unit Pub" as unit_pub <> + } + + interface "PublicInterface" as PublicInterface + unit_pub -( PublicInterface +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/unit_class_detail.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/unit_class_detail.puml new file mode 100644 index 00000000..987332e8 --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/unit_class_detail.puml @@ -0,0 +1,22 @@ +' ******************************************************************************* +' 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 unit_class_detail + +namespace package_u::component_u { + class unit_one { + + DoWork() + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/unit_overview.puml b/bazel/rules/rules_score/test/fixtures/clickable_example/unit_overview.puml new file mode 100644 index 00000000..b8c2443e --- /dev/null +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/unit_overview.puml @@ -0,0 +1,22 @@ +' ******************************************************************************* +' 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 unit_overview + +package "Package U" as package_u { + component "Component U" as component_u <> { + component "Unit One" as unit_one <> + } +} + +@enduml diff --git a/bazel/rules/rules_score/test/template/conf.template.py b/bazel/rules/rules_score/test/template/conf.template.py index 16470d6a..31eb0777 100644 --- a/bazel/rules/rules_score/test/template/conf.template.py +++ b/bazel/rules/rules_score/test/template/conf.template.py @@ -18,7 +18,12 @@ Template variables like {PROJECT_NAME} are replaced during Bazel build. """ +import os + import bazel_sphinx_needs +from sphinx.util import logging + +logger = logging.getLogger(__name__) # Project configuration - {PROJECT_NAME} will be replaced by the module name during build project = "{PROJECT_NAME}" @@ -35,12 +40,74 @@ "sphinx_design", "myst_parser", "sphinxcontrib.plantuml", - "score_plantuml", "score_metamodel", "score_draw_uml_funcs", "score_layout", + "clickable_plantuml", ] +# --------------------------------------------------------------------------- +# PlantUML / Graphviz binary discovery +# --------------------------------------------------------------------------- +# NOTE: we intentionally do NOT list "score_plantuml" (from score_docs_as_code) +# as an extension here. Its setup() overwrites app.config.plantuml with a path +# derived from RUNFILES_DIR that can be relative to the execroot. sphinxcontrib +# .plantuml invokes the plantuml command via subprocess.Popen(..., cwd=) (e.g. the doctree's source dir or its own cache dir), so a +# relative path silently fails to resolve there ('plantuml command ... cannot +# be run'), even though the binary itself works fine when invoked directly. +# +# Instead we resolve PLANTUML_BIN / GRAPHVIZ_DOT — which the sphinx_module +# Bazel rule always injects as action env vars (from //third_party/plantuml +# and //third_party/docs_runtime:dot) — and turn them into ABSOLUTE paths via +# os.path.abspath(). This mirrors the production +# bazel/rules/rules_score/templates/conf.template.py and is immune to any cwd +# changes made by sphinxcontrib-plantuml's subprocess calls. +_plantuml_bin = os.environ.get("PLANTUML_BIN") +if not _plantuml_bin: + raise ValueError( + "PLANTUML_BIN environment variable is not set. It must point at the " + "//third_party/plantuml:plantuml launcher and is normally provided by the " + "sphinx_module Bazel rule. If you are invoking Sphinx outside that rule, " + "set PLANTUML_BIN to the plantuml binary path." + ) +plantuml_path = os.path.abspath(_plantuml_bin) + +_graphviz_dot_path = os.environ.get("GRAPHVIZ_DOT") +if not _graphviz_dot_path: + raise ValueError( + "GRAPHVIZ_DOT environment variable is not set. It must point at the " + "//third_party/docs_runtime:dot hermetic wrapper and is normally provided " + "by the sphinx_module Bazel rule. If you are invoking Sphinx outside that " + "rule, set GRAPHVIZ_DOT to the hermetic dot wrapper path." + ) +graphviz_dot = os.path.abspath(_graphviz_dot_path) + +# The hermetic dot sysroot has its GD plugin pruned (no X11/pango/GD deps in +# the minimal sysroot), so it does not support png output. Sphinx's built-in +# graphviz extension (used e.g. by lobster's ".. graphviz:: tracing_policy" +# diagrams) defaults to png; force svg, which the hermetic dot supports. +graphviz_output_format = "svg" + +_fta_metamodel_dir = os.environ.get("FTA_METAMODEL_DIR", "") +if _fta_metamodel_dir: + _fta_metamodel_dir = os.path.abspath(_fta_metamodel_dir) + _include_flag = f" --jvm_flag=-Dplantuml.include.path={_fta_metamodel_dir}" +else: + logger.warning( + "FTA_METAMODEL_DIR is not set; FTA diagrams using " + "!include fta_metamodel.puml will fail to render." + ) + _include_flag = "" + +# PlantUML uses the same hermetic Graphviz dot as sphinx.ext.graphviz for its +# internal layout calls, via the -graphvizdot flag. +plantuml = f"{plantuml_path}{_include_flag} -graphvizdot {graphviz_dot}" + +# Render PlantUML diagrams as SVG so clickable_plantuml's injected links are +# actually clickable in the generated docs (png mode has no clickable overlay). +plantuml_output_format = "svg_obj" + # MyST parser extensions myst_enable_extensions = ["colon_fence"] diff --git a/plantuml/linker/README.md b/plantuml/linker/README.md deleted file mode 100644 index 710adbc9..00000000 --- a/plantuml/linker/README.md +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/plantuml/linker/src/main.rs b/plantuml/linker/src/main.rs deleted file mode 100644 index d3e708e2..00000000 --- a/plantuml/linker/src/main.rs +++ /dev/null @@ -1,274 +0,0 @@ -// ******************************************************************************* -// 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 -// ******************************************************************************* - -//! PlantUML Linker -//! -//! Reads FlatBuffers `.fbs.bin` files produced by the PlantUML parser and -//! generates `plantuml_links.json` for the `clickable_plantuml` Sphinx extension. -//! -//! The tool correlates components across multiple diagrams: when a component -//! alias in diagram A matches a top-level component alias in diagram B, a -//! clickable link is created from A → B. - -use std::collections::HashMap; -use std::fs; - -use clap::{Parser, ValueEnum}; -use env_logger::Builder; - -use component_fbs::component as fb_component; - -// --------------------------------------------------------------------------- -// Log level -// --------------------------------------------------------------------------- - -/// CLI-visible log level (mirrors the parser's convention). -#[derive(Copy, Clone, ValueEnum, Debug)] -enum CliLogLevel { - Error, - Warn, - Info, - Debug, - Trace, -} - -impl CliLogLevel { - fn to_level_filter(self) -> log::LevelFilter { - match self { - CliLogLevel::Error => log::LevelFilter::Error, - CliLogLevel::Warn => log::LevelFilter::Warn, - CliLogLevel::Info => log::LevelFilter::Info, - CliLogLevel::Debug => log::LevelFilter::Debug, - CliLogLevel::Trace => log::LevelFilter::Trace, - } - } -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -#[derive(Parser, Debug)] -#[command(name = "linker")] -#[command(version = "1.0")] -#[command( - about = "Generate plantuml_links.json from FlatBuffers diagram outputs", - long_about = "Reads .fbs.bin files from the PlantUML parser and produces a \ - plantuml_links.json file mapping component aliases to their \ - detailed diagrams for the clickable_plantuml Sphinx extension." -)] -struct Args { - /// FlatBuffers binary files to process (.fbs.bin) - #[arg(long, num_args = 1..)] - fbs_files: Vec, - - /// Output JSON file path - #[arg(long, default_value = "plantuml_links.json")] - output: String, - - /// Log level: error, warn, info, debug, trace - #[arg(long, value_enum, default_value = "warn")] - log_level: CliLogLevel, -} - -// --------------------------------------------------------------------------- -// Data model -// --------------------------------------------------------------------------- - -/// A component extracted from a FlatBuffers diagram. -#[derive(Debug)] -struct DiagramComponent { - alias: String, - parent_id: Option, -} - -/// All components from a single diagram file. -#[derive(Debug)] -struct DiagramInfo { - source_file: String, - components: Vec, -} - -/// One entry in the output JSON `links` array. -#[derive(Debug, serde::Serialize)] -struct LinkEntry { - source_file: String, - source_id: String, - target_file: String, -} - -/// Root structure of the output JSON. -#[derive(Debug, serde::Serialize)] -struct LinksJson { - links: Vec, -} - -// --------------------------------------------------------------------------- -// FlatBuffers reading -// --------------------------------------------------------------------------- - -fn read_diagram(path: &str) -> Result { - let data = fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?; - - if data.is_empty() { - return Err(format!("Empty file (placeholder): {path}")); - } - - let graph = flatbuffers::root::(&data) - .map_err(|e| format!("Failed to parse FlatBuffer {path}: {e}"))?; - - let source_file = graph - .components() - .and_then(|entries| { - entries - .iter() - .next() - .and_then(|entry| entry.value()) - .map(|comp| comp.source_location()) - .and_then(|location| location.file()) - .filter(|file| !file.is_empty()) - .map(ToOwned::to_owned) - }) - .unwrap_or_else(|| path.to_string()); - - let mut components = Vec::new(); - if let Some(entries) = graph.components() { - for entry in entries.iter() { - let Some(comp) = entry.value() else { - continue; - }; - let alias = comp.alias().or(comp.name()).unwrap_or_default().to_string(); - if alias.is_empty() { - continue; - } - components.push(DiagramComponent { - alias, - parent_id: comp.parent_id().map(|s| s.to_string()), - }); - } - } - - Ok(DiagramInfo { - source_file, - components, - }) -} - -// --------------------------------------------------------------------------- -// Link generation -// --------------------------------------------------------------------------- - -/// Build links by matching component aliases across diagrams. -/// -/// For each component alias in diagram A, if a top-level component (no parent) -/// with the same alias exists in diagram B, we create a link: -/// source_file = A, source_id = alias, target_file = B -/// -/// A component is considered "top-level" if its `parent_id` is `None`. -fn generate_links(diagrams: &[DiagramInfo]) -> Vec { - // Index: alias → list of diagrams where that alias is a top-level component - let mut top_level_index: HashMap> = HashMap::new(); - for diagram in diagrams { - for comp in &diagram.components { - if comp.parent_id.is_none() { - top_level_index - .entry(comp.alias.clone()) - .or_default() - .push(&diagram.source_file); - } - } - } - - let mut links = Vec::new(); - - for diagram in diagrams { - for comp in &diagram.components { - if let Some(target_diagrams) = top_level_index.get(&comp.alias) { - for &target_file in target_diagrams { - // Don't link a component to its own diagram. - if target_file == diagram.source_file { - continue; - } - links.push(LinkEntry { - source_file: diagram.source_file.clone(), - source_id: comp.alias.clone(), - target_file: target_file.to_string(), - }); - } - } - } - } - - // Deduplicate: same (source_file, source_id, target_file) may appear - // when a component is nested inside multiple parent scopes. - links.sort_by(|a, b| { - (&a.source_file, &a.source_id, &a.target_file).cmp(&( - &b.source_file, - &b.source_id, - &b.target_file, - )) - }); - links.dedup_by(|a, b| { - a.source_file == b.source_file - && a.source_id == b.source_id - && a.target_file == b.target_file - }); - - // PlantUML supports only one URL per alias — keep the first target - // (alphabetically) for each (source_file, source_id) pair. - links.dedup_by(|a, b| a.source_file == b.source_file && a.source_id == b.source_id); - - links -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -fn main() -> Result<(), Box> { - let args = Args::parse(); - Builder::new() - .filter_level(args.log_level.to_level_filter()) - .init(); - - if args.fbs_files.is_empty() { - return Err("No .fbs.bin files provided. Use --fbs-files ...".into()); - } - - let mut diagrams = Vec::new(); - for fbs_path in &args.fbs_files { - match read_diagram(fbs_path) { - Ok(diagram) => { - log::info!( - "Read {} components from {}", - diagram.components.len(), - diagram.source_file - ); - diagrams.push(diagram); - } - Err(e) => { - log::warn!("Skipping {}: {}", fbs_path, e); - } - } - } - - let links = generate_links(&diagrams); - log::info!("Generated {} link(s)", links.len()); - - let output = LinksJson { links }; - let json = serde_json::to_string_pretty(&output)?; - fs::write(&args.output, &json)?; - log::debug!("Written to {}", args.output); - - Ok(()) -} diff --git a/plantuml/parser/BUILD b/plantuml/parser/BUILD index 197ce9f6..afc02c25 100644 --- a/plantuml/parser/BUILD +++ b/plantuml/parser/BUILD @@ -21,9 +21,3 @@ alias( actual = "//plantuml/parser/puml_cli:puml_cli", visibility = ["//visibility:public"], ) - -alias( - name = "linker", - actual = "//plantuml/linker:linker", - visibility = ["//visibility:public"], -) diff --git a/plantuml/parser/puml_cli/BUILD b/plantuml/parser/puml_cli/BUILD index ea703163..f8cef2da 100644 --- a/plantuml/parser/puml_cli/BUILD +++ b/plantuml/parser/puml_cli/BUILD @@ -19,6 +19,7 @@ rust_binary( visibility = ["//visibility:public"], deps = [ "//plantuml/parser/puml_fta", + "//plantuml/parser/puml_idmap", "//plantuml/parser/puml_lobster", "//plantuml/parser/puml_parser", "//plantuml/parser/puml_resolver", @@ -45,6 +46,7 @@ rust_test( crate_root = "src/main.rs", deps = [ "//plantuml/parser/puml_fta", + "//plantuml/parser/puml_idmap", "//plantuml/parser/puml_lobster", "//plantuml/parser/puml_parser", "//plantuml/parser/puml_resolver", diff --git a/plantuml/parser/puml_cli/src/main.rs b/plantuml/parser/puml_cli/src/main.rs index 13283150..36b18102 100644 --- a/plantuml/parser/puml_cli/src/main.rs +++ b/plantuml/parser/puml_cli/src/main.rs @@ -27,6 +27,7 @@ use component_serializer::ComponentSerializer; use sequence_serializer::SequenceSerializer; use puml_fta::{lobster_document, FtaChain, FtaModel}; +use puml_idmap::{write_empty_idmap_to_file, write_idmap_to_file, IdMapModel}; use puml_lobster::{write_lobster_to_file, LobsterModel}; use puml_parser::{ DiagramParser, ErrorLocation, Preprocessor, ProcedureParserService, PumlActivityParser, @@ -109,6 +110,20 @@ struct Args { /// processing is performed in this mode. #[arg(long)] fta_output_dir: Option, + + /// Output directory for generated idmap sidecar files (optional). + /// When set, a .idmap.json is written for each resolved diagram, + /// recording the defines/references used by the clickable_plantuml + /// Sphinx extension to resolve cross-diagram links. + #[arg(long)] + idmap_output_dir: Option, + + /// 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). + /// Must be a relative path (never a machine-specific absolute path). + #[arg(long)] + source_name: Option, } #[derive(Copy, Clone, ValueEnum, Debug)] @@ -121,7 +136,6 @@ enum DiagramType { Sequence, } -#[allow(dead_code)] // Class and Sequence variants are WIP #[derive(Debug, Serialize)] enum ParsedDiagram { Activity(puml_parser::RawActivityDiagram), @@ -154,9 +168,16 @@ fn run() -> Result<(), Box> { .into(), ); } + if args.source_name.is_some() { + return Err("--source-name cannot be combined with --fta-output-dir".into()); + } return run_fta(&args, dir, log_level); } + if let Some(name) = &args.source_name { + validate_source_name(name)?; + } + let emit_debug_json = log_level.to_level_filter() >= log::LevelFilter::Debug; let fbs_output_dir: Option = if let Some(dir) = &args.fbs_output_dir { @@ -167,20 +188,39 @@ fn run() -> Result<(), Box> { None }; - let lobster_output_dir: Option = match &args.lobster_output_dir { - Some(dir) => { - let p = PathBuf::from(dir); - fs::create_dir_all(&p)?; - Some(p) - } - None => None, + let lobster_output_dir: Option = if let Some(dir) = &args.lobster_output_dir { + let p = PathBuf::from(dir); + fs::create_dir_all(&p)?; + Some(p) + } else { + None + }; + + let idmap_output_dir: Option = if let Some(dir) = &args.idmap_output_dir { + let p = PathBuf::from(dir); + fs::create_dir_all(&p)?; + Some(p) + } else { + None }; + // Two input files with the same file stem (in different directories) would + // otherwise silently overwrite each other's `.idmap.json`; track the + // output paths already claimed this run and fail fast on a collision. + let mut seen_idmap_outputs: HashSet = HashSet::new(); let file_list = collect_files_from_args(&args)?; if file_list.is_empty() { return Err("No valid PUML files found.".into()); } + if args.source_name.is_some() && file_list.len() != 1 { + return Err(format!( + "--source-name requires exactly one input file (a single --file, no \ + --folders); got {} files", + file_list.len(), + ) + .into()); + } debug!("Collected {} puml files.", file_list.len()); debug!("Preprocessing: include expansion"); @@ -197,6 +237,13 @@ fn run() -> Result<(), Box> { } } + // Capture the @startuml before resolve_parsed_diagram + // consumes parsed_content — the resolver discards it from the map. + let diagram_name: Option = match &parsed_content { + ParsedDiagram::Component(doc) => doc.name.clone(), + _ => None, + }; + match resolve_parsed_diagram(parsed_content) { Ok(logic_result) => { debug!( @@ -209,6 +256,10 @@ fn run() -> Result<(), Box> { } } + let source_file = args + .source_name + .clone() + .unwrap_or_else(|| source_path_for_output(path)); let fbs_buffer = serialize_resolved_diagram(&logic_result); if let Some(ref dir) = fbs_output_dir { write_fbs_to_file(&fbs_buffer, path, dir)?; @@ -221,7 +272,36 @@ fn run() -> Result<(), Box> { ResolvedDiagram::Activity(_) => LobsterModel::Empty, ResolvedDiagram::Sequence(_) => LobsterModel::Empty, }; - write_lobster_to_file(lobster_model, path, ldir)?; + write_lobster_to_file(lobster_model, path, &source_file, ldir)?; + } + + if let Some(idir) = &idmap_output_dir { + let output_path = puml_idmap::idmap_output_path(path, idir); + if !seen_idmap_outputs.insert(output_path.clone()) { + return Err(format!( + "duplicate idmap output {}: multiple input files map to the \ + same .idmap.json in {}; rename one of the inputs so \ + their file stems differ", + output_path.display(), + idir.display(), + ) + .into()); + } + + match idmap_model_for(&logic_result) { + Some(idmap_model) => { + write_idmap_to_file( + idmap_model, + path, + Some(&source_file), + diagram_name.as_deref(), + idir, + )?; + } + None => { + write_empty_idmap_to_file(path, Some(&source_file), idir)?; + } + } } } Err(e) => { @@ -259,6 +339,16 @@ fn run_fta( // Chains/lobster items reference each diagram by basename; two inputs sharing // a basename (in different directories) would be indistinguishable downstream. let mut seen_basenames: HashSet = HashSet::new(); + // Mirrors the collision guard in the main resolve path: two input files + // whose file *stem* collides (independent of the basename check above, + // which only fires on an exact basename match) would otherwise silently + // overwrite each other's `.idmap.json`. + let mut seen_idmap_outputs: HashSet = HashSet::new(); + // Created once up front (rather than per-file inside the loop below) since + // the directory is the same for every file this run. + if let Some(ref idir_str) = args.idmap_output_dir { + fs::create_dir_all(idir_str)?; + } for file in &sorted { let basename = file.file_name().and_then(|n| n.to_str()).ok_or_else(|| { @@ -291,6 +381,30 @@ fn run_fta( let model = FtaModel::from_procedure_file(&parsed)?; all_items.extend(model.lobster_items(basename)); all_chains.extend(model.chains(basename)); + + if let Some(ref idir_str) = args.idmap_output_dir { + let idir = PathBuf::from(idir_str); + let output_path = puml_idmap::idmap_output_path(file, &idir); + if !seen_idmap_outputs.insert(output_path.clone()) { + return Err(format!( + "duplicate idmap output {}: multiple FTA input files map to the \ + same .idmap.json in {}; rename one of the inputs so \ + their file stems differ", + output_path.display(), + idir.display(), + ) + .into()); + } + let source_file = source_path_for_output(file); + write_idmap_to_file( + IdMapModel::Fta(&model), + file, + Some(&source_file), + None, + &idir, + )?; + } + debug!("Processed FTA diagram: {}", file.display()); } @@ -329,6 +443,19 @@ pub enum ResolvedDiagram { Sequence(sequence_logic::SequenceTree), } +/// Map a resolved diagram to the `IdMapModel` variant used to build its +/// `.idmap.json`, or `None` for diagram kinds with no linkable elements +/// (currently only `Activity`), which the caller must route to +/// [`write_empty_idmap_to_file`] instead. +fn idmap_model_for(resolved: &ResolvedDiagram) -> Option> { + match resolved { + ResolvedDiagram::Component(model) => Some(IdMapModel::Component(model)), + ResolvedDiagram::Class(model) => Some(IdMapModel::Class(model)), + ResolvedDiagram::Sequence(model) => Some(IdMapModel::Sequence(model)), + ResolvedDiagram::Activity(_) => None, + } +} + fn resolve_parsed_diagram( parsed_content: ParsedDiagram, ) -> Result> { @@ -532,25 +659,71 @@ fn collect_files_from_args( Ok(file_list) } -fn resolve_path(path: &Path) -> PathBuf { +fn resolve_path(path: &Path) -> Result> { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + // When running with 'bazel run', use BUILD_WORKSPACE_DIRECTORY - let base_dir = std::env::var("BUILD_WORKSPACE_DIRECTORY") + let base_dir = match std::env::var("BUILD_WORKSPACE_DIRECTORY") { + Ok(dir) => PathBuf::from(dir), + Err(_) => std::env::current_dir()?, + }; + + Ok(base_dir.join(path)) +} + +/// Validate a user-supplied `--source-name` value. +/// +/// Rejects empty strings and absolute paths: an absolute path would leak the +/// local machine's directory layout into build outputs (breaking build +/// reproducibility) and would defeat the whole purpose of the flag, which is +/// to supply a *stable, workspace-relative* key for cross-diagram matching. +fn validate_source_name(name: &str) -> Result<(), Box> { + if name.is_empty() { + return Err("--source-name must not be empty".into()); + } + if Path::new(name).is_absolute() { + return Err(format!( + "--source-name must be a workspace-relative path, got an absolute path: {name}" + ) + .into()); + } + Ok(()) +} + +fn source_path_for_output(path: &Path) -> String { + let workspace_root = std::env::var("BUILD_WORKSPACE_DIRECTORY") .ok() .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap()); + .or_else(|| std::env::current_dir().ok()); - if path.is_absolute() { - path.to_path_buf() - } else { - base_dir.join(path) + // Prefer a stable, workspace-relative path so the embedded `source` is + // reproducible across machines and portable outside Bazel. + if let Some(root) = workspace_root { + if let Ok(rel) = path.strip_prefix(&root) { + return rel.to_string_lossy().into_owned(); + } } + + // Already relative: keep it verbatim (still portable). + if path.is_relative() { + return path.to_string_lossy().into_owned(); + } + + // Absolute path outside the workspace: never embed the machine-specific + // absolute path — it leaks local directory layout and breaks build + // reproducibility. Fall back to the file name only. + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) } fn add_single_file( path: &Path, file_list: &mut HashSet>, ) -> Result<(), Box> { - let abs_path = resolve_path(path); + let abs_path = resolve_path(path)?; if !abs_path.is_file() { return Err(format!("Path is not a file: {}", path.display()).into()); @@ -566,7 +739,7 @@ fn collect_puml_files_from_folder( dir: &Path, file_list: &mut HashSet>, ) -> Result<(), Box> { - let abs_dir = resolve_path(dir); + let abs_dir = resolve_path(dir)?; if !abs_dir.is_dir() { return Err(format!("Path is not a directory: {}", dir.display()).into()); @@ -764,3 +937,297 @@ mod fta_pipeline_tests { fs::remove_dir_all(&dir).ok(); } } + +#[cfg(test)] +mod idmap_wiring_tests { + use super::*; + + fn unique_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "puml_idmap_wiring_{}_{}_{}", + tag, + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Remove a test's temp dir, surfacing (rather than silently swallowing) + /// any cleanup failure so a locked/undeletable dir doesn't go unnoticed. + fn cleanup_dir(dir: &Path) { + if let Err(e) = fs::remove_dir_all(dir) { + eprintln!( + "warning: failed to remove temp dir {}: {}", + dir.display(), + e + ); + } + } + + /// Activity diagrams carry no cross-linkable elements, so the CLI routes + /// them to the explicit empty-writer API rather than a model converter. + /// This verifies both the routing predicate (the diagram resolves to + /// `ResolvedDiagram::Activity`) and the resulting on-disk empty idmap. + #[test] + fn activity_diagram_routes_to_empty_idmap_writer() { + let content = "@startuml\nstart\n:Do work;\nstop\n@enduml"; + let path = Rc::new(PathBuf::from("flow/activity.puml")); + + let parsed = parse_puml_file(&path, content, LogLevel::Info, DiagramType::Activity) + .expect("activity parse must succeed"); + let resolved = resolve_parsed_diagram(parsed).expect("activity must resolve"); + assert!( + matches!(resolved, ResolvedDiagram::Activity(_)), + "activity diagram must resolve to the Activity variant that triggers the empty writer" + ); + + // 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) + .expect("empty idmap must be written"); + + assert_eq!( + output.file_name().and_then(|n| n.to_str()), + Some("activity.idmap.json") + ); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(json["source"], "flow/activity.puml"); + assert!(json["defines"].as_array().unwrap().is_empty()); + assert!(json["references"].as_array().unwrap().is_empty()); + + cleanup_dir(&dir); + } + + /// An absolute path outside the workspace must never leak into the emitted + /// `source` field; the fallback collapses to the file name only. + #[test] + fn source_path_for_output_does_not_leak_absolute_paths() { + let outside = Path::new("/nonexistent/abs/dir/diagram.puml"); + let source = source_path_for_output(outside); + assert_eq!(source, "diagram.puml"); + assert!( + !source.starts_with('/'), + "absolute path must not leak into source, got: {source}" + ); + } + + /// Component diagrams route through `idmap_model_for` to the + /// `IdMapModel::Component` dispatch arm and `write_idmap_to_file`, unlike + /// the Activity arm covered above. + #[test] + fn component_diagram_routes_to_idmap_model_dispatch() { + let content = "@startuml\n\ + component \"Client\" as Client <> {\n\ + portout out1\n\ + }\n\ + component \"Server\" as Server <> {\n\ + portin in1\n\ + }\n\ + out1 --> in1 : calls\n\ + @enduml"; + let path = Rc::new(PathBuf::from("comp/client_server.puml")); + + let parsed = parse_puml_file(&path, content, LogLevel::Info, DiagramType::Component) + .expect("component parse must succeed"); + let resolved = resolve_parsed_diagram(parsed).expect("component must resolve"); + assert!(matches!(resolved, ResolvedDiagram::Component(_))); + + let idmap_model = + idmap_model_for(&resolved).expect("component diagrams must dispatch to an IdMapModel"); + + 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) + .expect("component idmap must be written"); + + assert_eq!( + output.file_name().and_then(|n| n.to_str()), + Some("client_server.idmap.json") + ); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(json["source"], "comp/client_server.puml"); + + cleanup_dir(&dir); + } + + /// Class diagrams route through `idmap_model_for` to the + /// `IdMapModel::Class` dispatch arm and `write_idmap_to_file`. + #[test] + fn class_diagram_routes_to_idmap_model_dispatch() { + let content = "@startuml\nclass A {\n +a\n}\n@enduml"; + let path = Rc::new(PathBuf::from("cls/a.puml")); + + let parsed = parse_puml_file(&path, content, LogLevel::Info, DiagramType::Class) + .expect("class parse must succeed"); + let resolved = resolve_parsed_diagram(parsed).expect("class must resolve"); + assert!(matches!(resolved, ResolvedDiagram::Class(_))); + + let idmap_model = + idmap_model_for(&resolved).expect("class diagrams must dispatch to an IdMapModel"); + + 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) + .expect("class idmap must be written"); + + assert_eq!( + output.file_name().and_then(|n| n.to_str()), + Some("a.idmap.json") + ); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(json["source"], "cls/a.puml"); + + cleanup_dir(&dir); + } + + /// Sequence diagrams route through `idmap_model_for` to the + /// `IdMapModel::Sequence` dispatch arm and `write_idmap_to_file`. + #[test] + fn sequence_diagram_routes_to_idmap_model_dispatch() { + let content = + "@startuml\nparticipant Alice\nparticipant Bob\nAlice -> Bob : hello\n@enduml"; + let path = Rc::new(PathBuf::from("seq/hello.puml")); + + let parsed = parse_puml_file(&path, content, LogLevel::Info, DiagramType::Sequence) + .expect("sequence parse must succeed"); + let resolved = resolve_parsed_diagram(parsed).expect("sequence must resolve"); + assert!(matches!(resolved, ResolvedDiagram::Sequence(_))); + + let idmap_model = + idmap_model_for(&resolved).expect("sequence diagrams must dispatch to an IdMapModel"); + + 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) + .expect("sequence idmap must be written"); + + assert_eq!( + output.file_name().and_then(|n| n.to_str()), + Some("hello.idmap.json") + ); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(json["source"], "seq/hello.puml"); + assert!(json["defines"].as_array().unwrap().is_empty()); + assert_eq!(json["references"].as_array().unwrap().len(), 2); + + cleanup_dir(&dir); + } + + /// Regression test for cross-diagram-type FQN linking: a component + /// diagram's `interface` reference and a class/internal-API diagram's + /// `interface` define must resolve to the *identical* FQN `id` when both + /// declare the same enclosing `package`. + /// + /// Both `ComponentResolver` and `ClassResolver` build ids as dot-joined + /// FQNs rooted at the enclosing `package`/namespace name (not at the + /// `@startuml `), so as long as two diagrams share the same + /// package name for a jointly-referenced element, `puml_idmap`'s + /// FQN-first matching in `clickable_plantuml` links them correctly + /// without falling back to (collision-prone) alias-only matching. This + /// mirrors the real `component_internal_api/positive_interface_match` + /// integration test fixture. + #[test] + fn component_reference_and_class_define_share_fqn_for_same_package_interface() { + let component_content = "@startuml\n\ + package \"Package A\" as package_a {\n\ + component \"Component A\" as component_a <> {\n\ + component \"Unit 1\" as unit_1 <>\n\ + }\n\ + interface \"InternalInterface\" as InternalInterface\n\ + unit_1 -( InternalInterface\n\ + }\n\ + @enduml"; + let component_path = Rc::new(PathBuf::from("comp/component_diagram.puml")); + let component_parsed = parse_puml_file( + &component_path, + component_content, + LogLevel::Info, + DiagramType::Component, + ) + .expect("component parse must succeed"); + let component_resolved = + resolve_parsed_diagram(component_parsed).expect("component must resolve"); + let component_idmap_model = idmap_model_for(&component_resolved) + .expect("component diagrams must dispatch to an IdMapModel"); + + let class_content = "@startuml\n\ + package package_a {\n\ + interface \"InternalInterface\" as InternalInterface {\n\ + +GetData()\n\ + }\n\ + }\n\ + @enduml"; + let class_path = Rc::new(PathBuf::from("cls/internal_api_diagram.puml")); + let class_parsed = parse_puml_file( + &class_path, + class_content, + LogLevel::Info, + DiagramType::Class, + ) + .expect("class parse must succeed"); + let class_resolved = resolve_parsed_diagram(class_parsed).expect("class must resolve"); + let class_idmap_model = idmap_model_for(&class_resolved) + .expect("class diagrams must dispatch to an IdMapModel"); + + let dir = unique_dir("cross_type_fqn"); + let component_output = write_idmap_to_file( + component_idmap_model, + &component_path, + Some(&source_path_for_output(&component_path)), + None, + &dir, + ) + .expect("component idmap must be written"); + let class_output = write_idmap_to_file( + class_idmap_model, + &class_path, + Some(&source_path_for_output(&class_path)), + None, + &dir, + ) + .expect("class idmap must be written"); + + let component_json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&component_output).unwrap()).unwrap(); + let class_json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&class_output).unwrap()).unwrap(); + + let component_ref_id = component_json["references"] + .as_array() + .unwrap() + .iter() + .find(|e| e["alias"] == "InternalInterface") + .expect("component diagram must reference InternalInterface")["id"] + .as_str() + .unwrap() + .to_string(); + let class_define_id = class_json["defines"] + .as_array() + .unwrap() + .iter() + .find(|e| e["alias"] == "InternalInterface") + .expect("class diagram must define InternalInterface")["id"] + .as_str() + .unwrap() + .to_string(); + + assert_eq!( + component_ref_id, class_define_id, + "component reference FQN and class define FQN must match exactly \ + for clickable_plantuml's FQN-first resolution to link them" + ); + assert_eq!(component_ref_id, "package_a.InternalInterface"); + + cleanup_dir(&dir); + } +} diff --git a/plantuml/parser/puml_fta/src/lib.rs b/plantuml/parser/puml_fta/src/lib.rs index bcca3516..b4a3ef5e 100644 --- a/plantuml/parser/puml_fta/src/lib.rs +++ b/plantuml/parser/puml_fta/src/lib.rs @@ -50,10 +50,26 @@ pub enum NodeKind { IntermediateEvent, /// `$BasicEvent` — a leaf root cause / control measure. BasicEvent, - /// `$AndGate`, `$OrGate`, `$TransferInGate` — a logic gate. + /// `$AndGate`, `$OrGate`, `$TransferInGate` — a logic gate. Use + /// [`FtaNode::gate_kind`] to distinguish which one. Gate, } +/// Which specific gate macro produced a [`NodeKind::Gate`] node. +/// +/// `NodeKind::Gate` alone does not distinguish an internal `$AndGate`/`$OrGate` +/// from a `$TransferInGate` (which links to another diagram's top event). +/// Consumers that need that distinction (e.g. `puml_idmap`) must match on this +/// field rather than guessing from the node's `alias` shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum GateKind { + And, + Or, + /// Transfers into another diagram's top event; `alias` is that top + /// event's TRLC fully-qualified name. + TransferIn, +} + /// One node of a fault tree. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FtaNode { @@ -66,6 +82,9 @@ pub struct FtaNode { /// Alias of the parent node this node connects upward to. `None` for the /// top event (the root). pub connection: Option, + /// `Some` only when `kind == NodeKind::Gate`; identifies which gate macro + /// produced this node. `None` for all other kinds. + pub gate_kind: Option, /// 1-based line of the macro call in its source diagram. /// `None` when the line is unavailable (e.g. synthesised nodes in tests). pub line: Option, @@ -145,6 +164,7 @@ impl FtaModel { name: Some(string_arg(call, 0)?), alias: string_arg(call, 1)?, connection: None, + gate_kind: None, line, }, INTERMEDIATE_EVENT => FtaNode { @@ -152,6 +172,7 @@ impl FtaModel { name: Some(string_arg(call, 0)?), alias: string_arg(call, 1)?, connection: Some(string_arg(call, 2)?), + gate_kind: None, line, }, BASIC_EVENT => FtaNode { @@ -159,15 +180,25 @@ impl FtaModel { name: Some(string_arg(call, 0)?), alias: string_arg(call, 1)?, connection: Some(string_arg(call, 2)?), + gate_kind: None, line, }, - AND_GATE | OR_GATE | TRANSFER_IN_GATE => FtaNode { - kind: NodeKind::Gate, - name: None, - alias: string_arg(call, 0)?, - connection: Some(string_arg(call, 1)?), - line, - }, + AND_GATE | OR_GATE | TRANSFER_IN_GATE => { + let gate_kind = match call.name.as_str() { + AND_GATE => GateKind::And, + OR_GATE => GateKind::Or, + TRANSFER_IN_GATE => GateKind::TransferIn, + _ => unreachable!("matched only by the outer arm pattern"), + }; + FtaNode { + kind: NodeKind::Gate, + name: None, + alias: string_arg(call, 0)?, + connection: Some(string_arg(call, 1)?), + gate_kind: Some(gate_kind), + line, + } + } // Unknown / cosmetic macros are not part of the topology. _ => continue, }; diff --git a/plantuml/parser/puml_idmap/BUILD b/plantuml/parser/puml_idmap/BUILD new file mode 100644 index 00000000..6e84ec0d --- /dev/null +++ b/plantuml/parser/puml_idmap/BUILD @@ -0,0 +1,39 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "puml_idmap", + srcs = ["src/lib.rs"], + visibility = ["//plantuml/parser:__subpackages__"], + deps = [ + "//plantuml/parser/puml_fta", + "//tools/metamodel/class:class_diagram", + "//tools/metamodel/component:component_diagram", + "//tools/metamodel/sequence:sequence_diagram", + "@crates//:log", + "@crates//:serde", + "@crates//:serde_json", + ], +) + +rust_test( + name = "puml_idmap_test", + crate = ":puml_idmap", + deps = [ + "//tools/metamodel/class:class_diagram", + "//tools/metamodel/component:component_diagram", + "//tools/metamodel/sequence:sequence_diagram", + ], +) diff --git a/plantuml/parser/puml_idmap/src/lib.rs b/plantuml/parser/puml_idmap/src/lib.rs new file mode 100644 index 00000000..6b5bca94 --- /dev/null +++ b/plantuml/parser/puml_idmap/src/lib.rs @@ -0,0 +1,1355 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Converts the resolved PlantUML logical model into an `.idmap.json` file +//! consumed by the `clickable_plantuml` Sphinx extension. +//! +//! The idmap separates each diagram's elements into two roles: +//! +//! * **defines** – elements that are *elaborated* in this diagram (they have +//! child elements, class members, or this diagram is the detail view). +//! * **references** – leaf mentions and relation endpoints (elements that +//! should link *away* to wherever they are elaborated). +//! +//! This mirrors the structure of `puml_lobster` but produces idmap JSON +//! rather than LOBSTER trace JSON. + +use class_diagram::ClassDiagram; +use component_diagram::LogicComponent; +use puml_fta::{FtaModel, GateKind, NodeKind}; +use sequence_logic::SequenceTree; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// A single element entry in the idmap. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +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, +} + +/// Root structure of an `.idmap.json` file. +#[derive(Debug, Serialize, Deserialize)] +pub struct IdMapFile { + /// Workspace-relative source path, e.g. `score/mw/com/proxy_detail.puml`. + pub source: String, + /// Elements elaborated (defined) in this diagram. + pub defines: Vec, + /// Elements referenced (leaf/relation endpoint) in this diagram. + pub references: Vec, +} + +// --------------------------------------------------------------------------- +// Model wrapper +// --------------------------------------------------------------------------- + +/// Union of the resolved diagram models accepted by the idmap writer. +pub enum IdMapModel<'a> { + Component(&'a HashMap), + Class(&'a ClassDiagram), + Sequence(&'a SequenceTree), + Fta(&'a FtaModel), +} + +// --------------------------------------------------------------------------- +// Model converters +// --------------------------------------------------------------------------- + +/// Produce an [`IdMapFile`] from a resolved component diagram. +/// +/// An element is a **define** when at least one other element lists it as its +/// `parent_id` (i.e. it has children and is therefore elaborated here). +/// All remaining elements are **references** (top-level leaves that mention +/// something that may be detailed in another diagram). `package` elements +/// are not special-cased: an empty package (no children) is a reference like +/// any other leaf, while a package with children is a define, exactly like a +/// `component` with nested elements. +fn comp_model_to_idmap( + model: &HashMap, + source: &str, + diagram_name: Option<&str>, +) -> IdMapFile { + // Collect the set of IDs that are listed as parent by at least one child. + let has_children: HashSet<&str> = model + .values() + .filter_map(|c| c.parent_id.as_deref()) + .collect(); + + let mut defines = Vec::new(); + let mut references = Vec::new(); + + for comp in model.values() { + let alias = comp + .alias + .as_deref() + .or(comp.name.as_deref()) + .unwrap_or(&comp.id) + .to_string(); + // An element is a define when it has children OR when the diagram's + // @startuml matches its alias/name (the diagram elaborates it). + let matches_diagram_name = diagram_name + .map(|dn| comp.alias.as_deref() == Some(dn) || comp.name.as_deref() == Some(dn)) + .unwrap_or(false); + let is_define = has_children.contains(comp.id.as_str()) || matches_diagram_name; + let entry = IdMapEntry { + alias, + id: comp.id.clone(), + }; + if is_define { + defines.push(entry); + } else { + references.push(entry); + } + } + + // NOTE: `LogicComponent.relations` endpoints are intentionally *not* scanned + // here (unlike class-diagram relationships). Every component relation + // endpoint is itself a declared component in this same `model` map, so it is + // already emitted above as either a define or a reference. Scanning + // relations would only re-derive those identical ids and produce duplicates. + // This is a structural invariant of `ComponentResolver`'s output, not a + // convention shared with `class_model_to_idmap` below — the class-diagram + // resolver *can* leave relationship endpoints that reference no known + // entity (see the dangling-endpoint handling and warning there), which is + // why that converter, unlike this one, must scan `relationships` at all. + defines.sort_by(|a, b| a.id.cmp(&b.id)); + references.sort_by(|a, b| a.id.cmp(&b.id)); + + IdMapFile { + source: source.to_string(), + defines, + references, + } +} + +/// Produce an [`IdMapFile`] from a resolved class diagram. +/// +/// A class entity is a **define** when it has any members (methods or +/// variables), making this diagram the elaboration site. Entities without +/// members are **references**. Relation endpoints are also emitted as +/// references so links can resolve from relationship mentions. Namespaces are +/// also synthesized as defines (see below) so a unit/component elaborated +/// only via `namespace { class RealClass {...} }` remains linkable. +fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile { + // The @startuml value is preserved in ClassDiagram::name by the resolver. + let diagram_name = if model.name.is_empty() { + None + } else { + Some(model.name.as_str()) + }; + + let mut defines = Vec::new(); + let mut references = Vec::new(); + let mut define_ids: HashSet = HashSet::new(); + let mut reference_ids: HashSet = HashSet::new(); + let entity_name_by_id: HashMap<&str, &str> = model + .entities + .iter() + .map(|e| (e.id.as_str(), e.name.as_str())) + .collect(); + + // The resolver never materialises a namespace/package as an entity of its + // own (only its FQN survives on each child's `enclosing_namespace_id`), so + // no namespace can reach the entity loop below by itself. Collected here + // so relationship endpoints that only name a container can be recognised + // below. + let namespace_ids: HashSet<&str> = model + .entities + .iter() + .filter_map(|e| e.enclosing_namespace_id.as_deref()) + .collect(); + // Namespaces that own at least one child this diagram elaborates as a + // define; populated by the entity loop below and consumed afterwards to + // synthesize namespace-level defines (see there for the rationale). + let mut namespaces_with_defined_child: HashSet<&str> = HashSet::new(); + + for entity in &model.entities { + let has_members = !entity.methods.is_empty() || !entity.variables.is_empty(); + let matches_diagram_name = diagram_name == Some(entity.name.as_str()); + let is_define = has_members || matches_diagram_name; + if is_define { + define_ids.insert(entity.id.clone()); + defines.push(IdMapEntry { + alias: entity.name.clone(), + id: entity.id.clone(), + }); + if let Some(ns) = entity.enclosing_namespace_id.as_deref() { + namespaces_with_defined_child.insert(ns); + } + continue; + } + + if reference_ids.insert(entity.id.clone()) { + references.push(IdMapEntry { + alias: entity.name.clone(), + id: entity.id.clone(), + }); + } + } + + // Synthesize a define for each namespace/package FQN that has at least one + // directly-nested child this diagram itself elaborates (i.e. a define, not + // a memberless stub shown only for collaborator context). A namespace is a + // valid elaboration site even though the resolver never gives it its own + // entity: e.g. a unit elaborated only via `namespace unit_1 { class Foo { + // ... } }` (a real implementation class, not a same-named placeholder) + // must still be linkable from a component/sequence diagram's bare + // `unit_1` reference. Without this, such units could only be made linkable + // by inventing a fake same-named class, which would fail the + // class_design_implementation design-vs-implementation consistency check. + // Requiring a defined child (rather than any child at all) matters when + // the same namespace is *also* referenced as a memberless/collaborator + // stub from an unrelated diagram elsewhere — that diagram must not become + // 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. + 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 + // synthetic namespace define. + continue; + } + let alias = ns.rsplit('.').next().unwrap_or(ns).to_string(); + define_ids.insert(ns.to_string()); + defines.push(IdMapEntry { + alias, + id: ns.to_string(), + }); + } + + // Relationships are now stored per-entity (`SimpleEntity::relationships`) + // rather than on the diagram as a whole; flatten across all entities. + for relationship in model.entities.iter().flat_map(|e| &e.relationships) { + for endpoint in [&relationship.source, &relationship.target] { + // A relation endpoint that is only a namespace/package container is + // not a linkable element; never emit it as a reference. + if namespace_ids.contains(endpoint.as_str()) { + continue; + } + if define_ids.contains(endpoint) || !reference_ids.insert(endpoint.clone()) { + continue; + } + + // A relationship endpoint that matches neither a known entity nor a + // known namespace is a dangling/orphan reference (e.g. a typo'd id, + // or an id from a diagram the resolver didn't see). Emit it by its + // raw id so the link still round-trips, but warn so a malformed + // diagram is visible in the build log rather than silently guessed + // at — mirroring how `puml_fta` warns on malformed aliases instead + // of failing the build. + let alias = match entity_name_by_id.get(endpoint.as_str()) { + Some(&name) => name.to_string(), + None => { + log::warn!( + "class diagram relationship endpoint {:?} does not match any \ + known entity or namespace in {source}; emitting it as a \ + reference by its raw id", + endpoint, + ); + endpoint.clone() + } + }; + + references.push(IdMapEntry { + alias, + id: endpoint.clone(), + }); + } + } + + defines.sort_by(|a, b| a.id.cmp(&b.id)); + references.sort_by(|a, b| a.id.cmp(&b.id)); + + IdMapFile { + source: source.to_string(), + defines, + references, + } +} + +/// Collect the unique participant names from a sequence tree. +fn collect_participants(tree: &SequenceTree) -> HashSet { + use sequence_logic::{Event, SequenceNode}; + + fn walk_nodes(nodes: &[SequenceNode], out: &mut HashSet) { + for node in nodes { + match &node.event { + Event::Interaction(i) => { + out.insert(i.caller.clone()); + out.insert(i.callee.clone()); + } + Event::Return(r) => { + out.insert(r.caller.clone()); + out.insert(r.callee.clone()); + } + Event::Condition(_) => {} + } + walk_nodes(&node.branches_node, out); + } + } + + let mut participants = HashSet::new(); + walk_nodes(&tree.root_interactions, &mut participants); + + for p in &tree.participants { + participants.insert(p.alias.clone().unwrap_or_else(|| p.display_name.clone())); + } + + participants +} + +/// Produce an [`IdMapFile`] from a resolved sequence diagram. +/// +/// Sequence diagrams have no "definition" elements — all participants are +/// references (each participant links away to the component diagram that +/// elaborates it). +fn sequence_model_to_idmap(model: &SequenceTree, source: &str) -> IdMapFile { + let participants = collect_participants(model); + let mut references: Vec = participants + .into_iter() + .map(|name| IdMapEntry { + alias: name.clone(), + id: name, + }) + .collect(); + references.sort_by(|a, b| a.id.cmp(&b.id)); + + IdMapFile { + source: source.to_string(), + defines: Vec::new(), + references, + } +} + +/// Produce an empty [`IdMapFile`] for diagrams without cross-linkable elements. +fn empty_idmap(source: &str) -> IdMapFile { + IdMapFile { + source: source.to_string(), + defines: Vec::new(), + references: Vec::new(), + } +} + +/// Return `true` when `alias` is a 2-part TRLC fully-qualified name of the form +/// `Package.Record`, where each part is a valid identifier (leading ASCII +/// letter or `_`, followed by ASCII alphanumerics or `_`). +fn is_trlc_fqn(alias: &str) -> bool { + let parts: Vec<&str> = alias.split('.').collect(); + if parts.len() != 2 { + return false; + } + parts.iter().all(|part| { + let mut chars = part.chars(); + let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_'); + first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + }) +} + +/// Produce an [`IdMapFile`] from a resolved FTA model. +/// +/// A node is a **define** when it is the tree's top event (`NodeKind::TopEvent`, +/// `connection` is `None` — a relation sink never used as a source). +/// A gate node is a **reference** only when [`FtaNode::gate_kind`] is +/// `GateKind::TransferIn` (a `$TransferInGate` pointing to another diagram's +/// top event); `GateKind::And`/`GateKind::Or` gates are internal and produce +/// no cross-diagram link. All other nodes (basic/intermediate events) are +/// likewise internal. +fn fta_model_to_idmap(model: &FtaModel, source: &str) -> IdMapFile { + let mut defines = Vec::new(); + let mut references = Vec::new(); + + for node in &model.nodes { + match node.kind { + NodeKind::TopEvent => { + defines.push(IdMapEntry { + alias: node.alias.clone(), + id: node.alias.clone(), + }); + } + NodeKind::Gate if node.gate_kind == Some(GateKind::TransferIn) => { + if !is_trlc_fqn(&node.alias) { + log::warn!( + "FTA $TransferInGate {:?} does not look like a TRLC \ + fully-qualified name (expected 'Package.Record'); \ + emitting it as a reference anyway", + node.alias, + ); + } + references.push(IdMapEntry { + alias: node.alias.clone(), + id: node.alias.clone(), + }); + } + _ => {} // BasicEvent, IntermediateEvent, $AndGate/$OrGate — internal, no link. + } + } + + defines.sort_by(|a, b| a.id.cmp(&b.id)); + references.sort_by(|a, b| a.id.cmp(&b.id)); + + IdMapFile { + source: source.to_string(), + defines, + references, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Write an `.idmap.json` file for *model* into *output_dir*. +/// +/// The output filename is `.idmap.json` where `` is the file +/// stem of *input_path* (the original `.puml` source file). +/// +/// The `source` field embedded in the JSON is set to *source_name* when +/// provided (preferred: a stable workspace-relative path such as +/// `score/mw/com/proxy_detail.puml`), otherwise falls back to +/// `input_path.to_string_lossy()`. +pub fn write_idmap_to_file( + model: IdMapModel<'_>, + input_path: &Path, + source_name: Option<&str>, + diagram_name: Option<&str>, + output_dir: &Path, +) -> 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 { + 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), + }; + + write_idmap_json(input_path, output_dir, &idmap) +} + +/// Write an empty `.idmap.json` for diagrams that intentionally have no +/// cross-linkable elements (for example, activity diagrams). +pub fn write_empty_idmap_to_file( + input_path: &Path, + source_name: Option<&str>, + output_dir: &Path, +) -> 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); + + write_idmap_json(input_path, output_dir, &idmap) +} + +/// Compute the `.idmap.json` output path for `input_path` in +/// `output_dir`, without writing anything. +/// +/// Exposed so callers that process multiple input files in one run (e.g. the +/// CLI) can pre-check for filename collisions *before* writing: two `.puml` +/// files in different directories that share a file stem would otherwise +/// silently overwrite each other's `.idmap.json` output, since the filename +/// is derived from the stem alone. +pub fn idmap_output_path(input_path: &Path, output_dir: &Path) -> PathBuf { + let file_stem = input_path + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("output"); + output_dir.join(format!("{file_stem}.idmap.json")) +} + +fn write_idmap_json( + input_path: &Path, + output_dir: &Path, + idmap: &IdMapFile, +) -> io::Result { + let output_path = idmap_output_path(input_path, output_dir); + + let json = serde_json::to_string_pretty(idmap) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(&output_path, json)?; + + log::debug!("idmap written to {}", output_path.display()); + Ok(output_path) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use class_diagram::{MemberVariable, Method, RelationType, Relationship, SimpleEntity}; + use component_diagram::{ComponentType, SourceLocation}; + use puml_fta::FtaNode; + use sequence_logic::{Event, Interaction, SequenceNode}; + + fn component( + id: &str, + alias: Option<&str>, + name: Option<&str>, + parent: Option<&str>, + ) -> LogicComponent { + LogicComponent { + id: id.to_string(), + name: name.map(str::to_string), + alias: alias.map(str::to_string), + parent_id: parent.map(str::to_string), + element_type: ComponentType::Component, + stereotype: None, + relations: Vec::new(), + source_location: SourceLocation::new("test.puml", 0), + } + } + + fn component_map(components: Vec) -> HashMap { + components.into_iter().map(|c| (c.id.clone(), c)).collect() + } + + #[test] + fn component_children_make_define_leaves_make_reference() { + // `Proxy` has a child `Handler` → Proxy is a define, Handler a reference. + let model = component_map(vec![ + component("Proxy", Some("Proxy"), None, None), + component("Handler", Some("Handler"), None, Some("Proxy")), + ]); + + let idmap = comp_model_to_idmap(&model, "pkg/proxy.puml", None); + + assert_eq!(idmap.source, "pkg/proxy.puml"); + assert_eq!( + idmap + .defines + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["Proxy"] + ); + assert_eq!( + idmap + .references + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["Handler"] + ); + } + + #[test] + fn component_with_no_children_is_all_references() { + let model = component_map(vec![ + component("A", Some("A"), None, None), + component("B", Some("B"), None, None), + ]); + + let idmap = comp_model_to_idmap(&model, "pkg/overview.puml", None); + + assert!(idmap.defines.is_empty()); + assert_eq!(idmap.references.len(), 2); + } + + #[test] + fn component_alias_falls_back_to_name_then_id() { + let model = component_map(vec![ + component("id.only", None, None, None), + component("id.named", None, Some("DisplayName"), None), + component("id.aliased", Some("AliasName"), Some("DisplayName"), None), + ]); + + let idmap = comp_model_to_idmap(&model, "pkg/aliases.puml", None); + + let alias_of = |id: &str| -> String { + idmap + .references + .iter() + .find(|e| e.id == id) + .map(|e| e.alias.clone()) + .unwrap() + }; + assert_eq!(alias_of("id.only"), "id.only"); + assert_eq!(alias_of("id.named"), "DisplayName"); + assert_eq!(alias_of("id.aliased"), "AliasName"); + } + + #[test] + fn component_output_is_sorted_by_id() { + let model = component_map(vec![ + component("zeta", Some("zeta"), None, None), + component("alpha", Some("alpha"), None, None), + component("mu", Some("mu"), None, None), + ]); + + let idmap = comp_model_to_idmap(&model, "pkg/sorted.puml", None); + + let ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["alpha", "mu", "zeta"]); + } + + #[test] + fn class_entities_with_members_are_defines() { + let with_members = SimpleEntity { + id: "pkg.WithMembers".to_string(), + name: "WithMembers".to_string(), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let without_members = SimpleEntity { + id: "pkg.Empty".to_string(), + name: "Empty".to_string(), + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![with_members, without_members], + }; + + let idmap = class_model_to_idmap(&model, "pkg/classes.puml"); + + assert_eq!( + idmap + .defines + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["pkg.WithMembers"] + ); + assert_eq!( + idmap + .references + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["pkg.Empty"] + ); + } + + #[test] + fn class_output_is_sorted_by_id_for_defines_and_references() { + let with_members_z = SimpleEntity { + id: "pkg.Z".to_string(), + name: "Z".to_string(), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let with_members_a = SimpleEntity { + id: "pkg.A".to_string(), + name: "A".to_string(), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let ref_m = SimpleEntity { + id: "pkg.M".to_string(), + name: "M".to_string(), + ..Default::default() + }; + let ref_b = SimpleEntity { + id: "pkg.B".to_string(), + name: "B".to_string(), + ..Default::default() + }; + + let model = ClassDiagram { + name: "sorted".to_string(), + entities: vec![with_members_z, ref_m, with_members_a, ref_b], + }; + + let idmap = class_model_to_idmap(&model, "pkg/class_sorted.puml"); + + let define_ids: Vec<&str> = idmap.defines.iter().map(|e| e.id.as_str()).collect(); + let ref_ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + + assert_eq!(define_ids, ["pkg.A", "pkg.Z"]); + assert_eq!(ref_ids, ["pkg.B", "pkg.M"]); + } + + #[test] + fn sequence_participants_become_sorted_references() { + let interaction = |caller: &str, callee: &str| SequenceNode { + event: Event::Interaction(Interaction { + caller: caller.to_string(), + callee: callee.to_string(), + method: "call".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: Vec::new(), + }; + let tree = SequenceTree { + name: None, + participants: Vec::new(), + root_interactions: vec![interaction("Zebra", "Alpha"), interaction("Alpha", "Mango")], + }; + + let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); + + assert!(idmap.defines.is_empty()); + let ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["Alpha", "Mango", "Zebra"]); + } + + #[test] + fn empty_model_yields_empty_idmap() { + let idmap = empty_idmap("pkg/activity.puml"); + + assert_eq!(idmap.source, "pkg/activity.puml"); + assert!(idmap.defines.is_empty()); + assert!(idmap.references.is_empty()); + } + + #[test] + fn overview_top_level_leaves_are_references_not_defines() { + // [Gateway] --> [Proxy] — no children on either + let model = component_map(vec![ + component("Gateway", Some("Gateway"), None, None), + component("Proxy", Some("Proxy"), None, None), + ]); + let idmap = comp_model_to_idmap(&model, "overview.puml", None); + assert!(idmap.defines.is_empty()); + assert_eq!(idmap.references.len(), 2); + } + + #[test] + fn detail_diagram_name_promotes_to_define() { + // @startuml Proxy — diagram_name matches element alias + let model = component_map(vec![ + component("Proxy", Some("Proxy"), None, None), + component("Proxy.RequestHandler", Some("RequestHandler"), None, None), + ]); + let idmap = comp_model_to_idmap(&model, "proxy_detail.puml", Some("Proxy")); + assert!(idmap.defines.iter().any(|e| e.alias == "Proxy")); + } + + #[test] + fn package_with_children_is_a_define() { + let mut pkg = component("Pkg", Some("Pkg"), None, None); + pkg.element_type = ComponentType::Package; + let model = component_map(vec![ + pkg, + component("A", Some("A"), None, Some("Pkg")), + component("AA", Some("AA"), None, Some("A")), + ]); + + let idmap = comp_model_to_idmap(&model, "pkg.puml", None); + assert!(idmap.defines.iter().any(|e| e.id == "Pkg")); + assert!(!idmap.references.iter().any(|e| e.id == "Pkg")); + } + + #[test] + fn empty_package_is_a_reference() { + let mut pkg = component("Pkg", Some("Pkg"), None, None); + pkg.element_type = ComponentType::Package; + let model = component_map(vec![pkg]); + + let idmap = comp_model_to_idmap(&model, "pkg.puml", None); + assert!(!idmap.defines.iter().any(|e| e.id == "Pkg")); + assert!(idmap.references.iter().any(|e| e.id == "Pkg")); + } + + #[test] + fn class_relationship_endpoints_are_emitted_as_references() { + let define = SimpleEntity { + id: "pkg.Define".to_string(), + name: "Define".to_string(), + variables: vec![MemberVariable::default()], + relationships: vec![Relationship { + source: "pkg.Define".to_string(), + target: "pkg.ExternalRef".to_string(), + relation_type: RelationType::Association, + source_multiplicity: None, + target_multiplicity: None, + ..Default::default() + }], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![define], + }; + + let idmap = class_model_to_idmap(&model, "pkg/classes.puml"); + assert!(idmap.references.iter().any(|e| e.id == "pkg.ExternalRef")); + } + + #[test] + fn class_relationship_endpoint_that_is_a_define_is_not_duplicated_as_reference() { + // A --> B where BOTH endpoints have members (are defines). The + // `define_ids.contains(endpoint)` guard must keep them out of references. + let a = SimpleEntity { + id: "pkg.A".to_string(), + name: "A".to_string(), + variables: vec![MemberVariable::default()], + relationships: vec![Relationship { + source: "pkg.A".to_string(), + target: "pkg.B".to_string(), + relation_type: RelationType::Association, + source_multiplicity: None, + target_multiplicity: None, + ..Default::default() + }], + ..Default::default() + }; + let b = SimpleEntity { + id: "pkg.B".to_string(), + name: "B".to_string(), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![a, b], + }; + + let idmap = class_model_to_idmap(&model, "pkg/classes.puml"); + + let define_ids: Vec<&str> = idmap.defines.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(define_ids, ["pkg.A", "pkg.B"]); + assert!( + idmap.references.is_empty(), + "endpoints already present as defines must not be duplicated into references, got: {:?}", + idmap.references + ); + } + + #[test] + fn class_namespace_container_endpoint_is_not_emitted_as_reference() { + // A relation points at the namespace/package container FQN. Containers + // are not double-counted as references (they're already synthesized + // as a define below), so the endpoint must be dropped from references. + let child = SimpleEntity { + id: "pkg.Container.Child".to_string(), + name: "Child".to_string(), + enclosing_namespace_id: Some("pkg.Container".to_string()), + variables: vec![MemberVariable::default()], + relationships: vec![Relationship { + source: "pkg.Container.Child".to_string(), + target: "pkg.Container".to_string(), + relation_type: RelationType::Association, + source_multiplicity: None, + target_multiplicity: None, + ..Default::default() + }], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![child], + }; + + let idmap = class_model_to_idmap(&model, "pkg/ns.puml"); + assert!( + !idmap.references.iter().any(|e| e.id == "pkg.Container"), + "namespace container must not leak as a reference, got: {:?}", + idmap.references + ); + assert!( + idmap.defines.iter().any(|e| e.id == "pkg.Container"), + "namespace container must be synthesized as a define so units \ + elaborated only via a namespace (not a same-named class) stay \ + linkable, got: {:?}", + idmap.defines + ); + } + + #[test] + fn class_namespace_container_as_relationship_source_is_not_emitted_as_reference() { + // Symmetric to the test above, but the container FQN is the + // relationship's `source` rather than its `target` — the endpoint + // filter must apply to both sides, not just `target`. + // NOTE: a relationship whose `source` is the container FQN itself + // couldn't be produced by the real resolver (relationships are always + // pushed onto a concrete entity, and containers are never entities), + // but the idmap converter must stay defensive either way — attach it + // to the concrete child entity to exercise the `source` endpoint side + // of the filter. + let child = SimpleEntity { + id: "pkg.Container.Child".to_string(), + name: "Child".to_string(), + enclosing_namespace_id: Some("pkg.Container".to_string()), + variables: vec![MemberVariable::default()], + relationships: vec![Relationship { + source: "pkg.Container".to_string(), + target: "pkg.Container.Child".to_string(), + relation_type: RelationType::Association, + source_multiplicity: None, + target_multiplicity: None, + ..Default::default() + }], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![child], + }; + + let idmap = class_model_to_idmap(&model, "pkg/ns.puml"); + assert!( + !idmap.references.iter().any(|e| e.id == "pkg.Container"), + "namespace container must not leak as a reference when it is the \ + relationship source, got: {:?}", + idmap.references + ); + assert!(idmap.defines.iter().any(|e| e.id == "pkg.Container")); + } + + #[test] + fn class_namespace_define_alias_is_last_path_segment_and_id_is_full_fqn() { + // Real-world shape: a unit elaborated only via + // `namespace unit_1 { class Foo { ... } }` — Foo is a real + // implementation class (not a same-named placeholder for the unit), + // so the only way a bare `unit_1` reference elsewhere can resolve to + // this diagram is via a synthesized define for the namespace itself. + let foo = SimpleEntity { + id: "safety_software_seooc_example.component_example.unit_1.Foo".to_string(), + name: "Foo".to_string(), + enclosing_namespace_id: Some( + "safety_software_seooc_example.component_example.unit_1".to_string(), + ), + methods: vec![Method::default()], + ..Default::default() + }; + let model = ClassDiagram { + name: "unit_1_class_diagram".to_string(), + entities: vec![foo], + }; + + let idmap = class_model_to_idmap(&model, "unit_1/docs/unit_1_class_diagram.puml"); + + let namespace_define = idmap + .defines + .iter() + .find(|e| e.id == "safety_software_seooc_example.component_example.unit_1") + .expect("namespace must be synthesized as a define"); + assert_eq!(namespace_define.alias, "unit_1"); + } + + #[test] + fn class_namespace_define_does_not_shadow_a_real_entity_with_the_same_id() { + // Defensive: if a namespace FQN happens to collide with a genuine + // entity's id (e.g. a dangling relationship endpoint already resolved + // as a reference, or - in principle - a define), the synthesized + // namespace define must not duplicate or shadow it. + let child = SimpleEntity { + id: "pkg.Container.Child".to_string(), + name: "Child".to_string(), + enclosing_namespace_id: Some("pkg.Container".to_string()), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let container_as_real_entity = SimpleEntity { + id: "pkg.Container".to_string(), + name: "Container".to_string(), + methods: vec![Method::default()], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![child, container_as_real_entity], + }; + + let idmap = class_model_to_idmap(&model, "pkg/ns.puml"); + + assert_eq!( + idmap + .defines + .iter() + .filter(|e| e.id == "pkg.Container") + .count(), + 1, + "the real entity must not be duplicated by the namespace synthesis, got: {:?}", + idmap.defines + ); + } + + #[test] + fn class_matches_diagram_name_promotes_memberless_entity_to_define() { + // @startuml Proxy — an entity without members whose name equals the + // diagram name is still elaborated here, so it is a define. + let proxy = SimpleEntity { + id: "pkg.Proxy".to_string(), + name: "Proxy".to_string(), + ..Default::default() + }; + let leaf = SimpleEntity { + id: "pkg.Leaf".to_string(), + name: "Leaf".to_string(), + ..Default::default() + }; + let model = ClassDiagram { + name: "Proxy".to_string(), + entities: vec![proxy, leaf], + }; + + let idmap = class_model_to_idmap(&model, "pkg/proxy.puml"); + assert!(idmap.defines.iter().any(|e| e.id == "pkg.Proxy")); + assert!(idmap.references.iter().any(|e| e.id == "pkg.Leaf")); + assert!(!idmap.defines.iter().any(|e| e.id == "pkg.Leaf")); + } + + // ── FTA converter ────────────────────────────────────────────────────── + + fn fta_node(kind: NodeKind, gate_kind: Option, alias: &str) -> FtaNode { + FtaNode { + kind, + name: None, + alias: alias.to_string(), + connection: None, + gate_kind, + line: None, + } + } + + #[test] + fn fta_top_event_is_define_and_transfer_gate_is_reference() { + let model = FtaModel { + nodes: vec![ + fta_node(NodeKind::TopEvent, None, "pkg.TopFailure"), + // $TransferInGate: alias is a foreign top-event FQN → reference. + fta_node( + NodeKind::Gate, + Some(GateKind::TransferIn), + "other.ForeignTop", + ), + // $AndGate/$OrGate are internal regardless of alias shape — + // classification is driven by `gate_kind`, not the alias, so a + // dotted alias on a non-transfer gate must NOT be misread as a + // cross-diagram reference (regression test). + fta_node(NodeKind::Gate, Some(GateKind::And), "And.Gate1"), + fta_node(NodeKind::Gate, Some(GateKind::Or), "OG"), + // Basic events are internal even when the alias looks like an FQN. + fta_node(NodeKind::BasicEvent, None, "pkg.Cause"), + fta_node(NodeKind::IntermediateEvent, None, "IE"), + ], + }; + + let idmap = fta_model_to_idmap(&model, "pkg/fta.puml"); + + let define_ids: Vec<&str> = idmap.defines.iter().map(|e| e.id.as_str()).collect(); + let reference_ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(define_ids, ["pkg.TopFailure"]); + assert_eq!(reference_ids, ["other.ForeignTop"]); + } + + #[test] + fn is_trlc_fqn_accepts_two_part_identifiers_and_rejects_others() { + // Valid: exactly two identifier parts. + assert!(is_trlc_fqn("Package.Record")); + assert!(is_trlc_fqn("_priv.R1")); + assert!(is_trlc_fqn("a1.b2")); + + // Invalid: wrong number of parts. + assert!(!is_trlc_fqn("NoDot")); + assert!(!is_trlc_fqn("a.b.c")); + assert!(!is_trlc_fqn("")); + + // Invalid: empty parts. + assert!(!is_trlc_fqn(".Record")); + assert!(!is_trlc_fqn("Package.")); + + // Invalid: bad leading character or illegal characters. + assert!(!is_trlc_fqn("1bad.Name")); + assert!(!is_trlc_fqn("good.2bad")); + assert!(!is_trlc_fqn("has space.Name")); + assert!(!is_trlc_fqn("dash-ed.Name")); + } + + // ── Public write_idmap_to_file dispatch ───────────────────────────────── + + #[test] + fn write_idmap_to_file_writes_component_dispatch_to_disk() { + let dir = unique_tmp_dir("write_component"); + let model = component_map(vec![ + component("Proxy", Some("Proxy"), None, None), + component("Handler", Some("Handler"), None, Some("Proxy")), + ]); + let input = Path::new("some/dir/proxy.puml"); + + let output = write_idmap_to_file( + IdMapModel::Component(&model), + input, + Some("pkg/proxy.puml"), + None, + &dir, + ) + .expect("component idmap must be written"); + + assert_eq!( + output.file_name().and_then(OsStr::to_str), + Some("proxy.idmap.json") + ); + let parsed: IdMapFile = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(parsed.source, "pkg/proxy.puml"); + assert_eq!( + parsed + .defines + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["Proxy"] + ); + assert_eq!( + parsed + .references + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["Handler"] + ); + + cleanup_tmp_dir(&dir); + } + + #[test] + fn write_idmap_to_file_writes_class_dispatch_to_disk() { + let dir = unique_tmp_dir("write_class"); + let with_members = SimpleEntity { + id: "pkg.WithMembers".to_string(), + name: "WithMembers".to_string(), + variables: vec![MemberVariable::default()], + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![with_members], + }; + let input = Path::new("some/dir/classes.puml"); + + let output = write_idmap_to_file( + IdMapModel::Class(&model), + input, + Some("pkg/classes.puml"), + None, + &dir, + ) + .expect("class idmap must be written"); + + let parsed: IdMapFile = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(parsed.source, "pkg/classes.puml"); + assert_eq!( + parsed + .defines + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["pkg.WithMembers"] + ); + + cleanup_tmp_dir(&dir); + } + + #[test] + fn write_idmap_to_file_writes_sequence_dispatch_to_disk() { + let dir = unique_tmp_dir("write_sequence"); + let interaction = SequenceNode { + event: Event::Interaction(Interaction { + caller: "Alpha".to_string(), + callee: "Beta".to_string(), + method: "call".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: Vec::new(), + }; + let tree = SequenceTree { + name: None, + participants: Vec::new(), + root_interactions: vec![interaction], + }; + let input = Path::new("some/dir/seq.puml"); + + let output = write_idmap_to_file( + IdMapModel::Sequence(&tree), + input, + Some("pkg/seq.puml"), + None, + &dir, + ) + .expect("sequence idmap must be written"); + + let parsed: IdMapFile = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!(parsed.source, "pkg/seq.puml"); + assert!(parsed.defines.is_empty()); + assert_eq!( + parsed + .references + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["Alpha", "Beta"] + ); + + cleanup_tmp_dir(&dir); + } + + // ── Public empty writer API ──────────────────────────────────────────── + + fn unique_tmp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "puml_idmap_{}_{}_{}", + tag, + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Remove a test's temp dir, surfacing (rather than silently swallowing) + /// any cleanup failure so a locked/undeletable dir doesn't go unnoticed. + fn cleanup_tmp_dir(dir: &Path) { + if let Err(e) = fs::remove_dir_all(dir) { + eprintln!( + "warning: failed to remove temp dir {}: {}", + dir.display(), + e + ); + } + } + + #[test] + fn write_empty_idmap_to_file_emits_empty_arrays_to_disk() { + 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) + .expect("empty idmap must be written"); + + assert_eq!( + output.file_name().and_then(OsStr::to_str), + Some("activity.idmap.json"), + "output filename must be derived from the input stem" + ); + + let content = fs::read_to_string(&output).unwrap(); + let parsed: IdMapFile = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.source, "score/activity.puml"); + assert!(parsed.defines.is_empty()); + assert!(parsed.references.is_empty()); + + cleanup_tmp_dir(&dir); + } + + #[test] + fn write_empty_idmap_to_file_falls_back_to_input_path_when_source_is_none() { + 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 content = fs::read_to_string(&output).unwrap(); + let parsed: IdMapFile = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.source, "rel/dir/diagram.puml"); + + cleanup_tmp_dir(&dir); + } + + // ── Sequence participant traversal ───────────────────────────────────── + + #[test] + fn sequence_collect_participants_traverses_returns_and_nested_branches() { + use sequence_logic::{Condition, ConditionType, Return}; + + // Deeply nested interaction inside a control block (Condition), which + // itself sits inside a Return node's branches. `collect_participants` + // must recurse through both and pick up Deep/Nested, plus the Return's + // own caller/callee (A/B). Condition contributes no participant itself. + let deep = SequenceNode { + event: Event::Interaction(Interaction { + caller: "Deep".to_string(), + callee: "Nested".to_string(), + method: "call".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: Vec::new(), + }; + let control_block = SequenceNode { + event: Event::Condition(Condition { + condition_type: ConditionType::Alt, + condition_value: "ok?".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: vec![deep], + }; + let ret = SequenceNode { + event: Event::Return(Return { + caller: "B".to_string(), + callee: "A".to_string(), + return_content: "ok".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: vec![control_block], + }; + let tree = SequenceTree { + name: None, + participants: Vec::new(), + root_interactions: vec![ret], + }; + + let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); + + assert!(idmap.defines.is_empty()); + let ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["A", "B", "Deep", "Nested"]); + } + + #[test] + fn sequence_declared_but_unused_participant_is_still_a_reference() { + // `Idle` is declared via a `participant` statement but never appears + // as a caller/callee in any message, so the interaction walk alone + // would miss it. `collect_participants` must merge in `tree.participants` + // to still emit it as a reference. + let interaction = SequenceNode { + event: Event::Interaction(Interaction { + caller: "Alice".to_string(), + callee: "Bob".to_string(), + method: "call".to_string(), + }), + source_location: SourceLocation::new("test.puml", 0), + branches_node: Vec::new(), + }; + let declared_idle = sequence_logic::SequenceParticipant { + display_name: "Idle".to_string(), + alias: None, + participant_type: sequence_logic::ParticipantType::Participant, + source_location: SourceLocation::new("test.puml", 0), + stereotype: None, + }; + let tree = SequenceTree { + name: None, + participants: vec![declared_idle], + root_interactions: vec![interaction], + }; + + let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); + + let ids: Vec<&str> = idmap.references.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["Alice", "Bob", "Idle"]); + } +} diff --git a/plantuml/parser/puml_lobster/BUILD b/plantuml/parser/puml_lobster/BUILD index 6738d1ee..685a6c1e 100644 --- a/plantuml/parser/puml_lobster/BUILD +++ b/plantuml/parser/puml_lobster/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "puml_lobster", @@ -23,3 +23,12 @@ rust_library( "@crates//:serde_json", ], ) + +rust_test( + name = "puml_lobster_test", + crate = ":puml_lobster", + deps = [ + "//tools/metamodel/class:class_diagram", + "//tools/metamodel/component:component_diagram", + ], +) diff --git a/plantuml/parser/puml_lobster/src/lib.rs b/plantuml/parser/puml_lobster/src/lib.rs index 95a23abe..050dfcaf 100644 --- a/plantuml/parser/puml_lobster/src/lib.rs +++ b/plantuml/parser/puml_lobster/src/lib.rs @@ -149,12 +149,12 @@ fn map_entity_type_to_kind(entity_type: EntityType) -> &'static str { pub fn write_lobster_to_file( model: LobsterModel<'_>, input_path: &Path, + source_path: &str, output_dir: &Path, ) -> io::Result { let lobster = match model { LobsterModel::Component(component_model) => { - let source_path = input_path.to_string_lossy(); - comp_model_to_lobster(component_model, source_path.as_ref()) + comp_model_to_lobster(component_model, source_path) } LobsterModel::Class(class_model) => class_model_to_lobster(class_model), LobsterModel::Empty => empty_lobster_document(), @@ -180,3 +180,147 @@ fn write_lobster_value_to_file( fs::write(&output_path, content + "\n")?; Ok(output_path) } + +#[cfg(test)] +mod tests { + use super::*; + use class_diagram::SimpleEntity; + use component_diagram::SourceLocation; + + fn interface_component(id: &str) -> LogicComponent { + LogicComponent { + id: id.to_string(), + name: None, + alias: None, + parent_id: None, + element_type: ComponentType::Interface, + stereotype: None, + relations: Vec::new(), + source_location: SourceLocation::new("test.puml", 0), + } + } + + fn unique_tmp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "puml_lobster_{}_{}_{}", + tag, + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Remove a test's temp dir, surfacing (rather than silently swallowing) + /// any cleanup failure so a locked/undeletable dir doesn't go unnoticed. + fn cleanup_tmp_dir(dir: &Path) { + if let Err(e) = fs::remove_dir_all(dir) { + eprintln!( + "warning: failed to remove temp dir {}: {}", + dir.display(), + e + ); + } + } + + #[test] + fn write_lobster_to_file_embeds_source_path_for_component_model() { + let mut model = HashMap::new(); + model.insert("pkg.Iface".to_string(), interface_component("pkg.Iface")); + let dir = unique_tmp_dir("component"); + let input = Path::new("some/dir/component.puml"); + + let output = write_lobster_to_file( + LobsterModel::Component(&model), + input, + "pkg/component.puml", + &dir, + ) + .expect("lobster file must be written"); + + let doc: Value = serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + let items = doc["data"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["location"]["file"], "pkg/component.puml"); + + cleanup_tmp_dir(&dir); + } + + #[test] + fn write_lobster_to_file_embeds_source_path_for_class_model() { + let entity = SimpleEntity { + id: "pkg.Foo".to_string(), + name: "Foo".to_string(), + source_location: SourceLocation::new("pkg/classes.puml", 0), + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![entity], + }; + let dir = unique_tmp_dir("class"); + let input = Path::new("some/dir/classes.puml"); + + let output = + write_lobster_to_file(LobsterModel::Class(&model), input, "pkg/classes.puml", &dir) + .expect("lobster file must be written"); + + let doc: Value = serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + let items = doc["data"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["location"]["file"], "pkg/classes.puml"); + + cleanup_tmp_dir(&dir); + } + + /// Each entity's own `source_location.file` is embedded in its lobster + /// item, independent of the `source_path` parameter passed to the writer + /// (which only applies to component models). + #[test] + fn write_lobster_to_file_class_entity_uses_own_source_location() { + let entity = SimpleEntity { + id: "pkg.Foo".to_string(), + name: "Foo".to_string(), + source_location: SourceLocation::new("pkg/entity_specific.puml", 0), + ..Default::default() + }; + let model = ClassDiagram { + name: "d".to_string(), + entities: vec![entity], + }; + let dir = unique_tmp_dir("class_override"); + let input = Path::new("some/dir/classes.puml"); + + let output = + write_lobster_to_file(LobsterModel::Class(&model), input, "pkg/classes.puml", &dir) + .expect("lobster file must be written"); + + let doc: Value = serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + assert_eq!( + doc["data"].as_array().unwrap()[0]["location"]["file"], + "pkg/entity_specific.puml" + ); + + cleanup_tmp_dir(&dir); + } + + #[test] + fn write_lobster_to_file_output_filename_is_input_stem() { + let dir = unique_tmp_dir("filename"); + let input = Path::new("some/dir/my_diagram.puml"); + + let output = write_lobster_to_file(LobsterModel::Empty, input, "pkg/my_diagram.puml", &dir) + .expect("lobster file must be written"); + + assert_eq!( + output.file_name().and_then(|n| n.to_str()), + Some("my_diagram.lobster") + ); + + cleanup_tmp_dir(&dir); + } +} diff --git a/plantuml/sphinx/clickable_plantuml/BUILD b/plantuml/sphinx/clickable_plantuml/BUILD index 3827f9a0..0197d812 100644 --- a/plantuml/sphinx/clickable_plantuml/BUILD +++ b/plantuml/sphinx/clickable_plantuml/BUILD @@ -16,6 +16,12 @@ filegroup( visibility = ["//visibility:public"], ) +filegroup( + name = "doc_assets", + srcs = glob(["docs/assets/*.puml"]), + visibility = ["//visibility:public"], +) + py_library( name = "clickable_plantuml", srcs = ["clickable_plantuml.py"], diff --git a/plantuml/sphinx/clickable_plantuml/README.md b/plantuml/sphinx/clickable_plantuml/README.md index 9d767037..9203648c 100644 --- a/plantuml/sphinx/clickable_plantuml/README.md +++ b/plantuml/sphinx/clickable_plantuml/README.md @@ -14,21 +14,63 @@ Sphinx extension that makes PlantUML diagrams clickable by injecting hyperlinks into rendered SVG/PNG diagrams. +## Concept: From `.puml` Source to Clickable Diagram + +This extension is only the last of three stages in a pipeline that turns plain +`.puml` text files into cross-linked, clickable architecture diagrams. It does +**not** parse PlantUML itself — it only reads the small `.idmap.json` sidecar +files produced upstream and uses them to inject link directives before +`sphinxcontrib-plantuml` renders the image. + +A PlantUML class diagram of the pipeline (steps plus their input/output +artifacts) shows the three stages summarized below. Its source is stored in +`docs/assets/clickable_plantuml_pipeline.puml`: + +```{uml} docs/assets/clickable_plantuml_pipeline.puml +:align: center +:alt: clickable_plantuml pipeline overview +``` + +1. **Parse & resolve** — `puml_cli` (in `plantuml/parser/`) is invoked once per + `.puml` file by the `architectural_design()` Bazel rule. It preprocesses + `!include`s, parses the diagram with a `pest`-grammar parser + (`puml_parser`), and resolves aliases/references into a fully-qualified + model (`puml_resolver`). This is the only stage that actually understands + PlantUML syntax. +2. **idmap generation** — `puml_idmap` walks the resolved model and classifies + each element as either a *define* (something this diagram elaborates, + e.g. a component with children, or a class with members) or a *reference* + (a leaf mention or relation endpoint that should link elsewhere). The + result is written as one `.idmap.json` sidecar per diagram — see + the *Role detection algorithm* and *idmap Format* sections below for the + exact rules and shape. +3. **Sphinx consumption** — `clickable_plantuml.py` (this directory) never + touches `.puml` source or FlatBuffers output. At `builder-inited` it scans + the source tree for every `*.idmap.json`, builds a global + `{alias|id → [definer source paths]}` index, and at `doctree-resolved` uses + that index to rewrite each diagram's `uml` text with + `url of is [[]]` directives *before* handing it off to + `sphinxcontrib-plantuml`'s own renderer, which does the actual PlantUML → + SVG/PNG rendering. + ## Sphinx Integration -The extension hooks into the native Sphinx build lifecycle. URLs are computed by -`app.builder.get_relative_uri()`, which works for any builder and -output directory layout. +The extension hooks into the native Sphinx build lifecycle. URL computation +depends on the configured `plantuml_output_format`: in `svg_obj` mode the +rendered SVG lives in `_images/`, so links are made relative to that directory +(`os.path.relpath(target_uri, imagedir)`); for inline `svg`/`png` the link is +relative to the containing HTML page via +`app.builder.get_relative_uri(from_docname, to_docname)`. ``` Sphinx build lifecycle clickable_plantuml hooks ═══════════════════════════════════ ═══════════════════════════════════════ builder-inited ───► on_builder_inited() - │ (one-time setup) Load all *plantuml_links.json files - │ from srcdir (recursive). - │ Store {puml_basename → alias_map} - │ in app.env. + │ (one-time setup) Load all *.idmap.json files from + │ srcdir (recursive). + │ Build definition index: + │ {alias|id → [definer source paths]}. │ ├─ READ PHASE ────────────────────────────────────────────────────────────── │ for each document: @@ -40,10 +82,9 @@ Sphinx build lifecycle clickable_plantuml hooks │ │ │ doctree-read ───► on_doctree_read() │ (per document) Traverse the parsed doctree. - │ For every plantuml node that has a - │ filename attribute, record - │ {puml_basename → docname} in app.env. - │ Warn on basename collisions. + │ For every plantuml node, record + │ {normalized_source_path → docname} + │ in app.env (path identity, not basename). │ │ env-merge-info ───► on_env_merge_info() │ (parallel builds only) Merge puml→docname maps gathered @@ -55,119 +96,236 @@ Sphinx build lifecycle clickable_plantuml hooks │ post-transform / resolve │ │ │ doctree-resolved ───► on_doctree_resolved() - │ (per document) For each plantuml node, look up the - │ alias_map from app.env. - │ Resolve target .puml → docname, then - │ call app.builder.get_relative_uri() - │ to get the correct relative URL. - │ Append url of is [[url]] - │ directives to node['uml'] before - │ sphinxcontrib-plantuml renders it. + │ (per document) For each plantuml node, load its idmap. + │ For each reference entry, look up the + │ definition index (FQN first, then alias). + │ Apply proximity tiebreak on ambiguity. + │ Build the URL (relative to _images/ in + │ svg_obj mode, else page-relative via + │ get_relative_uri), then append + │ url of is [[url]] directives to + │ node['uml'] before rendering. │ build-finished ``` ## How It Works -1. **Link discovery** (`builder-inited`) – Scans for `*plantuml_links.json` files in the Sphinx source directory. -2. **Diagram location mapping** (`doctree-read`) – As Sphinx reads each document, the extension traverses the parsed doctree to record which `docname` contains which `.puml` diagram (keyed by basename). Basename collisions across documents are reported as warnings. -3. **URL resolution & link injection** (`doctree-resolved`) – For each plantuml node, resolves target `.puml` references to the docname that contains the target diagram, generates a relative URL via `app.builder.get_relative_uri()`, and appends `url of is [[url]]` directives to the PlantUML source before rendering. -4. **Incremental / parallel support** – `env-purge-doc` removes stale entries when a document is re-read; `env-merge-info` merges state from parallel worker processes. +1. **idmap discovery** (`builder-inited`) – Scans for `*.idmap.json` files in + the Sphinx source directory. Each sidecar records *defines* (elements + elaborated in that diagram, i.e. with children/members) and *references* + (leaf mentions and relation endpoints). A global definition index maps + each alias/FQN to the set of diagrams that elaborate it. -## Automatic JSON Generation (Bazel) +2. **Diagram location mapping** (`doctree-read`) – Records which `docname` + contains which `.puml` diagram, keyed by the canonical workspace-relative + path. A node's identity is recovered by normalising its absolute path + (`srcdir` + the node's `incdir` + `filename`) and deriving exactly one + canonical key using the precomputed workspace offset from `builder-inited`. + Matching is exact canonical-key equality after an `os.path.realpath` prefix + strip; a safe *unique*-suffix fallback additionally covers symlinked/staged + layouts where the prefix strip doesn't line up (only used when exactly one + known source key matches — never a best-effort guess among several + same-basename candidates), so same-basename diagrams in different packages + remain distinct. -`plantuml_links.json` is generated by the `architectural_design()` rule. +3. **URL resolution & link injection** (`doctree-resolved`) – For each + reference in a diagram's idmap, resolves the unique definer via the index. + When multiple diagrams define the same element, a *proximity tiebreak* + selects the definer sharing the longest common path prefix with the source + diagram. On a genuine tie, no link is emitted (safe over wrong). URLs are + built relative to `_images/` in `svg_obj` mode (else page-relative via + `app.builder.get_relative_uri()`) and percent-encoded before injection. -The `architectural_design()` rule invokes `//tools/plantuml/linker:linker` on all -`.fbs.bin` FlatBuffers files produced by the PlantUML parser. See -[Link Mapping Format](#link-mapping-format) for a detailed -description of which links are emitted. +4. **Incremental / parallel support** – `env-purge-doc` removes stale entries + when a document is re-read; `env-merge-info` merges state from parallel + worker processes. -### Algorithm +## Automatic idmap Generation (Bazel) -Given the set of `.fbs.bin` files for one `architectural_design()` target: +`.idmap.json` sidecars are produced by the `architectural_design()` rule. -1. **Build a top-level index** – For each diagram, collect every component whose - `parent_id` is `None` (i.e. it is not nested inside another component). - The index maps `alias → diagram file`. +The rule passes `--source-name ` and +`--idmap-output-dir` to `puml_cli` for every `.puml` file. The +`--source-name` argument **must be the srcdir-relative workspace path** to +satisfy the canonical-key invariant. Passing `short_path` (the workspace +root-relative path for Bazel sources) ensures this requirement is met. The +resulting idmap `source` field is a stable, unique, workspace-relative path +(e.g. `score/mw/com/proxy_detail.puml`), which becomes the diagram's identity +key throughout the extension. Duplicate `source` values across idmaps raise +an error; duplicate basenames (file stems) within one target also raise an +error. -2. **Emit links** – For every component in every diagram, look up its alias in - the top-level index. If a *different* diagram defines that alias as a - top-level component, emit a link entry: +### Role detection algorithm - ``` - source_file = diagram that contains the reference - source_id = alias of the component - target_file = diagram that defines it as a top-level component - ``` +Given the resolved model of one `.puml` diagram: -3. **Deduplicate** – Sort and deduplicate so that each `(source_file, source_id)` - pair has exactly one target (first alphabetically). Duplicate `source_id` - entries within the same source diagram are removed because PlantUML's - `url of X is [[…]]` directive supports only one URL per alias. +1. **defines** – An element is a *define* when any of the following hold: + - At least one other element lists it as its `parent_id` (component diagrams). + - It has member variables or methods (class diagrams). + - The diagram's `@startuml ` matches its alias or display name + (component and class diagrams). + - It is a `$TopEvent` node — the tree root whose `connection` is `None`, + never used as a relation source (FTA diagrams). +2. **references** – Elements that link away to another diagram: + - Top-level leaf boxes and relation endpoints (component diagrams). + - All participants (sequence diagrams — no defines in sequence). + - `$TransferInGate` nodes whose alias is a TRLC-style FQN + (`Package.Record`) referencing another diagram's top event (FTA diagrams). + - Internal FTA nodes (`$BasicEvent`, `$IntermediateEvent`, `$AndGate`, + `$OrGate`) are omitted — they do not cross-link to other diagrams. -### Concrete Example +### Concrete example ```text -' adas_overview.puml — subsystem context +' overview.puml — top-level leaves are REFERENCES @startuml -component ADAS -component BrakeController -component LaneKeepAssist -ADAS --> BrakeController -ADAS --> LaneKeepAssist +[Gateway] --> [Proxy] @enduml ``` ```text -' brake_controller.puml — component detail +' proxy_detail.puml — Proxy has a child → DEFINE @startuml -component BrakeController -interface BrakeDemandIF -interface WheelSpeedIF -BrakeController --> BrakeDemandIF -BrakeController <-- WheelSpeedIF +package Proxy { [RequestHandler] } @enduml ``` -Generated links — one in each direction: - +`proxy_detail.idmap.json`: ```json -{ - "links": [ - { - "source_file": "adas_overview.puml", - "source_id": "BrakeController", - "target_file": "brake_controller.puml" - }, - { - "source_file": "brake_controller.puml", - "source_id": "BrakeController", - "target_file": "adas_overview.puml" - } - ] -} +{ "source": "score/mw/com/proxy_detail.puml", + "defines": [{ "alias": "Proxy", "id": "Proxy" }], + "references": [{ "alias": "RequestHandler", "id": "Proxy.RequestHandler" }] } ``` -Clicking `BrakeController` in the overview navigates to its detail diagram; -clicking it in the detail diagram navigates back to the overview. +`overview.idmap.json`: +```json +{ "source": "score/overview.puml", + "defines": [], + "references": [{ "alias": "Gateway", "id": "Gateway" }, + { "alias": "Proxy", "id": "Proxy" }] } +``` -`ADAS` and `LaneKeepAssist` appear as top-level only in `adas_overview.puml` and -have no dedicated detail diagram, so **no links** are emitted for them. +Result: `Proxy` in `overview.puml` links to `proxy_detail.puml`. +`Gateway` has no definer → no link. -(link-mapping-format)= -## Link Mapping Format +## idmap Format -Place one or more `*plantuml_links.json` filesinside the Sphinx source directory: +`.idmap.json` files are written by the parser and read by this extension. +They are not intended to be authored manually. ```json { - "links": [ - { - "source_file": "my_diagram.puml", - "source_id": "ComponentA", - "target_file": "other_diagram.puml" - } + "source": "path/to/diagram.puml", + "defines": [ + { "alias": "ComponentName", "id": "fully.qualified.Name" } + ], + "references": [ + { "alias": "OtherComponent", "id": "OtherComponent" } ] } ``` + +## End-to-End Clickable Diagram Example + +Rather than duplicating a hand-written, untested example here, this exact +scenario is built and regression-tested as part of `rules_score`'s own test +suite: + +- [`overview.puml`](../../../bazel/rules/rules_score/test/fixtures/clickable_example/overview.puml) + references `Proxy` (a top-level leaf, per the *Role detection algorithm* + above). +- [`proxy_detail.puml`](../../../bazel/rules/rules_score/test/fixtures/clickable_example/proxy_detail.puml) + defines `Proxy` as a `package` with a nested child, making it a *define*. + +Both files are wired into a real `architectural_design()` + +`dependable_element()` target +(`clickable_example_lib` in [`bazel/rules/rules_score/test/BUILD`](../../../bazel/rules/rules_score/test/BUILD)), +so every change to the parser, `puml_idmap`, or this extension is checked +against genuinely Bazel-built `.idmap.json` artifacts — not just prose. The +[`clickable_example_link_rendered_test`](../../../bazel/rules/rules_score/test/check_clickable_example_link.sh) +`sh_test` asserts that: + +- `overview.idmap.json` references `Proxy` +- `proxy_detail.idmap.json` defines `Proxy` + +i.e. that clickable_plantuml has exactly what it needs to make the rendered +`Proxy` element in `overview.puml` clickable, linking to the page containing +`proxy_detail.puml`. Run it with: + +```shell +bazel test //bazel/rules/rules_score/test:clickable_example_link_rendered_test +``` + +The same pattern covers the other diagram-type scenarios from the *Role +detection algorithm* above: + +- **Cross-diagram-type interface linking** — `interface_overview.puml` (a + component diagram) references `InternalInterface` via a unit's `-(` + binding, and `interface_detail.puml` (auto-detected as a class diagram + because the interface has a method) defines it. Both share the FQN + `package_a.InternalInterface`, since ids are rooted at the enclosing + `package_a` package rather than the `@startuml` name. See + `interface_example_lib` and `interface_example_link_rendered_test` in + [`bazel/rules/rules_score/test/BUILD`](../../../bazel/rules/rules_score/test/BUILD). +- **Pure class-diagram linking** — `class_overview.puml` references + `AuditTrail` as a bare, member-less class, and `class_detail.puml` defines + it (it has a method, making it the elaboration site). See + `class_example_lib` and `class_example_link_rendered_test` in the same + `BUILD` file. + +The following three scenarios each cover a full, real-world "chain" from the +*Role detection algorithm* — a reference in one architectural view and the +matching definition in another — and are wired up the same way: + +- **Interface in the static architecture links to the public API** — + `public_api_overview.puml` (a component diagram, `static` attribute) shows a + unit bound to `PublicInterface` via a `-(` port, and `public_api_detail.puml` + (passed via architectural_design's `public_api` attribute, which also feeds + FMEA/safety-analysis traceability) defines it. Both share the FQN + `package_pub.PublicInterface`. See `public_api_example_lib` and + `public_api_example_link_rendered_test` in + [`bazel/rules/rules_score/test/BUILD`](../../../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 + diagram naming a class after the same unit, nested in the matching + namespace) defines it. Both share the FQN `package_u.component_u.unit_one`, + since component-diagram ids and class-diagram namespace FQNs use the same + dot-joined scheme. See `unit_example_lib` and + `unit_example_link_rendered_test` in the same `BUILD` file. +- **Dynamic architecture participant links to its own class diagram, with a + shared public API** — `dynamic_overview.puml` (a sequence diagram, `dynamic` + attribute) shows participants `Dispatcher` and `Worker` — both references, + since sequence diagrams have no defines. `dynamic_component_detail.puml` (a + component diagram, `static` attribute) shows both as leaf units (also + references, since neither has children) sharing a `TaskInterface` binding + (so the diagrams also pass the architectural-design consistency validator). + Each unit is elaborated by its own class diagram + (`dynamic_dispatcher_class.puml`, `dynamic_worker_class.puml`, also in + `static`), which is where the click ultimately lands. `TaskInterface` itself + is only ever a reference in the component diagram; its definition lives in + the public API (`dynamic_public_api_detail.puml`, via the `public_api` + attribute), exactly like the public-API scenario above. See + `dynamic_example_lib`, `dynamic_example_link_rendered_test`, + `dynamic_dispatcher_class_link_rendered_test`, + `dynamic_worker_class_link_rendered_test` and + `dynamic_public_api_link_rendered_test` in the same `BUILD` file. + + **Limitation: clicking on individual *messages* in a sequence diagram (e.g. + `Assign`/`Done`) is not currently supported**, for two independent reasons: + 1. `sequence_model_to_idmap()` in `puml_idmap/src/lib.rs` only extracts + `caller`/`callee` (participant names) into `references`. The resolved + `SequenceTree` (`tools/metamodel/sequence/sequence_logic.rs`) *does* + carry the message text (`Interaction.method`, `Return.return_content`), + but the idmap converter never reads those fields, so no reference is + ever produced for a message label today. + 2. Even if (1) were fixed, this extension's injection mechanism + (`_inject_links_into_uml`) only knows how to append `url of is + [[url]]` directives before `@enduml` — a PlantUML feature that attaches + a link to a *declared, aliased element* (participant/class/component), + not to an arrow's message text. Making a message clickable requires + PlantUML's other, unrelated hyperlink form — rewriting the message line + itself, e.g. `Dispatcher -> Worker : [[url Assign]]` — which needs a + different, source-location-aware rewriting path (to target the right + occurrence when a label repeats) that doesn't exist yet. diff --git a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py index 7317a052..51078ab6 100644 --- a/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py +++ b/plantuml/sphinx/clickable_plantuml/clickable_plantuml.py @@ -10,27 +10,73 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Sphinx extension to make PlantUML diagrams clickable.""" +"""Sphinx extension to make PlantUML diagrams clickable. + +Design overview +--------------- +Link data is derived from ``.idmap.json`` sidecar files produced by the +PlantUML parser (``puml_cli --idmap-output-dir ...``). Each idmap file +records two roles for the elements in one ``.puml`` diagram: + +* **defines** – elements elaborated (given children / structure) in that + diagram. A component diagram that contains ``package Proxy { ... }`` is + the definition site of ``Proxy``. +* **references** – leaf mentions and relation endpoints. A top-level + ``[Proxy]`` box in an overview is a reference that should link to the + diagram that defines it. + +The matching algorithm: + +1. Build a *definition index*: ``{alias|id → [source_paths]}``. +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. +""" + +from __future__ import annotations import functools import json +import os import re -from pathlib import Path +import urllib.parse +from pathlib import Path, PurePosixPath from typing import Any from docutils import nodes from sphinx.application import Sphinx +from sphinx.errors import ExtensionError from sphinx.util import logging logger = logging.getLogger(__name__) -# Environment attribute names used by this extension. -_ENV_LINK_DATA = "clickable_plantuml_link_data" -# Stores {puml_basename: (docname, anchor_id_or_None)} +# --------------------------------------------------------------------------- +# Environment attribute names +# --------------------------------------------------------------------------- + +# {normalized_source_path: raw_idmap_dict} — loaded once in builder-inited. +_ENV_IDMAP_BY_SOURCE = "clickable_plantuml_idmap_by_source" +# {alias_or_id: [source_path, ...]} — definition index built in builder-inited. +_ENV_DEF_INDEX = "clickable_plantuml_def_index" +# {normalized_source_path: (docname, anchor_or_None)} — populated in doctree-read. _ENV_PUML_DOCNAMES = "clickable_plantuml_puml_docnames" +# Absolute prefix to strip from PlantUML node paths to obtain canonical source keys. +_ENV_WORKSPACE_OFFSET = "clickable_plantuml_workspace_offset" +# Frozen set of known canonical source keys for fast membership checks. +_ENV_SOURCE_KEYS = "clickable_plantuml_source_keys" +# {docname: count} of plantuml nodes with no resolvable idmap, per document — +# keyed by docname (like _ENV_PUML_DOCNAMES) so env-purge-doc/env-merge-info +# can correctly drop/merge counts instead of maintaining a single running +# total that double-counts on re-reads or under-merges across parallel workers. +_ENV_NO_IDMAP_NODE_COUNTS_BY_DOC = "clickable_plantuml_no_idmap_node_counts_by_doc" -# Characters allowed in PlantUML alias identifiers. -_ALIAS_SAFE_RE = re.compile(r"^[\w.]+$") +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- def _find_parent_section_id(node: nodes.Node) -> str | None: @@ -45,76 +91,248 @@ def _find_parent_section_id(node: nodes.Node) -> str | None: return None +def _normalize_source_path(raw: str) -> str: + """Normalise a source path to a forward-slash relative string.""" + return str(PurePosixPath(raw)).lstrip("/") + + +def _assert_canonical_source_key(source_key: str) -> None: + """Assert that *source_key* is a workspace-relative POSIX key.""" + if source_key != _normalize_source_path(source_key): + raise ValueError(f"non-canonical source key: {source_key!r}") + + +def _common_prefix_length(path_a: str, path_b: str) -> int: + """Return the number of shared path components between two canonical keys. + + Both inputs must be canonical workspace-relative POSIX keys to ensure + the proximity comparison is valid (never mixing staging paths with + canonical paths). + """ + _assert_canonical_source_key(path_a) + _assert_canonical_source_key(path_b) + parts_a = PurePosixPath(path_a).parts + parts_b = PurePosixPath(path_b).parts + count = 0 + for a, b in zip(parts_a, parts_b): + if a == b: + count += 1 + else: + break + return count + + +def _proximity_tiebreak(source: str, candidates: list[str]) -> str | None: + """Pick the candidate with the longest common prefix with *source*. + + All inputs are canonical workspace-relative POSIX keys (guaranteed by the + exact-matching in P0-1); the assertions guard that invariant so a staging + path can never sneak into the comparison. Returns ``None`` when two or + more candidates score equally (tie → no link). + """ + _assert_canonical_source_key(source) + best_candidate: str | None = None + best_score = -1 + has_tie = False + + for candidate in candidates: + _assert_canonical_source_key(candidate) + score = _common_prefix_length(source, candidate) + if score > best_score: + best_score = score + best_candidate = candidate + has_tie = False + elif score == best_score: + has_tie = True + + if has_tie or best_candidate is None: + return None + return best_candidate + + +def _resolve_definer( + alias: str, + fqn: str, + source_key: str, + definition_index: dict[str, list[str]], +) -> 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). + """ + _assert_canonical_source_key(source_key) + + def _candidates_for(key: str) -> list[str]: + raw = definition_index.get(key, []) + for candidate in raw: + _assert_canonical_source_key(candidate) + return [c for c in raw if c != source_key] + + candidates = _candidates_for(fqn) or _candidates_for(alias) + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + target = _proximity_tiebreak(source_key, candidates) + if target is None: + logger.warning( + "clickable_plantuml: ambiguous definition for '%s' in '%s'" + " — tied candidates %s; no link emitted", + alias, + source_key, + candidates, + ) + return target + + +def _build_target_url( + builder: Any, + output_format: str, + imagedir: str, + docname: str, + target_docname: str, + anchor: str | None, +) -> str: + """Build the link URL for a resolved definer diagram. + + In ``svg_obj`` mode the rendered SVG lives in the ``_images/`` directory, + so URLs inside the SVG must be relative to ``_images/`` rather than the + containing HTML page. For inline ``svg``/``png`` the SVG is embedded in + the page, so a page-relative URL is correct. The optional section + *anchor* is appended as a fragment. + """ + if output_format == "svg_obj": + target_uri = builder.get_target_uri(target_docname) + url = os.path.relpath(target_uri, imagedir).replace("\\", "/") + else: + url = builder.get_relative_uri(docname, target_docname) + if anchor: + url += f"#{anchor}" + return url + + +def _escape_plantuml_url(url: str) -> str: + """Percent-encode characters significant in PlantUML URL syntax. + + PlantUML terminates ``url of X is [[...]]`` at the first ``]]``. We also + encode ``[``, spaces, and other characters that would confuse the PlantUML + lexer. The fragment (after ``#``) is encoded separately to preserve it. + """ + # Characters that are safe to leave unencoded in a URL context. ``#`` is + # deliberately excluded here and reintroduced only as the single fragment + # separator, so literal hash payload is always encoded. + _SAFE = "/:?&=@!$'()*+,;-._~" + fragment_sep = url.find("#") + if fragment_sep != -1: + base = urllib.parse.quote(url[:fragment_sep], safe=_SAFE) + frag = urllib.parse.quote(url[fragment_sep + 1 :], safe="-._~") + # Keep a real fragment separator so generated SVG href remains valid. + return f"{base}#{frag}" + return urllib.parse.quote(url, safe=_SAFE) + + # --------------------------------------------------------------------------- -# JSON loading +# idmap loading # --------------------------------------------------------------------------- -def _load_link_mappings( - search_dir: str, - pattern: str = "*plantuml_links.json", -) -> dict[str, dict[str, Any]]: - """Return ``{source_file: {source_id: {target_file, ...}}}``.""" - link_data: dict[str, dict[str, Any]] = {} - for json_file in Path(search_dir).rglob(pattern): +def _load_idmap_files( + source_dir: Path, +) -> tuple[dict[str, Any], dict[str, list[str]]]: + """Scan *source_dir* for ``*.idmap.json`` and build the lookup indices. + + The canonical key is the workspace-relative POSIX path stored in each + idmap's ``source`` field (baked in by ``--source-name``). Matching is + exact — there is no basename fallback — so two same-basename diagrams in + different packages never mislink. + + Returns: + idmap_by_source: ``{canonical_source_key → raw idmap dict}`` + definition_index: ``{alias_or_fqn_id → [canonical_source_keys]}`` + + Raises: + ExtensionError: when two idmaps normalise to the same canonical key. + """ + idmap_by_source: dict[str, Any] = {} + definition_index: dict[str, list[str]] = {} + + for json_path in sorted(source_dir.rglob("*.idmap.json")): try: - json_data = json.loads(json_file.read_text(encoding="utf-8")) - if "links" not in json_data or not isinstance(json_data["links"], list): - logger.warning( - "Invalid format in %s: missing 'links' array", - json_file.name, - ) - continue - file_link_count = 0 - for link_entry in json_data["links"]: - source_file = link_entry.get("source_file") - source_id = link_entry.get("source_id") - target_file = link_entry.get("target_file") - if not (source_file and source_id and target_file): - continue - link_data.setdefault(source_file, {})[source_id] = { - "target_file": target_file, - "line": link_entry.get("source_line", 0), - "description": link_entry.get("description", ""), - } - file_link_count += 1 - logger.info( - "Loaded %d links from %s", - file_link_count, - json_file.relative_to(search_dir), - ) + data = json.loads(json_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to load %s: %s", json_file.name, exc) - return link_data + logger.warning("clickable_plantuml: failed to load %s: %s", json_path, exc) + continue + + raw_source = data.get("source", "") + if not raw_source: + logger.warning( + "clickable_plantuml: idmap %s missing 'source' field — skipped", + json_path.name, + ) + continue + + source_key = _normalize_source_path(raw_source) + _assert_canonical_source_key(source_key) + if source_key in idmap_by_source: + raise ExtensionError( + "clickable_plantuml: duplicate idmap source key " + f"'{source_key}' (from {json_path.name}); each diagram's " + "--source-name must be a unique workspace-relative path." + ) + idmap_by_source[source_key] = data + 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) -def _collect_link_data(source_dir: Path) -> dict[str, dict[str, Any]]: - """Load all ``*plantuml_links.json`` files from *source_dir*.""" - if source_dir.exists(): - return _load_link_mappings(str(source_dir)) - return {} + logger.info( + "clickable_plantuml: loaded %d idmap file(s), %d unique definition keys", + len(idmap_by_source), + len(definition_index), + ) + return idmap_by_source, definition_index # --------------------------------------------------------------------------- -# UML injection helper +# UML injection # --------------------------------------------------------------------------- +# Characters allowed in a PlantUML alias identifier. +_ALIAS_SAFE_RE = re.compile(r"^[\w.\-]+$") +# Matches the @enduml terminator line (used to inject url directives before it). +_ENDUML_RE = re.compile(r"^\s*@enduml\s*$", re.MULTILINE) + def _inject_links_into_uml(uml_content: str, links: dict[str, str]) -> str: """Append ``url of is [[url]]`` directives before ``@enduml``.""" if not links: return uml_content - safe_links = { - alias: url + + directives = [ + f"url of {alias} is [[{url}]]" for alias, url in links.items() - if _ALIAS_SAFE_RE.match(alias) and "]]" not in url - } - if not safe_links: + if _ALIAS_SAFE_RE.match(alias) + ] + if not directives: return uml_content - url_directives = "\n".join( - f"url of {alias} is [[{url}]]" for alias, url in safe_links.items() - ) - enduml_match = re.search(r"^\s*@enduml\s*$", uml_content, re.MULTILINE) + + url_directives = "\n".join(directives) + enduml_match = _ENDUML_RE.search(uml_content) if enduml_match: prefix = uml_content[: enduml_match.start()] if not prefix.endswith("\n"): @@ -124,7 +342,7 @@ def _inject_links_into_uml(uml_content: str, links: dict[str, str]) -> str: # --------------------------------------------------------------------------- -# Sphinx event handlers +# plantuml node class (cached import) # --------------------------------------------------------------------------- @@ -139,117 +357,318 @@ def _get_plantuml_node_class() -> type | None: return None +# --------------------------------------------------------------------------- +# Node filename normalisation +# --------------------------------------------------------------------------- + + +def _compute_workspace_offset(srcdir: str, source_keys: set[str]) -> str: + """Compute the absolute prefix used to derive canonical source keys. + + This runs once during ``builder-inited``. The returned prefix is removed + from absolute PlantUML node paths to obtain exact workspace-relative keys. + """ + srcdir_posix = PurePosixPath(os.path.normpath(srcdir)).as_posix() + best_parent = "" + + for key in source_keys: + _assert_canonical_source_key(key) + parent = str(PurePosixPath(key).parent) + if parent in ("", "."): + continue + if srcdir_posix == parent or srcdir_posix.endswith("/" + parent): + if len(parent) > len(best_parent): + best_parent = parent + + if not best_parent: + return srcdir_posix + if srcdir_posix == best_parent: + return srcdir_posix + return srcdir_posix[: -(len(best_parent) + 1)] + + +def _has_path_suffix(path_value: str, suffix_value: str) -> bool: + """Return whether *suffix_value*'s path components are a tail of *path_value*'s.""" + path_parts = PurePosixPath(path_value).parts + suffix_parts = PurePosixPath(suffix_value).parts + if len(path_parts) < len(suffix_parts): + return False + return path_parts[-len(suffix_parts) :] == suffix_parts + + +def _unique_suffix_source_key(path_value: str, source_keys: set[str]) -> str | None: + """Return the single canonical source key that is a path-suffix match, or ``None``. + + Used only as a safe fallback for symlinked/absolute staging layouts where + the exact workspace-offset prefix match fails. A match is only trusted + when it is *unique*: zero or multiple candidates return ``None`` rather + than guessing (safe over wrong), so this can never introduce the + basename-collision ambiguity the exact-match path is designed to avoid. + """ + canonical_path = _normalize_source_path(path_value) + if canonical_path in source_keys: + return canonical_path + matches = [ + key + for key in source_keys + if _has_path_suffix(canonical_path, key) or _has_path_suffix(key, canonical_path) + ] + if len(matches) == 1: + return matches[0] + return None + + +def _node_source_key( + node: nodes.Node, srcdir: str, workspace_offset: str, source_keys: set[str] +) -> str | None: + """Return the canonical workspace-relative key for a plantuml *node*. + + ``sphinxcontrib.plantuml`` stores the diagram location on the node as + ``incdir`` (directory relative to Sphinx's source root) plus ``filename`` + (bare basename). Resolution has two tiers, both deterministic: + + 1. **Exact match** – both the node path and ``workspace_offset`` are + resolved via ``os.path.realpath`` and the workspace-offset prefix is + stripped; the remainder must equal a canonical source key exactly. + 2. **Unique suffix match** – a safe fallback for symlinked/absolute + staging layouts (see [`_unique_suffix_source_key`][]): only used when + exactly one canonical source key shares a path suffix with the node's + path, never a best-effort guess among several candidates. + + There is no ambiguous/basename-overlap fallback: when neither tier yields + a unique answer, this returns ``None`` rather than guessing, so + same-basename diagrams in different packages never silently mislink. + + Returns ``None`` when the node carries no filename, or no tier above + resolves to a unique canonical key in ``source_keys``. + """ + filename: str = node.get("filename", "") + if not filename: + return None + srcdir = os.fspath(srcdir) + incdir: str = node.get("incdir", "") + node_abs = PurePosixPath( + os.path.normpath(os.path.join(srcdir, incdir, filename)) + ).as_posix() + + workspace_offset = PurePosixPath( + os.path.realpath(os.fspath(workspace_offset)) + ).as_posix() + node_abs_real = PurePosixPath(os.path.realpath(node_abs)).as_posix() + + workspace_offset = workspace_offset.rstrip("/") + if node_abs_real.startswith(workspace_offset + "/"): + source_key = _normalize_source_path(node_abs_real[len(workspace_offset) + 1 :]) + if source_key in source_keys: + _assert_canonical_source_key(source_key) + return source_key + + candidate_paths = list( + dict.fromkeys( + ( + node_abs, + node_abs_real, + os.path.normpath(os.path.join(incdir, filename)), + ) + ) + ) + for candidate_path in candidate_paths: + source_key = _unique_suffix_source_key(candidate_path, source_keys) + if source_key is not None: + _assert_canonical_source_key(source_key) + return source_key + + return None + + +# --------------------------------------------------------------------------- +# Sphinx event handlers +# --------------------------------------------------------------------------- + + def on_builder_inited(app: Sphinx) -> None: - """Load JSON link data once, before any documents are read.""" + """Load idmap files and build the definition index once.""" if app.builder.format != "html": return source_dir = Path(app.srcdir) - link_data = _collect_link_data(source_dir) - if not link_data: - logger.info("clickable_plantuml: no link mappings found") + if not source_dir.exists(): + logger.info("clickable_plantuml: srcdir does not exist — no idmaps loaded") return - # Normalise keys to basenames for consistent lookup. - normalized = {Path(k).name: v for k, v in link_data.items()} - setattr(app.env, _ENV_LINK_DATA, normalized) + idmap_by_source, definition_index = _load_idmap_files(source_dir) + if not idmap_by_source: + logger.info("clickable_plantuml: no *.idmap.json files found") + return - logger.info( - "clickable_plantuml: loaded links for %d source file(s)", len(normalized) - ) + workspace_offset = _compute_workspace_offset(app.srcdir, set(idmap_by_source)) + setattr(app.env, _ENV_WORKSPACE_OFFSET, workspace_offset) + setattr(app.env, _ENV_IDMAP_BY_SOURCE, idmap_by_source) + setattr(app.env, _ENV_DEF_INDEX, definition_index) + setattr(app.env, _ENV_SOURCE_KEYS, frozenset(idmap_by_source)) + setattr(app.env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {}) def on_doctree_read(app: Sphinx, doctree: nodes.document) -> None: - """Record which docname (and section anchor) contains which ``.puml`` diagram. + """Record which docname (and section anchor) contains which diagram. - Traverses the parsed doctree. - The mapping is stored in ``app.env`` and consumed during ``doctree-resolved``. + Each diagram is registered under its canonical workspace-relative key (the + idmap ``source`` matched to the node's absolute path), which is directly + comparable to the idmap ``source`` field. """ PlantumlNode = _get_plantuml_node_class() if PlantumlNode is None: return + if app.builder.format != "html": + return + + idmap_by_source: dict[str, Any] = getattr(app.env, _ENV_IDMAP_BY_SOURCE, {}) + if not idmap_by_source: + return + + source_keys: frozenset[str] = getattr( + app.env, _ENV_SOURCE_KEYS, frozenset(idmap_by_source) + ) + workspace_offset: str = getattr(app.env, _ENV_WORKSPACE_OFFSET, app.srcdir) puml_docnames: dict[str, tuple[str, str | None]] = getattr( app.env, _ENV_PUML_DOCNAMES, {} ) + no_idmap_counts_by_doc: dict[str, int] = getattr( + app.env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {} + ) - for node in doctree.traverse(PlantumlNode): - filename = Path(node.get("filename", "")).name - if not filename: + # doctree-read fires once per (re-)read of this docname; recompute this + # docname's count from scratch rather than accumulating onto a running + # total, so a rebuild doesn't double-count nodes from a previous read. + doc_no_idmap_count = 0 + + for node in doctree.findall(PlantumlNode): + key = _node_source_key(node, app.srcdir, workspace_offset, source_keys) + if not key: + logger.debug( + "clickable_plantuml: plantuml node in '%s' has no resolvable" + " source path — skipped", + app.env.docname, + ) + doc_no_idmap_count += 1 continue - if filename in puml_docnames: + if key in puml_docnames and puml_docnames[key][0] != app.env.docname: logger.warning( - "clickable_plantuml: diagram '%s' found in both '%s' and '%s' " - "(basename collision — last wins)", - filename, - puml_docnames[filename][0], + "clickable_plantuml: diagram '%s' found in both '%s' and '%s'" + " — last wins (path collision; check idmap source fields)", + key, + puml_docnames[key][0], app.env.docname, ) anchor = _find_parent_section_id(node) - puml_docnames[filename] = (app.env.docname, anchor) + puml_docnames[key] = (app.env.docname, anchor) + + if doc_no_idmap_count: + no_idmap_counts_by_doc[app.env.docname] = doc_no_idmap_count + else: + no_idmap_counts_by_doc.pop(app.env.docname, None) setattr(app.env, _ENV_PUML_DOCNAMES, puml_docnames) + setattr(app.env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, no_idmap_counts_by_doc) -def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None: - """Inject ``url of is [[url]]`` into plantuml nodes before rendering. +def on_build_finished(app: Sphinx, exception: Exception | None) -> None: + """Emit an aggregate info log for non-idmap-backed plantuml nodes.""" + if app.builder.format != "html" or exception is not None: + return + + no_idmap_counts_by_doc: dict[str, int] = getattr( + app.env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {} + ) + total = sum(no_idmap_counts_by_doc.values()) + if total: + logger.info( + "clickable_plantuml: %d plantuml nodes had no idmap (not linkable)", + total, + ) + - For each diagram, resolves target ``.puml`` references to the docname that - contains the target diagram and uses ``app.builder.get_relative_uri`` to - produce correct relative URLs. +def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None: + """Inject ``url of is [[url]]`` into plantuml nodes. + + Resolves each reference in the diagram's idmap to its definer diagram, + applies a proximity tiebreak on ambiguity, and builds a URL whose base + depends on the configured ``plantuml_output_format``: + + * ``svg_obj`` – the rendered SVG lives in the ``_images/`` directory and is + embedded via ````; ```` targets inside the SVG resolve + relative to ``_images/``, so the URL is + ``os.path.relpath(target_uri, imagedir)``. + * inline ``svg`` / ``png`` – the link resolves relative to the containing + HTML page, so the URL is + ``app.builder.get_relative_uri(docname, target_docname)``. """ - link_data: dict[str, dict[str, Any]] = getattr(app.env, _ENV_LINK_DATA, {}) - if app.builder.format != "html" or not link_data: + 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, {}) + if app.builder.format != "html" or not idmap_by_source: return PlantumlNode = _get_plantuml_node_class() if PlantumlNode is None: return + source_keys: frozenset[str] = getattr( + app.env, _ENV_SOURCE_KEYS, frozenset(idmap_by_source) + ) + workspace_offset: str = getattr(app.env, _ENV_WORKSPACE_OFFSET, app.srcdir) puml_docnames: dict[str, tuple[str, str | None]] = getattr( app.env, _ENV_PUML_DOCNAMES, {} ) - absolute_url_prefixes = ("http://", "https://", "/") + + # Loop-invariant for the whole build: resolve once instead of per reference. + output_format = getattr(app.config, "plantuml_output_format", "png") + imagedir = getattr(app.builder, "imagedir", "_images") modified_count = 0 - for node in doctree.traverse(PlantumlNode): - diagram_filename = Path(node.get("filename", "")).name - alias_map: dict[str, Any] = link_data.get(diagram_filename, {}) - if not alias_map: + for node in doctree.findall(PlantumlNode): + source_key = _node_source_key(node, app.srcdir, workspace_offset, source_keys) + if not source_key: + continue + + idmap = idmap_by_source.get(source_key) + if idmap is None: continue resolved_links: dict[str, str] = {} - for alias, info in alias_map.items(): - target_file: str = info["target_file"] - - if target_file.endswith(".puml"): - target_basename = Path(target_file).name - target_info = puml_docnames.get(target_basename) - if target_info is not None: - target_docname, target_anchor = target_info - # SVG files are stored in _images/ (one level below the - # HTML output root). Using get_relative_uri() would give a - # page-to-page relative URL, but that path is interpreted - # relative to the SVG file, not the parent HTML page — - # causing the browser to open the raw SVG. Instead, build - # the URL relative to _images/ by prepending "../" to the - # root-relative page URI returned by get_target_uri(). - page_uri = app.builder.get_target_uri(target_docname) - url = f"../{page_uri}" - if target_anchor: - url += f"#{target_anchor}" - resolved_links[alias] = url - else: - logger.debug( - "clickable_plantuml: target diagram '%s' for alias " - "'%s' not found in any document", - target_file, - alias, - ) - elif target_file.startswith(absolute_url_prefixes): - resolved_links[alias] = target_file - else: - resolved_links[alias] = target_file + seen_aliases_in_node: set[str] = set() + for ref in idmap.get("references", []): + alias: str = ref.get("alias", "") + fqn: str = ref.get("id", alias) + if not alias or alias in seen_aliases_in_node: + continue + + target_source = _resolve_definer(alias, fqn, source_key, definition_index) + if target_source is None: + continue + + target_info = puml_docnames.get(target_source) + if target_info is None: + logger.debug( + "clickable_plantuml: definer '%s' for alias '%s' not" + " found in any document — skipping", + target_source, + alias, + ) + continue + + target_docname, target_anchor = target_info + url = _build_target_url( + app.builder, + output_format, + imagedir, + docname, + target_docname, + target_anchor, + ) + + resolved_links[alias] = _escape_plantuml_url(url) + seen_aliases_in_node.add(alias) if resolved_links: node["uml"] = _inject_links_into_uml(node.get("uml", ""), resolved_links) @@ -272,6 +691,11 @@ def on_env_purge_doc(app: Sphinx, env: Any, docname: str) -> None: for k in keys_to_remove: del puml_docnames[k] + no_idmap_counts_by_doc: dict[str, int] = getattr( + env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {} + ) + no_idmap_counts_by_doc.pop(docname, None) + def on_env_merge_info(app: Sphinx, env: Any, docnames: set[str], other: Any) -> None: """Merge diagram location data from parallel read sub-processes.""" @@ -284,6 +708,15 @@ def on_env_merge_info(app: Sphinx, env: Any, docnames: set[str], other: Any) -> puml_docnames.update(other_map) setattr(env, _ENV_PUML_DOCNAMES, puml_docnames) + no_idmap_counts_by_doc: dict[str, int] = getattr( + env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {} + ) + other_no_idmap_counts_by_doc: dict[str, int] = getattr( + other, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {} + ) + no_idmap_counts_by_doc.update(other_no_idmap_counts_by_doc) + setattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, no_idmap_counts_by_doc) + def setup(app: Sphinx) -> dict[str, Any]: """Register the extension with Sphinx.""" @@ -292,9 +725,10 @@ def setup(app: Sphinx) -> dict[str, Any]: app.connect("doctree-resolved", on_doctree_resolved) app.connect("env-purge-doc", on_env_purge_doc) app.connect("env-merge-info", on_env_merge_info) + app.connect("build-finished", on_build_finished) return { - "version": "4.0", + "version": "5.0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/plantuml/sphinx/clickable_plantuml/docs/assets/clickable_plantuml_pipeline.puml b/plantuml/sphinx/clickable_plantuml/docs/assets/clickable_plantuml_pipeline.puml new file mode 100644 index 00000000..060fb9f9 --- /dev/null +++ b/plantuml/sphinx/clickable_plantuml/docs/assets/clickable_plantuml_pipeline.puml @@ -0,0 +1,68 @@ +' ******************************************************************************* +' 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 clickable_plantuml_pipeline +title From .puml source to a clickable diagram - pipeline steps and artifacts + +skinparam shadowing false +skinparam defaultTextAlignment center +skinparam componentStyle rectangle + +skinparam component { + BackgroundColor<> #FFF8E1 + BorderColor<> #F9A825 + BackgroundColor<> #F5F5F5 + BorderColor<> #757575 +} +skinparam artifact { + BackgroundColor<> #E3F2FD + BorderColor<> #1565C0 +} + +artifact ".puml source file\n(path: workspace-relative,\nfrom puml_file.short_path)" as PumlSrc <> + +component "puml_cli: parse & resolve" as Step1 <> { + component "puml_parser\n(pest grammar -> AST)" as Parser + component "puml_resolver\n(alias/reference\nresolution)" as Resolver + Parser -right-> Resolver +} + +artifact "resolved diagram model\n(class | component | sequence |\nactivity | FTA)" as Model <> + +component "puml_idmap: role detection" as Step2 <> { + component "classify each element:\ndefine (elaborated here) or\nreference (leaf / relation endpoint)" as RoleDetection +} + +artifact ".idmap.json\n(source, defines[], references[])" as IdMap <> + +component "clickable_plantuml: Sphinx extension" as Step3 <> { + component "builder-inited\n(build global definition index)" as BuilderInited + component "doctree-resolved\n(resolve + inject\n\"url of X is [[link]]\")" as DoctreeResolved + BuilderInited -right-> DoctreeResolved +} + +component "sphinxcontrib-plantuml" as Step4 <> { + component "renders uml text -> SVG/PNG" as Render +} + +artifact "clickable SVG/PNG\nin the built docs" as Output <> + +PumlSrc --> Step1 : consumed by +Step1 --> Model : produces +Model --> Step2 : consumed by +Step2 --> IdMap : produces\n(one per diagram) +IdMap --> Step3 : consumed by\n(every *.idmap.json\nin the source tree) +Step3 --> Step4 : hands off\nannotated uml to +Step4 --> Output : produces + +@enduml diff --git a/plantuml/linker/BUILD b/plantuml/sphinx/clickable_plantuml/tests/BUILD similarity index 53% rename from plantuml/linker/BUILD rename to plantuml/sphinx/clickable_plantuml/tests/BUILD index 38337849..bd804fef 100644 --- a/plantuml/linker/BUILD +++ b/plantuml/sphinx/clickable_plantuml/tests/BUILD @@ -10,25 +10,15 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("@pip_rules_score//:requirements.bzl", "requirement") +load("@score_tooling//python_basics:defs.bzl", "score_py_pytest") -rust_binary( - name = "linker", - srcs = ["src/main.rs"], - crate_root = "src/main.rs", - visibility = ["//visibility:public"], +score_py_pytest( + name = "test_clickable_plantuml", + srcs = ["test_clickable_plantuml.py"], deps = [ - "//tools/serialization/flatbuffers/component:component_fbs", - "@crates//:clap", - "@crates//:env_logger", - "@crates//:flatbuffers", - "@crates//:log", - "@crates//:serde", - "@crates//:serde_json", + "//plantuml/sphinx/clickable_plantuml", + requirement("docutils"), + requirement("sphinx"), ], ) - -rust_test( - name = "linker_test", - crate = ":linker", -) diff --git a/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py b/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py new file mode 100644 index 00000000..eb3de5fc --- /dev/null +++ b/plantuml/sphinx/clickable_plantuml/tests/test_clickable_plantuml.py @@ -0,0 +1,577 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Unit tests for the clickable_plantuml Sphinx extension helpers.""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from docutils import nodes +from sphinx.errors import ExtensionError + +import clickable_plantuml as clickable_plantuml_module +from clickable_plantuml import ( + _ENV_DEF_INDEX, + _ENV_IDMAP_BY_SOURCE, + _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, + _ENV_PUML_DOCNAMES, + _ENV_SOURCE_KEYS, + _ENV_WORKSPACE_OFFSET, + _build_target_url, + _common_prefix_length, + _escape_plantuml_url, + _inject_links_into_uml, + _load_idmap_files, + _node_source_key, + on_build_finished, + on_doctree_read, + on_doctree_resolved, + on_env_merge_info, + on_env_purge_doc, + _resolve_definer, +) + + +def _write_idmap( + directory: Path, + name: str, + source: str, + defines: list[dict[str, str]] | None = None, + references: list[dict[str, str]] | None = None, +) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / name).write_text( + json.dumps( + { + "source": source, + "defines": defines or [], + "references": references or [], + } + ), + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# index build +# --------------------------------------------------------------------------- + + +def test_load_idmap_builds_source_and_definition_indices(tmp_path: Path) -> None: + _write_idmap( + tmp_path / "a", + "proxy.idmap.json", + "/pkg/a/proxy.puml", + defines=[{"alias": "Proxy", "id": "pkg.Proxy"}], + ) + _write_idmap( + tmp_path / "b", + "overview.idmap.json", + "pkg/b/overview.puml", + references=[{"alias": "Proxy", "id": "pkg.Proxy"}], + ) + + idmap_by_source, definition_index = _load_idmap_files(tmp_path) + + 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"] + + +def test_same_basename_in_different_dirs_are_distinct_keys(tmp_path: Path) -> None: + _write_idmap(tmp_path / "a", "proxy.idmap.json", "pkg/a/proxy.puml") + _write_idmap(tmp_path / "b", "proxy.idmap.json", "pkg/b/proxy.puml") + + idmap_by_source, _ = _load_idmap_files(tmp_path) + + # No basename collapse: two proxy.puml remain independently keyed. + assert set(idmap_by_source) == {"pkg/a/proxy.puml", "pkg/b/proxy.puml"} + + +def test_duplicate_canonical_key_raises_build_error(tmp_path: Path) -> None: + _write_idmap(tmp_path / "a", "one.idmap.json", "pkg/dup.puml") + _write_idmap(tmp_path / "b", "two.idmap.json", "pkg/dup.puml") + + with pytest.raises(ExtensionError, match="duplicate idmap source key"): + _load_idmap_files(tmp_path) + + +# --------------------------------------------------------------------------- +# node source-key resolution (strict exact matching) +# --------------------------------------------------------------------------- + + +def test_node_source_key_same_basename_stays_distinct() -> None: + node = { + "filename": "proxy.puml", + "incdir": "pkg/b", + } + + key = _node_source_key( + node, + "/workspace", + "/workspace", + {"pkg/a/proxy.puml", "pkg/b/proxy.puml"}, + ) + + assert key == "pkg/b/proxy.puml" + + +@pytest.mark.parametrize( + "node", + [ + {"filename": "stray.puml", "incdir": "pkg/b"}, + {"incdir": "pkg/b"}, + ], +) +def test_node_source_key_returns_none_for_unresolvable_nodes( + node: dict[str, str], +) -> None: + key = _node_source_key( + node, + "/workspace", + "/workspace", + {"pkg/a/proxy.puml"}, + ) + + assert key is None + + +def test_node_source_key_resolves_symlinked_srcdir(tmp_path: Path) -> None: + real_root = tmp_path / "real_root" + (real_root / "pkg").mkdir(parents=True) + (real_root / "pkg" / "proxy.puml").write_text( + "@startuml\n@enduml\n", encoding="utf-8" + ) + + srcdir_link = tmp_path / "srcdir_link" + os.symlink(real_root, srcdir_link) + + node = { + "filename": "proxy.puml", + "incdir": "pkg", + } + + key = _node_source_key( + node, + str(srcdir_link), + str(real_root), + {"pkg/proxy.puml"}, + ) + + assert key == "pkg/proxy.puml" + + +def test_node_source_key_resolves_unique_suffix_match() -> None: + node = { + "filename": "static_design.puml", + "incdir": "architectural_design", + } + + key = _node_source_key( + node, + "/workspace/synthetic_src", + "/workspace/synthetic_src", + { + "bazel/rules/rules_score/examples/seooc/safety_software_seooc_example_index/architectural_design/static_design.puml", + }, + ) + + assert ( + key + == "bazel/rules/rules_score/examples/seooc/safety_software_seooc_example_index/architectural_design/static_design.puml" + ) + + +def test_node_source_key_resolves_absolute_incdir_unique_suffix() -> None: + node = { + "filename": "static_design.puml", + "incdir": "/tmp/sandbox/work/src/architectural_design", + } + + key = _node_source_key( + node, + "/workspace/synthetic_src", + "/workspace/synthetic_src", + { + "architectural_design/static_design.puml", + }, + ) + + assert key == "architectural_design/static_design.puml" + + +def test_node_source_key_returns_none_for_inconclusive_basename_overlap() -> None: + # Two candidates share the node's basename but neither is a unique path + # suffix match; the removed "best overlap" heuristic used to guess one of + # them, but that contradicted the "safe over wrong" contract, so this + # must now return None rather than silently picking a candidate. + node = { + "filename": "static_design.puml", + "incdir": "architectural_design", + } + + key = _node_source_key( + node, + "/workspace/seooc/safety_software_seooc_example_doc", + "/workspace/seooc/safety_software_seooc_example_doc", + { + "bazel/rules/rules_score/examples/seooc/design/static_design.puml", + "bazel/rules/rules_score/examples/some_other_library/static_design.puml", + }, + ) + + assert key is None + + +def test_node_source_key_returns_none_for_tied_basename_overlap() -> None: + node = { + "filename": "same.puml", + "incdir": "x", + } + + key = _node_source_key( + node, + "/workspace/root", + "/workspace/root", + { + "a/same.puml", + "b/same.puml", + }, + ) + + assert key is None + + +def test_doctree_read_non_idmap_backed_node_is_debug_only( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakePlantumlNode(nodes.Element): + pass + + monkeypatch.setattr( + clickable_plantuml_module, + "_get_plantuml_node_class", + lambda: _FakePlantumlNode, + ) + + node = _FakePlantumlNode() + node["filename"] = "stray.puml" + node["incdir"] = "pkg/b" + doctree = nodes.document("", "") + doctree += node + + env = SimpleNamespace(docname="index") + setattr(env, _ENV_IDMAP_BY_SOURCE, {"pkg/a/proxy.puml": {}}) + setattr(env, _ENV_SOURCE_KEYS, frozenset({"pkg/a/proxy.puml"})) + setattr(env, _ENV_WORKSPACE_OFFSET, "/workspace") + setattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {}) + + app = SimpleNamespace( + builder=SimpleNamespace(format="html"), + srcdir="/workspace", + env=env, + ) + + caplog.set_level(logging.DEBUG) + on_doctree_read(app, doctree) + + assert not any(record.levelno == logging.WARNING for record in caplog.records) + assert any( + "has no resolvable source path" in record.getMessage() + and record.levelno == logging.DEBUG + for record in caplog.records + ) + assert getattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC) == {"index": 1} + + +def test_build_finished_logs_aggregate_no_idmap_count( + caplog: pytest.LogCaptureFixture, +) -> None: + env = SimpleNamespace() + setattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {"a": 2, "b": 1}) + app = SimpleNamespace(builder=SimpleNamespace(format="html"), env=env) + + caplog.set_level(logging.INFO) + on_build_finished(app, None) + + assert "3 plantuml nodes had no idmap (not linkable)" in caplog.text + + +def test_env_purge_doc_removes_no_idmap_count_for_purged_doc() -> None: + env = SimpleNamespace() + setattr(env, _ENV_PUML_DOCNAMES, {"pkg/a.puml": ("stale", None)}) + setattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {"stale": 2, "keep": 5}) + + on_env_purge_doc(SimpleNamespace(), env, "stale") + + assert getattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC) == {"keep": 5} + assert getattr(env, _ENV_PUML_DOCNAMES) == {} + + +def test_env_merge_info_merges_no_idmap_counts_from_parallel_workers() -> None: + env = SimpleNamespace() + setattr(env, _ENV_PUML_DOCNAMES, {}) + setattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {"a": 1}) + + other = SimpleNamespace() + setattr(other, _ENV_PUML_DOCNAMES, {}) + setattr(other, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC, {"b": 4}) + + on_env_merge_info(SimpleNamespace(), env, {"b"}, other) + + assert getattr(env, _ENV_NO_IDMAP_NODE_COUNTS_BY_DOC) == {"a": 1, "b": 4} + + +def test_doctree_resolved_injects_link_directives( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakePlantumlNode(nodes.Element): + pass + + monkeypatch.setattr( + clickable_plantuml_module, + "_get_plantuml_node_class", + lambda: _FakePlantumlNode, + ) + + node = _FakePlantumlNode() + node["filename"] = "overview.puml" + node["incdir"] = "pkg" + node["uml"] = "@startuml\n[Proxy]\n@enduml\n" + + doctree = nodes.document("", "") + doctree += node + + env = SimpleNamespace() + setattr( + env, + _ENV_IDMAP_BY_SOURCE, + { + "pkg/overview.puml": { + "references": [{"alias": "Proxy", "id": "pkg.Proxy"}], + } + }, + ) + setattr(env, _ENV_DEF_INDEX, {"pkg.Proxy": ["pkg/proxy.puml"]}) + 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")} + ) + + class _FakeBuilder: + format = "html" + imagedir = "_images" + + def get_relative_uri(self, from_docname: str, to_docname: str) -> str: + _ = from_docname + return f"{to_docname}.html" + + app = SimpleNamespace( + builder=_FakeBuilder(), + config=SimpleNamespace(plantuml_output_format="svg"), + srcdir="/workspace", + env=env, + ) + + on_doctree_resolved(app, doctree, "index") + + assert "url of Proxy is [[design/proxy.html#proxy-section]]" in node["uml"] + + +# --------------------------------------------------------------------------- +# reference resolution and tiebreak behavior +# --------------------------------------------------------------------------- + + +def test_resolve_definer_prefers_fqn_over_alias() -> None: + definition_index = { + "Proxy": ["pkg/alias_hit.puml"], + "pkg.Proxy": ["pkg/fqn_hit.puml"], + } + + target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index) + + assert target == "pkg/fqn_hit.puml" + + +def test_resolve_definer_falls_back_to_alias_when_fqn_missing() -> None: + definition_index = { + "Proxy": ["pkg/alias_hit.puml"], + } + + target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index) + + assert target == "pkg/alias_hit.puml" + + +def test_resolve_definer_falls_back_to_alias_when_fqn_hit_is_only_self() -> None: + # The FQN index has an entry, but it's *only* a self-link (the diagram + # 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"], + } + + target = _resolve_definer("Proxy", "pkg.Proxy", "pkg/src.puml", definition_index) + + assert target == "pkg/alias_hit.puml" + + +def test_resolve_definer_skips_self_link() -> None: + definition_index = { + "Proxy": ["pkg/src.puml"], + } + + target = _resolve_definer("Proxy", "Proxy", "pkg/src.puml", definition_index) + + assert target is None + + +def test_resolve_definer_tie_returns_none_and_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + definition_index = { + "Proxy": ["a/one.puml", "b/two.puml"], + } + + caplog.set_level(logging.WARNING) + target = _resolve_definer("Proxy", "Proxy", "pkg/src.puml", definition_index) + + assert target is None + assert "ambiguous definition" in caplog.text + + +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") + + +# --------------------------------------------------------------------------- +# URL building and escaping +# --------------------------------------------------------------------------- + + +class _FakeBuilder: + def get_target_uri(self, docname: str) -> str: + return f"{docname}.html" + + def get_relative_uri(self, from_docname: str, to_docname: str) -> str: + _ = from_docname + return f"{to_docname}.html" + + +@pytest.mark.parametrize( + ("output_format", "expected"), + [ + ("svg_obj", "../design/proxy.html"), + ("svg", "design/proxy.html"), + ], +) +def test_build_target_url_respects_output_mode( + output_format: str, expected: str +) -> None: + url = _build_target_url( + _FakeBuilder(), + output_format, + "_images", + "index", + "design/proxy", + None, + ) + + assert url == expected + + +def test_build_target_url_appends_anchor() -> None: + url = _build_target_url( + _FakeBuilder(), + "svg", + "_images", + "index", + "design/proxy", + "section-1", + ) + + assert url == "design/proxy.html#section-1" + + +@pytest.mark.parametrize( + ("raw", "expected_fragment_count", "must_contain", "must_not_contain"), + [ + ("design/proxy.html#my-section", 1, ["#my-section"], []), + ("a#b#c", 1, ["%23"], []), + ("design/has]]end.html", 0, ["%5D"], ["]]"]), + ("path/a b[c].html", 0, [], [" ", "[", "]"]), + ], +) +def test_escape_plantuml_url_core_safety( + raw: str, + expected_fragment_count: int, + must_contain: list[str], + must_not_contain: list[str], +) -> None: + escaped = _escape_plantuml_url(raw) + + assert escaped.count("#") == expected_fragment_count + for token in must_contain: + assert token in escaped + for token in must_not_contain: + assert token not in escaped + + +def test_inject_links_inserts_directive_before_enduml() -> None: + uml = "@startuml\n[A] --> [B]\n@enduml\n" + + result = _inject_links_into_uml(uml, {"A": "a.html"}) + + assert "url of A is [[a.html]]" in result + assert result.index("url of A") < result.index("@enduml") + + +def test_inject_links_skips_unsafe_alias() -> None: + uml = "@startuml\n@enduml\n" + + result = _inject_links_into_uml(uml, {"bad alias!": "x.html"}) + + assert "url of" not in result + + +def test_inject_links_appends_directives_when_enduml_missing() -> None: + uml = "@startuml\n[A] --> [B]\n" + + result = _inject_links_into_uml(uml, {"A": "a.html"}) + + assert result.endswith("url of A is [[a.html]]") + + +def test_one_url_per_alias_dedup_contract() -> None: + # The resolved_links dict in on_doctree_resolved keys by alias, so an + # alias maps to exactly one URL (last write wins). Emulate that contract. + resolved_links: dict[str, str] = {} + resolved_links["A"] = "first.html" + resolved_links["A"] = "second.html" + + uml = _inject_links_into_uml("@startuml\n@enduml\n", resolved_links) + + assert uml.count("url of A is") == 1 + assert "[[second.html]]" in uml