diff --git a/.github/skills/score-architecture/SKILL.md b/.github/skills/score-architecture/SKILL.md index 80d3a952..b50edef8 100644 --- a/.github/skills/score-architecture/SKILL.md +++ b/.github/skills/score-architecture/SKILL.md @@ -256,7 +256,7 @@ cross-checked against each other and against the implementation. |------|-----------|-----------------------|---------| | **Static** | `static` | Bazel `dependable_element`/`component`/`unit` tree; internal API; public API; sequences | Structural component/unit tree — the anchor for all other checks | | **Dynamic** (sequence) | `dynamic` | Static component diagram; internal API | Unit interactions — participant names and cross-unit calls | -| **Public API** | `public_api` | Static diagram (top-level interfaces bound from the SEooC) | Interfaces the SEooC exposes; also feeds `FailureMode.interface` traceability | +| **Public API** | `public_api` | Static diagram (top-level interfaces bound from the SEooC) | Interfaces the SEooC exposes (class diagrams only); each public method becomes an `ApiMethod` TRLC record that `FailureMode.interface` references | | **Internal API** | `internal_api` | Static component diagram; sequences | Interfaces between components/units inside the SEooC | Real example diagrams (verbatim, from [`examples/seooc/design/`](../../../bazel/rules/rules_score/examples/seooc/design)): @@ -299,6 +299,9 @@ namespace safety_software_seooc_example { - **Public API** interfaces must be top-level in the static diagram and bound from the `<>` (e.g. `safety_software_seooc_example -d- SampleLibraryAPI`); interfaces nested inside components/units are treated as *internal* API. +- **Public API records**: only public methods (all overloads) and free functions yield `ApiMethod` + records; attributes, enums, typedefs and non-public members yield none. `package` is required with + `public_api`; reports show the generated `public_api.trlc` as item location, not the `.puml`. - **Internal API** interfaces are modeled inside the owning element's `namespace` so their fully-qualified name reflects containment. @@ -386,6 +389,7 @@ One target bundles every diagram kind (from [`examples/seooc/design/BUILD`](../. ```starlark architectural_design( name = "sample_seooc_design", + package = "sample_seooc_api", # required with public_api; TRLC package of public_api.trlc static = ["static_design.puml", "overview_design.puml", "index.md"], dynamic = ["dynamic_design.puml"], public_api = ["public_api.puml", "public_api.rst"], diff --git a/.github/skills/score-safety-analysis/SKILL.md b/.github/skills/score-safety-analysis/SKILL.md index 858b592c..b48b7acb 100644 --- a/.github/skills/score-safety-analysis/SKILL.md +++ b/.github/skills/score-safety-analysis/SKILL.md @@ -73,14 +73,14 @@ aliases, BUILD wired, `trlc` clean. for each, apply the applicable fault models and ask *"can this occur, and would it violate a safety goal?"* Dismiss low-relevance models with a short rationale. -| Fault-model category | Example models | `GuideWord` labels | +| Fault-model category | Example models | `Guideword` labels | |----------------------|----------------|--------------------| | Message (send/receive) | not sent/received, corrupted, lost, unintended (`MF_01_*`) | `LossOfFunction`, `PartialFunction`, `Corrupted`, `UnintendedFunction`, `Wrong` | | Timing / duration | too late/early, boundary violated (`CO_01_*`) | `TooEarly`, `TooLate`, `DelayedFunction` | | Execution | wrong result, loss, arbitrary/incomplete (`EX_01_*`) | `Wrong`, `LossOfFunction`, `ExceedingFunction`, `ArbitraryExecution` | > **Clustering:** one `FailureMode` per *(interface, guide word)* effect — group methods sharing a -> root cause in the `interface` field; one root cause under two guide words → two records. +> root cause in the `interface` list; one root cause under two guide words → two records. **Step 2 — effect, then causes.** Write `failureeffect` from the **caller/system perspective** in worst-case terms relative to the safety goal. Then build the FTA top-down to **actionable root @@ -108,24 +108,30 @@ Package: match the existing package declaration in the folder structure ```trlc package import ScoreReq +import ScoreReq.FailureMode { - guideword = ScoreReq.GuideWord. + guidewords = [ScoreReq.Guideword.] description = "statement describing the expected behaviour" failureeffect = "What goes wrong for the caller / system" - potentialcause = "Root cause(s) from the FMEA" - interface = ".[, .]" + interface = [._] version = 1 safety = ScoreReq.Asil.B } ``` -**GuideWord enum values:** `LossOfFunction`, `PartialFunction`, `Corrupted`, `UnintendedFunction`, `TooEarly`, `TooLate`, `Wrong`, `DelayedFunction`, `ExceedingFunction`, `ArbitraryExecution` +`` is the `package` of the `architectural_design` target. The build generates one +`ScoreReq.ApiMethod` record per public method (all overloads) and per free function of its +`public_api` class diagrams, named `_` (`` = namespace-qualified +entity id, `.` → `_`, e.g. `ns_Interface_Send`); the +`failure_modes` target lists the `architectural_design` in `deps`. + +**Guideword enum values:** `LossOfFunction`, `PartialFunction`, `Corrupted`, `UnintendedFunction`, `TooEarly`, `TooLate`, `Wrong`, `DelayedFunction`, `ExceedingFunction`, `ArbitraryExecution` **Rules:** - One record per FailureMode, not per interface method. -- Same root cause spanning multiple guide words → separate records (trlc only allows one `guideword` per record). -- `interface` may list multiple methods as a comma-separated string when root cause is shared. +- Same root cause spanning multiple guide words → separate records. +- `interface` may list several `ApiMethod` records when the root cause is shared. ## Step 2 — Create FTA Diagrams (`fta_.puml`) @@ -197,10 +203,10 @@ The `@` of a measure pins the reviewed version; a version change of the **Rule:** every `$RootCause` alias must be listed in the `root_causes` of exactly one `RootCauseAssessment`, and every own `AoU` must be listed in the `measures` of at least one. This is checked by lobster in the traceability report of the enclosing `dependable_element` (the Root Cause Assessments level traces to Root Causes and Assumptions of Use; uncovered items are reported as `missing reference to Root Cause Assessments`, and fail `bazel test ` with `maturity = "release"`). The safety analysis is three rules: `failure_modes` (the `FailureMode` records), `fault_trees` (`srcs` = FTA diagrams, `deps` = the `failure_modes` targets; generates the `` root causes) and `safety_analysis` (`failure_modes`, `fault_trees`, `assessments` and `safety_measures`: `assumptions_of_use` / `component_requirements` targets the assessments list; only their own records are rendered, targets in their `deps` just resolve references; raw `.trlc` files in `assessments` may only hold `RootCauseAssessment` records, every failure mode a root cause references must be in `failure_modes`, and an assessment may only list measures of `safety_measures`). Every `assumptions_of_use` target passed as `safety_measures` is an own AoU of the enclosing `dependable_element` (derived, deduplicated by label, forwarded to dependees); one no `safety_analysis` lists is neither rendered nor forwarded: ```python -failure_modes(name = "fm", srcs = ["failure_modes.trlc"]) +failure_modes(name = "fm", srcs = ["failure_modes.trlc"], deps = [":arch"]) fault_trees(name = "ft", srcs = [":fta_files"], deps = [":fm"]) assumptions_of_use(name = "aous", srcs = ["aous.trlc"]) -safety_analysis(name = "sa", arch_design = ":arch", failure_modes = [":fm"], fault_trees = ":ft", +safety_analysis(name = "sa", failure_modes = [":fm"], fault_trees = ":ft", assessments = ["assessments.trlc"], safety_measures = [":aous"]) ``` @@ -228,7 +234,7 @@ Pre-existing RSL union-type errors (`expected identifier, encountered '['`) are **Traceability chain that must be complete:** ``` -FailureMode.interface → public_api interface name +FailureMode.interface → ApiMethod record (generated from the public_api class diagram) FailureMode record → $FailureMode fm1..fm8 arguments $RootCause alias → RootCauseAssessment.root_causes RootCauseAssessment → measures (AoU / CompReq, versioned) diff --git a/bazel/rules/rules_score/README.md b/bazel/rules/rules_score/README.md index 5d9ea873..74f9832b 100644 --- a/bazel/rules/rules_score/README.md +++ b/bazel/rules/rules_score/README.md @@ -110,7 +110,8 @@ that `:satisfies:` / `:derived_from:` resolve in. ```starlark architectural_design( name = "my_design", - static = ["class.puml", "component.puml"], + package = "my_design_api", + static = ["component.puml"], dynamic = ["sequence.puml"], public_api = ["api.puml"], ) @@ -118,13 +119,19 @@ 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 `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 +- a `public_api.trlc` — one `ScoreReq.ApiMethod` record per public method (all + overloads) and per free function of the `public_api` class diagrams (other + diagram kinds in `public_api` fail the build; attributes, enums, typedefs and + non-public members yield no record). The `package` attribute names its TRLC + package and is required with `public_api`. Provided as `TrlcProviderInfo`, so + `failure_modes(deps = [...])` can reference the records -Diagrams in `public_api` are classified separately so their lobster items flow -through `public_api_lobster_files` for failure-mode traceability. +`lobster-trlc` runs on `public_api.trlc` and the result flows through +`public_api_lobster_files` for failure-mode traceability. Reports show the +generated `public_api.trlc` as item location, not the `.puml` source. --- @@ -194,6 +201,7 @@ requirements). failure_modes( name = "my_failure_modes", srcs = ["failure_modes.trlc"], + deps = [":my_design"], # provides the ApiMethod records of FailureMode.interface ) fault_trees( @@ -209,7 +217,6 @@ assumptions_of_use( safety_analysis( name = "my_safety_analysis", - arch_design = ":my_design", assessments = ["assessments.trlc"], failure_modes = [":my_failure_modes"], fault_trees = ":my_fault_trees", diff --git a/bazel/rules/rules_score/docs/_assets/safety_analysis_doc_pipeline.puml b/bazel/rules/rules_score/docs/_assets/safety_analysis_doc_pipeline.puml index 2e4b64dc..32f62114 100644 --- a/bazel/rules/rules_score/docs/_assets/safety_analysis_doc_pipeline.puml +++ b/bazel/rules/rules_score/docs/_assets/safety_analysis_doc_pipeline.puml @@ -78,8 +78,8 @@ package "Generated files" { rectangle "fta_events.trlc\n(RootCause stubs)" <> as fta_events rectangle "safety_analysis.rst" <> as safety_analysis_rst rectangle "failure_modes lobster" <> as fm_lob - rectangle "fta_root_causes.lobster" <> as ft_lob - rectangle "assessments.lobster" <> as sm_lob + rectangle ".lobster" <> as ft_lob + rectangle ".lobster" <> as sm_lob } ' ── Providers ──────────────────────────────────────────────────────────────── diff --git a/bazel/rules/rules_score/docs/_assets/safety_analysis_linkage.puml b/bazel/rules/rules_score/docs/_assets/safety_analysis_linkage.puml index 48da5834..46202bf4 100644 --- a/bazel/rules/rules_score/docs/_assets/safety_analysis_linkage.puml +++ b/bazel/rules/rules_score/docs/_assets/safety_analysis_linkage.puml @@ -36,9 +36,7 @@ package "Other dependable_element (listed in deps)" { package "This dependable_element" { package "Safety analysis" { - class FailureMode <> { - interface - } + class FailureMode <> class RootCause <> { title diagram @@ -52,6 +50,14 @@ package "This dependable_element" { class "AoU\n(own)" as OwnAoU <> } + package "Architecture" { + class ApiMethod <> { + diagram + line + signatures + } + } + package "Requirements" { class CompReq <> class FeatReq <> @@ -59,6 +65,9 @@ package "This dependable_element" { } } +' ── Generated from the public API class diagrams (public_api.trlc) ────────── +FailureMode "0..*" --> "1..*" ApiMethod : interface + ' ── Generated from the $RootCause macros of the FTA diagrams ──────────────── RootCause "1..*" --> FailureMode : failure_modes diff --git a/bazel/rules/rules_score/docs/_assets/tooling_chain.puml b/bazel/rules/rules_score/docs/_assets/tooling_chain.puml index 9e41329a..44cdd9d5 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**\npuml_cli (Rust)\n(.puml -> .fbs.bin + .lobster + .idmap.json)" <> as puml +rectangle "**PlantUML Parser**\npuml_cli (Rust)\n(.puml -> .fbs.bin + .idmap.json + public_api.trlc)" <> as puml rectangle "**puml_cli** (FTA mode)\nextract root causes\n(.puml -> fta_events.trlc)" <> as fta rectangle "**safety_analysis_assembler**\nTRLCRST page build\n(.trlc + fta_events.trlc -> safety_analysis.rst)" <> as asm rectangle "**Lobster**\nlobster-trlc / -report /\n-ci-report / gtest_report" <> as lob diff --git a/bazel/rules/rules_score/docs/_assets/traceability_dataflow.puml b/bazel/rules/rules_score/docs/_assets/traceability_dataflow.puml index a818374d..0bbfdeea 100644 --- a/bazel/rules/rules_score/docs/_assets/traceability_dataflow.puml +++ b/bazel/rules/rules_score/docs/_assets/traceability_dataflow.puml @@ -39,9 +39,9 @@ rectangle "Unit tests\n(cc_test / gtest)" <> as test_in ' ── Lobster artefacts (one per source kind) ─────────────────────────────────── rectangle "requirements.lobster" <> as reqs_lob -rectangle "public_api.lobster" <> as api_lob -rectangle "failuremodes.lobster\nassessments.lobster" <> as fmcm_lob -rectangle "fta_root_causes.lobster" <> as rc_lob +rectangle ".lobster" <> as api_lob +rectangle "failuremodes.lobster\n.lobster" <> as fmcm_lob +rectangle ".lobster" <> as rc_lob rectangle ".lobster\n(test results)" <> as test_lob ' ── Aggregation + gate ──────────────────────────────────────────────────────── @@ -51,7 +51,7 @@ rectangle "report.html\n+ RST report" <> as html rectangle "PASS / FAIL\n(bazel test gate)" <> as gate reqs_in --> reqs_lob : lobster-trlc -api_in --> api_lob : plantuml parser +api_in --> api_lob : puml_cli (public API TRLC)\nlobster-trlc fmcm_in --> fmcm_lob : lobster-trlc fta_in --> rc_lob : puml_cli (FTA mode) test_in --> test_lob : gtest_report diff --git a/bazel/rules/rules_score/docs/integration_guide.rst b/bazel/rules/rules_score/docs/integration_guide.rst index 3401c7b9..4484229e 100644 --- a/bazel/rules/rules_score/docs/integration_guide.rst +++ b/bazel/rules/rules_score/docs/integration_guide.rst @@ -202,16 +202,18 @@ Complete Example # Architecture architectural_design(name = "arch", + package = "my_arch_api", static = ["docs/arch.puml"], dynamic = ["docs/sequence.puml"], public_api = ["docs/public_api.puml"]) # Safety analysis - failure_modes(name = "my_failure_modes", srcs = ["docs/failures.trlc"]) + failure_modes(name = "my_failure_modes", srcs = ["docs/failures.trlc"], + deps = [":arch"]) fault_trees(name = "my_fault_trees", srcs = ["docs/fta.puml"], deps = [":my_failure_modes"]) - safety_analysis(name = "my_safety_analysis", arch_design = ":arch", + safety_analysis(name = "my_safety_analysis", assessments = ["docs/assessments.trlc"], failure_modes = [":my_failure_modes"], fault_trees = ":my_fault_trees", diff --git a/bazel/rules/rules_score/docs/overview.rst b/bazel/rules/rules_score/docs/overview.rst index aeedff98..5a0eb875 100644 --- a/bazel/rules/rules_score/docs/overview.rst +++ b/bazel/rules/rules_score/docs/overview.rst @@ -136,9 +136,10 @@ Bazel/C++ implementation: methods, enum literals, relationships) must match the generated C++ implementation model (:doc:`spec `). -- **Public API ↔ failure modes** — every public API interface item must be - referenced by a ``FailureMode`` (via ``FailureMode.interface``) in the - SEooC's own safety analysis; unreferenced interfaces fail traceability +- **Public API ↔ failure modes** — every public API method (``ApiMethod`` + record generated from the public API class diagrams) must be referenced by a + ``FailureMode`` (via ``FailureMode.interface``) in the SEooC's own safety + analysis; unreferenced methods fail traceability (:doc:`spec `). Certified scope (build) diff --git a/bazel/rules/rules_score/docs/rule_reference.rst b/bazel/rules/rules_score/docs/rule_reference.rst index 0670eb72..2709b6ee 100644 --- a/bazel/rules/rules_score/docs/rule_reference.rst +++ b/bazel/rules/rules_score/docs/rule_reference.rst @@ -443,12 +443,14 @@ architectural_design Bundles static, dynamic, public-API, and internal-API architecture views into a single target. Provides ``ArchitecturalDesignInfo`` consumed by ``dependable_element`` -and ``safety_analysis``. +and ``dependability_analysis``, and ``TrlcProviderInfo`` for the generated ``public_api.trlc`` +(``ScoreReq.ApiMethod`` records) that ``failure_modes`` lists in ``deps``. .. code-block:: python architectural_design( name = "arch", + package = "arch_api", static = ["docs/static_design.puml"], dynamic = ["docs/sequence.puml"], public_api = ["docs/public_api.puml"], @@ -478,7 +480,7 @@ and ``safety_analysis``. * - ``public_api`` - label list - no - - Public-API diagram files (``.puml``); also generates traceability items for safety analysis, exposed via ``ArchitecturalDesignInfo.public_api_lobster_files``. Several class diagrams resolve as one group (default ``[]``) + - Public-API class diagram files (``.puml``; other diagram kinds fail the build); generate ``public_api.trlc`` with one ``ScoreReq.ApiMethod`` record per public method (all overloads) and per free function, no class items; attributes, enums, typedefs and non-public members yield no record. Reports show the generated ``public_api.trlc`` as item location, not the ``.puml`` source. ``lobster-trlc`` turns the records into the Public API traceability level, exposed via ``ArchitecturalDesignInfo.public_api_lobster_files``. Several class diagrams resolve as one group (default ``[]``) * - ``internal_api`` - label list - no @@ -487,12 +489,16 @@ and ``safety_analysis``. - string - no - ``"release"`` (default) treats validation findings as errors; ``"development"`` emits warnings and continues + * - ``package`` + - string + - with ``public_api`` + - TRLC package of the generated ``public_api.trlc``; failure mode files ``import`` it. Building fails when ``public_api`` diagrams are given without it * - ``visibility`` - — - no - Bazel visibility -**Generated targets:** ```` (provides ``ArchitecturalDesignInfo``; no standalone test — consistency is validated as part of ``bazel test //pkg:my_element``) +**Generated targets:** ```` (provides ``ArchitecturalDesignInfo`` and ``TrlcProviderInfo``; no standalone test — consistency is validated as part of ``bazel test //pkg:my_element``) .. _multiple-static-puml-files: @@ -643,13 +649,16 @@ failure_modes Stage 1 of the safety analysis: the ``FailureMode`` records. The ``fault_trees`` diagrams reference them as tree top events (list the target in -``fault_trees.deps``); ``safety_analysis`` renders and traces them. +``fault_trees.deps``); ``safety_analysis`` renders and traces them. Each record's +``interface`` lists ``ApiMethod`` records of the ``architectural_design`` +target, which therefore belongs in ``deps``. .. code-block:: python failure_modes( name = "my_failure_modes", srcs = ["docs/failure_modes.trlc"], + deps = [":arch"], ) .. list-table:: @@ -755,7 +764,6 @@ records of the root causes and the measures they list. safety_analysis( name = "my_safety_analysis", - arch_design = ":arch", assessments = ["docs/assessments.trlc"], failure_modes = [":my_failure_modes"], fault_trees = ":my_fault_trees", @@ -790,10 +798,6 @@ records of the root causes and the measures they list. - label list - no - Measures the assessments list (default ``[]``): ``assumptions_of_use`` / ``component_requirements`` targets. Only the records of these targets are rendered and traced; the targets in their ``deps`` are used to resolve references only. The ``assumptions_of_use`` targets listed here are the *own* AoUs of the ``dependable_element`` whose ``dependability_analysis`` includes this target: they are rendered in the safety analysis, forwarded to dependees, and shown once however many safety analyses list them. An ``assumptions_of_use`` target that no ``safety_analysis`` lists here is neither rendered nor forwarded. - * - ``arch_design`` - - label - - yes - - ``architectural_design`` target * - ``visibility`` - — - no diff --git a/bazel/rules/rules_score/docs/tooling_architecture.rst b/bazel/rules/rules_score/docs/tooling_architecture.rst index 3d60152d..2622c599 100644 --- a/bazel/rules/rules_score/docs/tooling_architecture.rst +++ b/bazel/rules/rules_score/docs/tooling_architecture.rst @@ -101,7 +101,9 @@ are rendered under :doc:`tool_reference/index`. - ``@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), extracts interface ``.lobster`` items, + ``root_type`` per diagram kind), emits ``public_api.trlc`` (one + ``ApiMethod`` TRLC record per public method of the ``public_api`` class + diagrams; see ``--api-output-file``), 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 @@ -206,10 +208,11 @@ feed that pipeline: * **Requirements** (``.trlc``) → ``lobster-trlc`` → ``requirements.lobster``. * **Public API diagrams** (``public_api.puml``) → PlantUML parser → - ``public_api.lobster`` (enables failure-mode-to-interface tracing). + ``public_api.trlc`` (``ApiMethod`` records) → ``lobster-trlc`` → + ``.lobster`` (enables failure-mode-to-interface tracing). * **FMEA** (``failure_modes`` / ``assessments.trlc``) → ``lobster-trlc``; **FTA** (``fta.puml``) → ``puml_cli`` (FTA mode) → ``fta_events.trlc`` → - ``lobster-trlc`` → ``fta_root_causes.lobster``. + ``lobster-trlc`` → ``.lobster``. * **Unit tests** (gtest) → ``gtest_report`` → ``.lobster``. Lobster report assembly (``dependable_element``) @@ -365,7 +368,7 @@ The ``fault_trees`` rule drives two actions: fta_metamodel.puml``; the metamodel is on PlantUML's global include path (shipped in the docs toolchain runfiles), so it resolves at render time. #. **lobster-trlc** turns the generated ``RootCause`` records into - ``fta_root_causes.lobster``. The ``failure_modes`` files travel as inputs, so the + ``.lobster`` (``AnalysisInfo.lobster_files["fta_root_causes.lobster"]``). The ``failure_modes`` files travel as inputs, so the ``import``s of ``fta_events.trlc`` resolve. The ``safety_analysis`` rule drives two actions: @@ -376,7 +379,7 @@ The ``safety_analysis`` rule drives two actions: pass, expanding ``safety_analysis.template.rst`` into ``safety_analysis.rst``. #. **lobster-trlc** turns the RootCauseAssessment records into - ``assessments.lobster``; AoU and ``CompReq`` measures are traced at their own level. + ``.lobster`` (``AnalysisInfo.lobster_files["assessments.lobster"]``); AoU and ``CompReq`` measures are traced at their own level. ``SphinxSourcesInfo`` of ``safety_analysis`` carries three depsets: diff --git a/bazel/rules/rules_score/docs/user_guide/architectural_design.rst b/bazel/rules/rules_score/docs/user_guide/architectural_design.rst index 56a1fb06..cf3efa5a 100644 --- a/bazel/rules/rules_score/docs/user_guide/architectural_design.rst +++ b/bazel/rules/rules_score/docs/user_guide/architectural_design.rst @@ -535,7 +535,13 @@ for the user of the dependable element and state which functions of the dependab related information. As a proof for their safety relevance for each public method a FMEA should be carried out. This is documented by linking each method to a safety analysis: -``FailureMode`` records reference interface items by name (via the ``interface`` field), enabling traceability from each failure mode back to the architecture. +the build generates one ``ApiMethod`` TRLC record per public method (all overloads) and per free function of the public API class diagrams (the view accepts class diagrams only), and +``FailureMode`` records reference them (via the ``interface`` field), enabling traceability from each failure mode back to the architecture. +Attributes, enums, typedefs and non-public members yield no record. +Constructors and destructors are public methods like any other and get a record each (``_``, ``_destructor``). +The ``const`` qualifier is not part of a recorded signature yet, so const and non-const overloads are merged. +The records live in a generated ``public_api.trlc`` of the ``architectural_design`` target (reports show this file as item location, not the ``.puml`` source); list the target in the ``deps`` of ``failure_modes`` +and ``import`` its TRLC package in the failure mode file. The ``package`` attribute names that package and is required with ``public_api``. PlantUML ~~~~~~~~~ @@ -555,11 +561,10 @@ Bazel architectural_design( name = "my_arch", + package = "my_arch_api", public_api = ["public_api.puml"], ) -The ``public_api`` attribute also generates traceability items that can be referenced by ``safety_analysis`` targets (see :doc:`dependability_analysis`) via the ``arch_design`` attribute. - Internal API -------------- diff --git a/bazel/rules/rules_score/docs/user_guide/dependability_analysis.rst b/bazel/rules/rules_score/docs/user_guide/dependability_analysis.rst index 510089f7..5ccc25ff 100644 --- a/bazel/rules/rules_score/docs/user_guide/dependability_analysis.rst +++ b/bazel/rules/rules_score/docs/user_guide/dependability_analysis.rst @@ -98,7 +98,7 @@ artifacts that must be linked together: - Created in * - **Public API Interfaces** - PlantUML (from ``architectural_design.public_api``) - - Interfaces where failures can manifest; referenced by ``FailureMode.interface`` + - Interfaces where failures can manifest; the generated ``public_api.trlc`` holds one ``ApiMethod`` record per method, referenced by ``FailureMode.interface`` - :doc:`architectural_design` * - **Failure Modes** - TRLC (``.trlc``) @@ -115,9 +115,9 @@ artifacts that must be linked together: The artifacts are linked as follows: -- ``FailureMode.interface`` references an element of the ``public_api`` of the - ``architectural_design`` target. This connects the architectural view to the - safety analysis. +- ``FailureMode.interface`` references the ``ApiMethod`` record(s) generated + from the ``public_api`` class diagrams of the ``architectural_design`` target. + This connects the architectural view to the safety analysis. - Every ``$FailureMode`` node in an FTA diagram names the **TRLC fully-qualified record name(s)** (``Package.RecordName``) of the ``FailureMode`` record(s) it covers. @@ -194,6 +194,7 @@ it is not derived from the Bazel target name and is not enforced by the rule package SampleLibrary import ScoreReq + import sample_seooc_api ScoreReq.FailureMode SampleFailureMode{ guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -201,9 +202,15 @@ it is not derived from the Bazel target name and is not enforced by the rule failureeffect = "The world as we know it will end" version = 1 safety = ScoreReq.Asil.B - interface = "safety_software_seooc_example.SampleLibraryAPI.GetNumber" + interface = [sample_seooc_api.safety_software_seooc_example_SampleLibraryAPI_GetNumber] } +The ``failure_modes`` target lists the ``architectural_design`` target in +``deps``; the generated ``public_api.trlc`` it provides lives in the TRLC package +set by ``architectural_design(package = ...)``. Its record names are +``__`` (``.`` replaced by ``_``); overloads share +one record, free functions get one record each. + .. list-table:: :header-rows: 1 :widths: 25 75 @@ -217,9 +224,9 @@ it is not derived from the Bazel target name and is not enforced by the rule * - ``failureeffect`` - Consequence for the caller / system (see Step 2). * - ``interface`` - - Fully-qualified name of the affected ``public_api`` element - (``..``). Links the failure mode to the - architecture. + - ``ApiMethod`` record(s) of the affected public API method(s), written + ``.`` and generated from the ``public_api`` diagrams. + Links the failure mode to the architecture. * - ``safety`` - ASIL of the safety goal the failure mode can violate (see *ASIL rationale* in Step 3). @@ -552,6 +559,7 @@ Step 4 — Wire the analysis into Bazel failure_modes( name = "sample_failure_modes", srcs = ["sample_safety_analysis_failure_modes.trlc"], + deps = ["//design:sample_seooc_design"], ) fault_trees( @@ -564,7 +572,6 @@ Step 4 — Wire the analysis into Bazel safety_analysis( name = "sample_safety_analysis", - arch_design = "//design:sample_seooc_design", assessments = ["sample_safety_analysis_assessments.trlc"], failure_modes = [":sample_failure_modes"], fault_trees = ":sample_fault_trees", @@ -573,7 +580,8 @@ Step 4 — Wire the analysis into Bazel ) The analysis is split in three targets to avoid a dependency cycle: the -``failure_modes`` target holds the ``FailureMode`` records, the ``fault_trees`` +``failure_modes`` target holds the ``FailureMode`` records and, via ``deps``, the +``ApiMethod`` records of the ``architectural_design`` they reference, the ``fault_trees`` target generates the ```` package with the ``RootCause`` records referencing them, and the ``safety_analysis`` target lists the failure modes, the assessments (which reference the root causes) and the measures they list. @@ -643,7 +651,7 @@ root causes: :: - public_api interface ← FailureMode.interface + ApiMethod (public_api.trlc) ← FailureMode.interface | $FailureMode | diff --git a/bazel/rules/rules_score/examples/seooc/design/BUILD b/bazel/rules/rules_score/examples/seooc/design/BUILD index f3388ea5..007b1242 100644 --- a/bazel/rules/rules_score/examples/seooc/design/BUILD +++ b/bazel/rules/rules_score/examples/seooc/design/BUILD @@ -26,6 +26,7 @@ architectural_design( "internal_api.puml", "internal_api_detail.puml", ], + package = "sample_seooc_api", public_api = [ "public_api.puml", "public_api.rst", diff --git a/bazel/rules/rules_score/examples/seooc/safety_analysis/BUILD b/bazel/rules/rules_score/examples/seooc/safety_analysis/BUILD index 3fcc7087..ace65ed6 100644 --- a/bazel/rules/rules_score/examples/seooc/safety_analysis/BUILD +++ b/bazel/rules/rules_score/examples/seooc/safety_analysis/BUILD @@ -32,6 +32,7 @@ filegroup( failure_modes( name = "sample_failure_modes", srcs = ["sample_safety_analysis_failure_modes.trlc"], + deps = ["//design:sample_seooc_design"], ) # Root causes of the failure modes. The assessments reference them as @@ -48,7 +49,6 @@ fault_trees( # `safety_measures` are the measures the assessments may list. safety_analysis( name = "sample_safety_analysis", - arch_design = "//design:sample_seooc_design", assessments = ["sample_safety_analysis_assessments.trlc"], failure_modes = [":sample_failure_modes"], fault_trees = ":sample_fault_trees", diff --git a/bazel/rules/rules_score/examples/seooc/safety_analysis/sample_safety_analysis_failure_modes.trlc b/bazel/rules/rules_score/examples/seooc/safety_analysis/sample_safety_analysis_failure_modes.trlc index 627258f9..85858d1f 100644 --- a/bazel/rules/rules_score/examples/seooc/safety_analysis/sample_safety_analysis_failure_modes.trlc +++ b/bazel/rules/rules_score/examples/seooc/safety_analysis/sample_safety_analysis_failure_modes.trlc @@ -13,6 +13,7 @@ package SampleLibrary import ScoreReq +import sample_seooc_api ScoreReq.FailureMode SampleFailureMode{ guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -20,7 +21,7 @@ ScoreReq.FailureMode SampleFailureMode{ failureeffect = "The world as we know it will end" version = 1 safety = ScoreReq.Asil.B - interface = "safety_software_seooc_example.SampleLibraryAPI.GetNumber" + interface = [sample_seooc_api.safety_software_seooc_example_SampleLibraryAPI_GetNumber] } ScoreReq.FailureMode SampleFailureMode2{ @@ -29,5 +30,5 @@ ScoreReq.FailureMode SampleFailureMode2{ failureeffect = "Downstream consumers time out" version = 1 safety = ScoreReq.Asil.D - interface = "safety_software_seooc_example.SampleLibraryAPI.GetNumber" + interface = [sample_seooc_api.safety_software_seooc_example_SampleLibraryAPI_GetNumber] } diff --git a/bazel/rules/rules_score/examples/some_other_library/BUILD b/bazel/rules/rules_score/examples/some_other_library/BUILD index 11ee04d7..9f862fee 100644 --- a/bazel/rules/rules_score/examples/some_other_library/BUILD +++ b/bazel/rules/rules_score/examples/some_other_library/BUILD @@ -26,6 +26,7 @@ load( architectural_design( name = "sample_seooc_design", + package = "other_library_api", public_api = [ "public_api.puml", ], @@ -43,6 +44,7 @@ assumptions_of_use( failure_modes( name = "other_library_failure_modes", srcs = ["failure_modes.trlc"], + deps = [":sample_seooc_design"], ) fault_trees( @@ -54,7 +56,6 @@ fault_trees( safety_analysis( name = "other_library_safety_analysis", - arch_design = ":sample_seooc_design", assessments = ["assessments.trlc"], failure_modes = [":other_library_failure_modes"], fault_trees = ":other_library_fault_trees", diff --git a/bazel/rules/rules_score/examples/some_other_library/failure_modes.trlc b/bazel/rules/rules_score/examples/some_other_library/failure_modes.trlc index feb35229..9b0277e6 100644 --- a/bazel/rules/rules_score/examples/some_other_library/failure_modes.trlc +++ b/bazel/rules/rules_score/examples/some_other_library/failure_modes.trlc @@ -13,6 +13,7 @@ package OtherLibraryFmea import ScoreReq +import other_library_api ScoreReq.FailureMode ComputeOverloaded { guidewords = [ScoreReq.Guideword.TooLate] @@ -20,5 +21,5 @@ ScoreReq.FailureMode ComputeOverloaded { failureeffect = "Results are delivered late or not at all" version = 1 safety = ScoreReq.Asil.B - interface = "other_seooc.OtherLibraryAPI.Compute" + interface = [other_library_api.other_seooc_OtherLibraryAPI_Compute] } diff --git a/bazel/rules/rules_score/lobster/config/BUILD b/bazel/rules/rules_score/lobster/config/BUILD index b23c91c1..b680c3c9 100644 --- a/bazel/rules/rules_score/lobster/config/BUILD +++ b/bazel/rules/rules_score/lobster/config/BUILD @@ -50,6 +50,12 @@ filegroup( visibility = ["//visibility:public"], ) +filegroup( + name = "api_methods_config", + srcs = ["lobster_api_methods.yaml"], + visibility = ["//visibility:public"], +) + filegroup( name = "aou_config", srcs = ["lobster_aou.yaml"], diff --git a/bazel/rules/rules_score/lobster/config/lobster_api_methods.yaml b/bazel/rules/rules_score/lobster/config/lobster_api_methods.yaml new file mode 100644 index 00000000..64a9fb45 --- /dev/null +++ b/bazel/rules/rules_score/lobster/config/lobster_api_methods.yaml @@ -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 +# ******************************************************************************* +# lobster-trlc configuration for the ApiMethod records generated from the +# public API class diagrams. +inputs: + - . +conversion-rules: + - package: ScoreReq + record-type: ApiMethod + namespace: req + description-fields: signatures diff --git a/bazel/rules/rules_score/lobster/config/lobster_sa.conf.tpl b/bazel/rules/rules_score/lobster/config/lobster_sa.conf.tpl index 98d22f7a..4c74684e 100644 --- a/bazel/rules/rules_score/lobster/config/lobster_sa.conf.tpl +++ b/bazel/rules/rules_score/lobster/config/lobster_sa.conf.tpl @@ -1,10 +1,10 @@ -implementation "Architecture" { -{ARCH_SOURCES} +requirements "Public API" { +{PUBLIC_API_SOURCES} } requirements "Failure Modes" { {FM_SOURCES} - trace to: "Architecture"; + trace to: "Public API"; } activity "Root Causes" { diff --git a/bazel/rules/rules_score/private/architectural_design.bzl b/bazel/rules/rules_score/private/architectural_design.bzl index 845364de..5a2c9c75 100644 --- a/bazel/rules/rules_score/private/architectural_design.bzl +++ b/bazel/rules/rules_score/private/architectural_design.bzl @@ -19,9 +19,14 @@ documentation following S-CORE process guidelines. Architectural design documents describe the software architecture including static and dynamic views. The rule automatically invokes the PlantUML parser on .puml files -to produce FlatBuffers binary representations of the parsed diagrams. +to produce FlatBuffers binary representations of the parsed diagrams. The +class diagrams of the `public_api` view also yield a `public_api.trlc` of +`ScoreReq.ApiMethod` records (one per public method name, overloads collapsed, +and one per free function), which `FailureMode.interface` references. """ +load("@lobster//:lobster.bzl", "subrule_lobster_trlc") +load("@trlc//:trlc.bzl", "TrlcProviderInfo") load("//bazel/rules/rules_score:providers.bzl", "ArchitecturalDesignInfo", "SphinxSourcesInfo") load("//bazel/rules/rules_score/private:puml_parser.bzl", "disambiguated_stems", "run_puml_parser") load("//bazel/rules/rules_score/private:puml_utils.bzl", "emit_view_navigation", "plan_view_layout") @@ -34,7 +39,9 @@ load("//bazel/rules/rules_score/private:views.bzl", "ARCH_VIEWS") # ============================================================================ # Views passed as `--diagram-type` to `puml_cli`; every other view autodetects. +# `public_api` is class diagrams only: the API records come from them. _VIEW_DIAGRAM_TYPES = { + "public_api": "class", "static": "component", } @@ -104,19 +111,22 @@ def _architectural_design_impl(ctx): Collects architectural design artifacts including static, dynamic, public API, and internal API diagrams, runs the PlantUML parser on .puml files to generate FlatBuffers binaries, and provides them through the - ArchitecturalDesignInfo provider. + ArchitecturalDesignInfo provider. The class diagrams of the public API + view also yield the `ApiMethod` records, provided as TrlcProviderInfo. - The diagram type (component, class, sequence) is auto-detected by the - parser and encoded in the FlatBuffers binary via its schema root_type. + The `static` view accepts component diagrams only and the `public_api` view + class diagrams only (the parser fails the action otherwise); the other + views are auto-detected. The diagram type (component, class, sequence) is + encoded in the FlatBuffers binary via its schema root_type. Args: ctx: Rule context Returns: - List of providers including DefaultInfo, ArchitecturalDesignInfo, SphinxSourcesInfo + List of providers including DefaultInfo, ArchitecturalDesignInfo, TrlcProviderInfo, SphinxSourcesInfo """ - # All diagrams of this target share one flat fbs/lobster/idmap namespace + # All diagrams of this target share one flat fbs/idmap namespace # (keyed by ctx.label.name), so stems must be disambiguated across all # four views together, not per-view. stems = disambiguated_stems( @@ -124,31 +134,40 @@ def _architectural_design_impl(ctx): ctx.files.static + ctx.files.dynamic + ctx.files.public_api + ctx.files.internal_api, ) + api_package = ctx.attr.package + has_api_diagrams = any([f.extension == "puml" for f in ctx.files.public_api]) + if has_api_diagrams and not api_package: + fail("{}: `package` is required with `public_api` diagrams; failure mode files import it".format(ctx.label)) + if api_package and not has_api_diagrams: + fail("{}: `package` is only used with `public_api` .puml diagrams".format(ctx.label)) + view_fbs = {} - view_lobster = {} view_idmap = {} view_root_indexes = {} view_source_files = [] view_sphinx_srcs = [] root_index_files = [] view_aux_docs = [] + api_trlc = None for view_name, root_title in ARCH_VIEWS: view_files = getattr(ctx.files, view_name) # One `puml_cli` action per view: its diagrams resolve as one group, so # `static` may split a decomposition across an overview and detail files. - fbs_list, lobster_list, idmap_list = run_puml_parser( + is_public_api = view_name == "public_api" + parsed = run_puml_parser( ctx, view_files, stems, diagram_type = _VIEW_DIAGRAM_TYPES.get(view_name), - emit_lobster = True, + public_api_package = api_package if is_public_api else None, progress_label = "{} ({})".format(ctx.label, view_name), ) - view_fbs[view_name] = fbs_list - view_lobster[view_name] = lobster_list - view_idmap[view_name] = idmap_list + view_fbs[view_name] = parsed.fbs + view_idmap[view_name] = parsed.idmap + if is_public_api: + api_trlc = parsed.public_api # Reconcile generated wrappers/indexes against any hand-authored # rst/md pages before staging anything -- see plan_view_layout for @@ -198,7 +217,12 @@ def _architectural_design_impl(ctx): dynamic_fbs = depset(view_fbs["dynamic"]) public_api_fbs = depset(view_fbs["public_api"]) internal_api_fbs = depset(view_fbs["internal_api"]) - public_api_lobster = depset(view_lobster["public_api"]) + + # `ApiMethod` traceability exists only with public API diagrams. + public_api_lobster = [] + if api_trlc: + api_lobster, _ = subrule_lobster_trlc([api_trlc] + ctx.files._spec, ctx.file._api_methods_lobster_config) + public_api_lobster = [api_lobster] all_source_files = depset(transitive = view_source_files) @@ -234,7 +258,7 @@ def _architectural_design_impl(ctx): sphinx_aux_srcs = depset(view_aux_docs) return [ - DefaultInfo(files = depset([validation_log.file], transitive = [all_source_files])), + DefaultInfo(files = depset([validation_log.file] + ([api_trlc] if api_trlc else []), transitive = [all_source_files])), ArchitecturalDesignInfo( static = static_fbs, dynamic = dynamic_fbs, @@ -242,9 +266,15 @@ def _architectural_design_impl(ctx): internal_api = internal_api_fbs, view_root_indexes = view_root_indexes, name = ctx.label.name, - public_api_lobster_files = public_api_lobster, + public_api_lobster_files = depset(public_api_lobster), validation_logs = [validation_log], ), + # Lets `failure_modes` resolve `.`. + TrlcProviderInfo( + spec = depset(ctx.files._spec), + reqs = depset([api_trlc] if api_trlc else []), + deps = depset(), + ), # Each view's root index is the only top-level toctree entry; # everything else (wrappers, sub-indexes, idmap sidecars, colocated # sources) is staged via aux_srcs/deps for the sphinx documentation build. @@ -275,10 +305,13 @@ def _architectural_design_attrs(): "public_api": attr.label_list( allow_files = [".puml", ".svg", ".rst", ".md"], mandatory = False, - doc = "Public API diagrams (parsed identically to static/dynamic). " + - "Classified separately so their lobster items are exposed via " + - "public_api_lobster_files, enabling failure-mode-to-interface " + - "traceability at the dependable element level.", + doc = "Public API class diagrams (.puml; the .svg/.rst/.md pages are staged as documentation). " + + "The diagrams yield the ScoreReq.ApiMethod records " + + "(one per public method name, overloads collapsed, and one " + + "per free function) that FailureMode.interface references; " + + "their lobster items are exposed via public_api_lobster_files, " + + "enabling failure-mode-to-interface traceability at the " + + "dependable element level.", ), "internal_api": attr.label_list( allow_files = [".puml", ".svg", ".rst", ".md"], @@ -292,11 +325,26 @@ def _architectural_design_attrs(): values = ["release", "development"], doc = "Maturity level of the architectural design. 'release' treats validation findings as errors; 'development' emits warnings and continues.", ), + "package": attr.string( + default = "", + doc = "TRLC package of the generated public_api.trlc (the ApiMethod records). " + + "Required with `public_api` diagrams; failure mode files `import` it.", + ), + "_spec": attr.label_list( + allow_files = [".rsl", ".trlc"], + default = [Label("//bazel/rules/rules_score/trlc/config:score_requirements_model")], + doc = "S-CORE requirements model (.rsl) defining ApiMethod.", + ), "_puml_parser": attr.label( default = Label("@score_tooling//plantuml/parser:parser"), executable = True, cfg = "exec", - doc = "PlantUML parser tool that generates FlatBuffers/lobster/idmap files from .puml files", + doc = "PlantUML parser tool that generates FlatBuffers/idmap/ApiMethod TRLC files from .puml files", + ), + "_api_methods_lobster_config": attr.label( + default = Label("//bazel/rules/rules_score/lobster/config:api_methods_config"), + allow_single_file = True, + doc = "lobster-trlc YAML config for ApiMethod records.", ), "_puml_rst_template": attr.label( default = Label("//bazel/rules/rules_score:templates/puml_diagram.template.rst"), @@ -313,6 +361,7 @@ _architectural_design = rule( doc = "Collects architectural design documents and diagrams for S-CORE process compliance. " + "Automatically parses PlantUML files to produce FlatBuffers binary representations.", attrs = _architectural_design_attrs(), + subrules = [subrule_lobster_trlc], ) # ============================================================================ @@ -354,11 +403,17 @@ def architectural_design( .png, .svg) or documentation files (.rst, .md) containing dynamic architecture views such as sequence diagrams, activity diagrams, or state diagrams as defined in the S-CORE process. - public_api: Optional list of .puml files describing the public interface - of this element. These are parsed identically to static/dynamic - diagrams but classified separately so their lobster items are - exposed via public_api_lobster_files, enabling failure-mode-to- - interface traceability at the dependable element level. + public_api: Optional list of .puml class diagrams describing the public + interface of this element, classified separately from the other + views: the diagrams yield the + ScoreReq.ApiMethod records (one per public method name, overloads + collapsed, and one per free function) that FailureMode.interface + references, and their lobster items are exposed via + public_api_lobster_files, enabling failure-mode-to-interface + traceability at the dependable element level. A `failure_modes` + target lists this target in its `deps` to resolve the records. + Constructors and destructors are public methods like any other and + get a record each. internal_api: Optional list of .puml files describing internal interfaces of this element. These are parsed identically to static/dynamic diagrams but classified separately so their @@ -367,6 +422,9 @@ def architectural_design( maturity: Maturity level of the architectural design. Use "development" to write validation findings without failing the Bazel action. + package: TRLC package of the generated `public_api.trlc` (passed via + kwargs); required with `public_api` diagrams. Failure mode files + `import` it. visibility: Bazel visibility specification for the generated targets. Generated Targets: diff --git a/bazel/rules/rules_score/private/dependability_analysis.bzl b/bazel/rules/rules_score/private/dependability_analysis.bzl index c52d8fb2..a567c33a 100644 --- a/bazel/rules/rules_score/private/dependability_analysis.bzl +++ b/bazel/rules/rules_score/private/dependability_analysis.bzl @@ -21,7 +21,7 @@ A dependability analysis aggregates sub-analysis rules * **security_analysis** – security analysis rule targets (placeholder, optional). -The traceability test runs ``lobster-ci-report`` over Architecture <- Failure +The traceability test runs ``lobster-ci-report`` over Public API <- Failure Modes <- Root Causes. Coverage of root causes by assessments is checked in the ``dependable_element`` traceability report. """ @@ -128,15 +128,15 @@ def _dependability_analysis_impl(ctx): all_rst_aux = depset(transitive = rst_aux_transitive) if rst_aux_transitive else depset() # ========================================================================= - # Lobster traceability report (Architecture, Failure Modes, Root Causes) + # Lobster traceability report (Public API, Failure Modes, Root Causes) # ========================================================================= lobster_report_file = None lobster_html_file = None report_files = [] all_lobster_file_objects = [f for files in lobster_files.values() for f in files] - arch_lobster_files = arch_design_info.public_api_lobster_files.to_list() if arch_design_info else [] - all_lobster_file_objects = list(all_lobster_file_objects) + arch_lobster_files + api_lobster_files = arch_design_info.public_api_lobster_files.to_list() if arch_design_info else [] + all_lobster_file_objects = list(all_lobster_file_objects) + api_lobster_files if all_lobster_file_objects: lobster_config = ctx.actions.declare_file( "{}/traceability_config".format(ctx.label.name), @@ -146,7 +146,7 @@ def _dependability_analysis_impl(ctx): template = ctx.file._lobster_sa_template, output = lobster_config, substitutions = { - "{ARCH_SOURCES}": format_lobster_sources(arch_lobster_files), + "{PUBLIC_API_SOURCES}": format_lobster_sources(api_lobster_files), "{FM_SOURCES}": format_lobster_sources(lobster_files.get("failuremodes.lobster", [])), "{RC_SOURCES}": format_lobster_sources(lobster_files.get("fta_root_causes.lobster", [])), }, @@ -292,7 +292,7 @@ def dependability_analysis( """Define dependability analysis following S-CORE process guidelines. Aggregates up to three sub-analysis rules and validates the combined - traceability chain (Architecture <- Failure Modes <- Root Causes) via + traceability chain (Public API <- Failure Modes <- Root Causes) via ``lobster-ci-report``. Coverage of root causes by assessments is checked by the ``dependable_element`` traceability report. diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index d3b5e33f..0bba386a 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -1752,7 +1752,7 @@ def _dependable_element_index_impl(ctx): emit_empty = strict, ), "{PUBLIC_API_BLOCK}": format_lobster_block( - "implementation", + "requirements", "Public API", interface_req_list, emit_empty = strict, diff --git a/bazel/rules/rules_score/private/failure_modes.bzl b/bazel/rules/rules_score/private/failure_modes.bzl index a4e252dc..f769474a 100644 --- a/bazel/rules/rules_score/private/failure_modes.bzl +++ b/bazel/rules/rules_score/private/failure_modes.bzl @@ -37,7 +37,9 @@ def failure_modes( name: The name of the target. srcs: List of ``.trlc`` files containing ``ScoreReq.FailureMode`` records. deps: Optional targets (providing TrlcProviderInfo) needed to parse the - records. + records. List the ``architectural_design`` target whose + ``public_api.trlc`` the ``interface`` field references, and any + other target whose records are referenced. lobster_config: Lobster YAML configuration for FailureMode traceability extraction. Defaults to the standard S-CORE failure mode config. **kwargs: Additional arguments (e.g. ``visibility``, ``tags``). diff --git a/bazel/rules/rules_score/private/puml_parser.bzl b/bazel/rules/rules_score/private/puml_parser.bzl index a8bf861a..52d35a09 100644 --- a/bazel/rules/rules_score/private/puml_parser.bzl +++ b/bazel/rules/rules_score/private/puml_parser.bzl @@ -62,7 +62,7 @@ def disambiguated_stems(ctx, files): stems[f.path] = stem return stems -def run_puml_parser(ctx, files, stems, diagram_type = None, emit_lobster = False, progress_label = None): +def run_puml_parser(ctx, files, stems, diagram_type = None, public_api_package = None, progress_label = None): """Runs `puml_cli` once for a whole `.puml` file list, so the files resolve as one group. @@ -79,27 +79,33 @@ def run_puml_parser(ctx, files, stems, diagram_type = None, emit_lobster = False diagram_type: Optional CLI `--diagram-type` value (e.g. "component"); `None` leaves detection to the parser. With a value, a diagram of another kind fails the action. - emit_lobster: When `True`, also produce a `.lobster` per file. + public_api_package: When set, the files are public API class diagrams: + declares `{label}/public_api.trlc` (TRLC package + `public_api_package`) holding their `ScoreReq.ApiMethod` records. + Use with `diagram_type = "class"`. progress_label: Optional group description for the progress message; defaults to `str(ctx.label)`. Returns: - Tuple of (fbs_outputs, lobster_outputs, idmap_outputs), one entry per - `.puml` file in `files`, in order. `lobster_outputs` is empty unless - `emit_lobster`. + Struct with `fbs` and `idmap` (output File lists, one entry per + `.puml` file in `files`, in order) and `public_api` (the declared + `public_api.trlc` File, or `None` without `public_api_package` or + `.puml` files). """ puml_files = [f for f in files if f.extension == "puml"] + if not puml_files: - return [], [], [] + return struct(fbs = [], idmap = [], public_api = None) + + public_api = None + if public_api_package: + public_api = ctx.actions.declare_file("{}/public_api.trlc".format(ctx.label.name)) fbs_outputs = [] - lobster_outputs = [] idmap_outputs = [] arguments = [] for f in puml_files: stem = stems[f.path] fbs_outputs.append(ctx.actions.declare_file("{}/{}.fbs.bin".format(ctx.label.name, stem))) - if emit_lobster: - lobster_outputs.append(ctx.actions.declare_file("{}/{}.lobster".format(ctx.label.name, stem))) idmap_outputs.append(ctx.actions.declare_file("{}/{}.idmap.json".format(ctx.label.name, stem))) arguments.extend([ "--file", @@ -121,15 +127,17 @@ def run_puml_parser(ctx, files, stems, diagram_type = None, emit_lobster = False "--log-level", get_log_level(ctx), ]) - if emit_lobster: - arguments.extend(["--lobster-output-dir", lobster_outputs[0].dirname]) + api_outputs = [] + if public_api: + api_outputs.append(public_api) + arguments.extend(["--api-output-file", public_api.path, "--api-package", public_api_package]) ctx.actions.run( inputs = puml_files, - outputs = fbs_outputs + lobster_outputs + idmap_outputs, + outputs = fbs_outputs + idmap_outputs + api_outputs, executable = ctx.executable._puml_parser, arguments = arguments, progress_message = "Parsing PlantUML diagram group: %s" % (progress_label or str(ctx.label)), ) - return fbs_outputs, lobster_outputs, idmap_outputs + return struct(fbs = fbs_outputs, idmap = idmap_outputs, public_api = public_api) diff --git a/bazel/rules/rules_score/private/safety_analysis.bzl b/bazel/rules/rules_score/private/safety_analysis.bzl index 204a81fb..4d0296d9 100644 --- a/bazel/rules/rules_score/private/safety_analysis.bzl +++ b/bazel/rules/rules_score/private/safety_analysis.bzl @@ -71,21 +71,25 @@ Both rules are **build-only**. The combined traceability *test* is owned by the ``dependability_analysis`` rule which wraps ``safety_analysis``. """ +load("@lobster//:lobster.bzl", "subrule_lobster_trlc") load("@trlc//:trlc.bzl", "TrlcProviderInfo") -load("//bazel/rules/rules_score:providers.bzl", "AnalysisInfo", "ArchitecturalDesignInfo", "AssumptionsOfUseInfo", "ComponentRequirementsInfo", "FailureModesInfo", "FaultTreesInfo", "SafetyAnalysisProviderInfo", "SphinxSourcesInfo") +load("//bazel/rules/rules_score:providers.bzl", "AnalysisInfo", "AssumptionsOfUseInfo", "ComponentRequirementsInfo", "FailureModesInfo", "FaultTreesInfo", "SafetyAnalysisProviderInfo", "SphinxSourcesInfo") load("//bazel/rules/rules_score/private:lobster_config.bzl", "MERGE_LOBSTER_ITEMS_ATTR", "merge_lobster_files") load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_log_level") +_LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +_IDENTIFIER_CHARS = _LETTERS + "0123456789_" + def _default_fta_package(label): """Best-effort conversion of a Bazel *label* into a valid TRLC package - identifier (``[A-Za-z_][A-Za-z0-9_]*``). + identifier (an ASCII letter followed by ASCII letters, digits and ``_``). Package and name are joined with ``__`` so equally named targets of different packages get distinct packages in one TRLC build. The repository is left out: it differs between a root build and a build as a dependency, which would change the package that downstream records reference. - Non-identifier characters (e.g. ``-``, ``/``) become ``_``; a result - starting with a digit gets a leading ``_``. + Non-identifier characters (e.g. ``-``, ``/``) become ``_``; a result not + starting with a letter gets a leading ``P``. A ``_fta`` suffix is appended: TRLC rejects packages whose name is "too similar" (same modulo case/underscores) to another declared package, and @@ -95,12 +99,9 @@ def _default_fta_package(label): collide with that package. """ name = "__".join([p for p in [label.package, label.name] if p]) - out = "" - for i in range(len(name)): - c = name[i] - out += c if (c.isalnum() or c == "_") else "_" - if out[:1].isdigit(): - out = "_" + out + out = "".join([c if c in _IDENTIFIER_CHARS else "_" for c in name.elems()]) + if not out or out[0] not in _LETTERS: + out = "P" + out return out + "_fta" # ============================================================================ @@ -135,10 +136,9 @@ def _process_root_causes(ctx, fta_package): fta_events_trlc = ctx.actions.declare_file("{}/fta_events.trlc".format(ctx.label.name)) if not puml_inputs: - # No fault trees: emit an empty stub so the assembler still runs - # (rendering every failure mode without root causes). No - # "import ScoreReq" here: with zero records it would be flagged as an - # unused import by TRLC. + # No fault trees: the assembler still runs, rendering every failure + # mode without root causes. No `import ScoreReq`: with zero records TRLC + # flags it as unused. ctx.actions.write( fta_events_trlc, "// no root cause diagrams\npackage {}\n".format(fta_package), @@ -161,27 +161,6 @@ def _process_root_causes(ctx, fta_package): return puml_inputs, fta_events_trlc -# ============================================================================ -# Lobster (TRLC traceability) helper -# ============================================================================ - -def _lobster_trlc(ctx, trlc_files, config, out_name): - """Run ``lobster-trlc`` over *trlc_files* (incl. the model spec) producing ``{label}/``.""" - if not trlc_files: - return None - out = ctx.actions.declare_file("{}/{}".format(ctx.label.name, out_name)) - args = ctx.actions.args() - args.add("--config", config.path) - args.add("--out", out.path) - ctx.actions.run( - inputs = depset(trlc_files + [config]), - outputs = [out], - executable = ctx.executable._lobster_trlc, - arguments = [args], - progress_message = "lobster-trlc {}".format(out.path), - ) - return out - # ============================================================================ # Private Rule Implementation # ============================================================================ @@ -209,11 +188,9 @@ def _fault_trees_impl(ctx): lobster_files = {} if diagrams: - lobster_files["fta_root_causes.lobster"] = _lobster_trlc( - ctx, + lobster_files["fta_root_causes.lobster"], _ = subrule_lobster_trlc( [fta_events_trlc] + fm_files.to_list() + spec.to_list(), ctx.file._rc_lobster_config, - "fta_root_causes.lobster", ) return [ @@ -320,11 +297,9 @@ def _safety_analysis_impl(ctx): lobster_files["failuremodes.lobster"] = fm_lobster_files[0] if assessment_files: - lobster_files["assessments.lobster"] = _lobster_trlc( - ctx, + lobster_files["assessments.lobster"], _ = subrule_lobster_trlc( assessment_files + fta_and_fm_files + measure_files + context_files + spec_files, ctx.file._assessments_lobster_config, - "assessments.lobster", ) # The .puml diagrams are referenced inline via ``.. uml::`` but @@ -389,13 +364,6 @@ _fault_trees = rule( cfg = "exec", doc = "puml_cli binary used in FTA mode to extract fta_events.trlc.", ), - "_lobster_trlc": attr.label( - default = Label("@lobster//:lobster-trlc"), - executable = True, - allow_files = True, - cfg = "exec", - doc = "lobster-trlc executable used to generate the RC lobster file.", - ), "_rc_lobster_config": attr.label( default = Label("//bazel/rules/rules_score/lobster/config:fta_root_causes_config"), allow_single_file = True, @@ -404,6 +372,7 @@ _fault_trees = rule( }, **VERBOSITY_ATTR ), + subrules = [subrule_lobster_trlc], ) _safety_analysis = rule( @@ -442,11 +411,6 @@ _safety_analysis = rule( doc = "TRLC model specification files (``.rsl``) required for import resolution. " + "Defaults to the S-CORE requirements model.", ), - "arch_design": attr.label( - providers = [ArchitecturalDesignInfo], - mandatory = True, - doc = "architectural_design target for traceability.", - ), "_safety_analysis_assembler": attr.label( default = Label("//bazel/rules/rules_score:safety_analysis_assembler"), executable = True, @@ -454,13 +418,6 @@ _safety_analysis = rule( cfg = "exec", doc = "Safety-analysis page assembler (imports the extended TRLCRST library).", ), - "_lobster_trlc": attr.label( - default = Label("@lobster//:lobster-trlc"), - executable = True, - allow_files = True, - cfg = "exec", - doc = "lobster-trlc executable used to generate the assessments lobster file.", - ), "_assessments_lobster_config": attr.label( default = Label("//bazel/rules/rules_score/lobster/config:assessments_config"), allow_single_file = True, @@ -474,6 +431,7 @@ _safety_analysis = rule( }, **dict(VERBOSITY_ATTR, **MERGE_LOBSTER_ITEMS_ATTR) ), + subrules = [subrule_lobster_trlc], ) # ============================================================================ @@ -522,7 +480,6 @@ def fault_trees( def safety_analysis( name, - arch_design, fault_trees, failure_modes = [], safety_measures = [], @@ -550,8 +507,6 @@ def safety_analysis( Args: name: Target name. - arch_design: ``architectural_design`` target for traceability; its - ``public_api`` is what ``FailureMode.interface`` references. fault_trees: ``fault_trees`` target providing the root causes. failure_modes: ``failure_modes`` targets whose records are rendered and traced. Every failure mode a root cause references must be listed. @@ -574,6 +529,5 @@ def safety_analysis( failure_modes = failure_modes, safety_measures = safety_measures, assessments = assessments, - arch_design = arch_design, **kwargs ) diff --git a/bazel/rules/rules_score/private/unit_design.bzl b/bazel/rules/rules_score/private/unit_design.bzl index e88d2395..ec4c524a 100644 --- a/bazel/rules/rules_score/private/unit_design.bzl +++ b/bazel/rules/rules_score/private/unit_design.bzl @@ -54,21 +54,21 @@ def _unit_design_impl(ctx): # One `puml_cli` action per view; its diagrams resolve as one group and # the views never mix. Diagram type is autodetected. stems = disambiguated_stems(ctx, ctx.files.static + ctx.files.dynamic) - static_fbs_list, _static_lobster, static_idmap_list = run_puml_parser( + static = run_puml_parser( ctx, ctx.files.static, stems, progress_label = "{} (static)".format(ctx.label), ) - dynamic_fbs_list, _dynamic_lobster, dynamic_idmap_list = run_puml_parser( + dynamic = run_puml_parser( ctx, ctx.files.dynamic, stems, progress_label = "{} (dynamic)".format(ctx.label), ) - idmap_list = static_idmap_list + dynamic_idmap_list - static_fbs = depset(static_fbs_list) - dynamic_fbs = depset(dynamic_fbs_list) + idmap_list = static.idmap + dynamic.idmap + static_fbs = depset(static.fbs) + dynamic_fbs = depset(dynamic.fbs) # Stage idmap sidecars into the sphinx sources alongside the source # diagrams so `clickable_plantuml` (which scans `srcdir` recursively for diff --git a/bazel/rules/rules_score/providers.bzl b/bazel/rules/rules_score/providers.bzl index 39088ed5..0290cdfd 100644 --- a/bazel/rules/rules_score/providers.bzl +++ b/bazel/rules/rules_score/providers.bzl @@ -235,7 +235,7 @@ ArchitecturalDesignInfo = provider( "internal_api": "Depset of FlatBuffers binaries for internal API diagrams (class diagrams, etc.)", "view_root_indexes": "Dict mapping view name ('static', 'dynamic', 'public_api', 'internal_api') to that view's single top-level toctree-entry File (see emit_view_navigation's root_index), or None for views with no navigable files.", "name": "Name of the architectural design target", - "public_api_lobster_files": "Depset of .lobster traceability files generated from public_api diagrams.", + "public_api_lobster_files": "Depset of .lobster traceability files of the ScoreReq.ApiMethod records generated from public_api class diagrams.", "validation_logs": "List of validation log entries produced by this architectural design target. Each entry has file and name fields.", }, ) diff --git a/bazel/rules/rules_score/src/safety_analysis_assembler.py b/bazel/rules/rules_score/src/safety_analysis_assembler.py index c791f51d..daf67193 100644 --- a/bazel/rules/rules_score/src/safety_analysis_assembler.py +++ b/bazel/rules/rules_score/src/safety_analysis_assembler.py @@ -50,12 +50,13 @@ "debug": logging.DEBUG, } -# Overview summary table columns (one row per failure mode). -_FM_TABLE_COLUMNS = { - "guideword": "Guideword", - "safety": "ASIL", - "interface": "Interface", -} +# Overview summary table (one row per failure mode): header and field of each +# column after the failure-mode name. +_OVERVIEW_COLUMNS = ( + ("Guidewords", "guidewords"), + ("ASIL", "safety"), + ("Interface", "interface"), +) _OVERVIEW_TITLE = "Overview" _FAILURE_MODES_TITLE = "Failure Modes" @@ -213,24 +214,30 @@ def _assessments_by_root_cause(obj_map: dict) -> dict[str, list[str]]: # --------------------------------------------------------------------------- +def _code_list(values: list[str]) -> str: + """*values* (record names, enum literals) as comma-separated literals.""" + return ", ".join(f"``{value}``" for value in values) + + def _attr_grid(obj: object) -> _Directive | None: - """FM attributes: guideword/ASIL as centred chips (no card chrome), - interface/failure-effect as titled cards; a gutter separates the rows.""" + """FM attributes: guidewords/ASIL as centred chips (no card chrome), + interface/failure-effect as titled cards; a gutter separates the rows. + """ fields = obj.to_python_dict() items = [] - guideword = fields.get("guideword") - if guideword: - items.append(_grid_item(_badge(_GUIDEWORD_BADGE, guideword), {"class": "sd-text-center"})) + guidewords = fields.get("guidewords") + if guidewords: + chips = " ".join(_badge(_GUIDEWORD_BADGE, guideword) for guideword in guidewords) + items.append(_grid_item(chips, {"class": "sd-text-center"})) safety = fields.get("safety") if safety: items.append(_grid_item(_asil_badge(safety), {"class": "sd-text-center"})) - for field_name, label in ( - ("interface", "Interface"), - ("failureeffect", "Failure Effect"), - ): - value = fields.get(field_name) - if value: - items.append(_card(label, value)) + interface = fields.get("interface") + if interface: + items.append(_card("Interface", _code_list(interface))) + failure_effect = fields.get("failureeffect") + if failure_effect: + items.append(_card("Failure Effect", failure_effect)) return _grid(items, columns=2, gutter=3) if items else None @@ -345,11 +352,20 @@ def _fm_dropdown( # --------------------------------------------------------------------------- -def _render_overview(renderer: TRLCRST, fm_fqns: list[str]) -> str: +def _render_overview(obj_map: dict, fm_fqns: list[str]) -> str: if not fm_fqns: return "" - table = renderer.render_table_to_string(_FM_TABLE_COLUMNS, fqns=fm_fqns, name_header="Failure Mode", link_fn=_ref) - return _heading(_OVERVIEW_TITLE, "-") + "\n" + table + lines = [".. list-table::", " :header-rows: 1", "", " * - Failure Mode"] + lines.extend(f" - {header}" for header, _ in _OVERVIEW_COLUMNS) + for fqn in fm_fqns: + obj = obj_map[fqn] + fields = obj.to_python_dict() + lines.append(f" * - {_ref(fqn, obj.name)}") + for _, field_name in _OVERVIEW_COLUMNS: + value = fields.get(field_name) or "" + lines.append(f" - {_code_list(value) if isinstance(value, list) else value}") + lines.append("") + return _heading(_OVERVIEW_TITLE, "-") + "\n" + "\n".join(lines) + "\n" def _render_failure_modes( @@ -473,7 +489,7 @@ def _build_body(renderer: TRLCRST, title: str, fta_package: str, diagram_names: sections = [ _heading(title, "="), - _render_overview(renderer, fm_fqns), + _render_overview(obj_map, fm_fqns), _render_failure_modes(renderer, obj_map, fm_fqns, diagrams, assessments), _render_fault_trees(diagrams), _render_measures(renderer, obj_map, measure_fqns, set(measure_fqns) - listed), diff --git a/bazel/rules/rules_score/test/BUILD b/bazel/rules/rules_score/test/BUILD index e30abffb..fae3e8f4 100644 --- a/bazel/rules/rules_score/test/BUILD +++ b/bazel/rules/rules_score/test/BUILD @@ -536,7 +536,6 @@ safety_analysis( # failure_modes = [], # assessments = [], # safety_measures = [], # AoUs, component requirements - arch_design = ":arch_design", fault_trees = ":samplelibrary_fault_trees", ) @@ -681,6 +680,7 @@ dependable_element( # :public_api_example_link_rendered_test below. architectural_design( name = "arch_design_public_api_example", + package = "package_pub_api", public_api = ["fixtures/clickable_example/public_api_detail.puml"], static = ["fixtures/clickable_example/public_api_overview.puml"], ) @@ -693,6 +693,7 @@ architectural_design( failure_modes( name = "public_api_example_failure_modes", srcs = ["fixtures/clickable_example/public_api_failure_modes.trlc"], + deps = [":arch_design_public_api_example"], ) fault_trees( @@ -703,7 +704,6 @@ fault_trees( safety_analysis( name = "public_api_example_safety_analysis", - arch_design = ":arch_design_public_api_example", failure_modes = [":public_api_example_failure_modes"], fault_trees = ":public_api_example_fault_trees", ) @@ -723,6 +723,7 @@ dependability_analysis( failure_modes( name = "multi_failure_mode_example_failure_modes", srcs = ["fixtures/clickable_example/multi_failure_mode_failure_modes.trlc"], + deps = [":arch_design_public_api_example"], ) fault_trees( @@ -733,7 +734,6 @@ fault_trees( safety_analysis( name = "multi_failure_mode_example_safety_analysis", - arch_design = ":arch_design_public_api_example", failure_modes = [":multi_failure_mode_example_failure_modes"], fault_trees = ":multi_failure_mode_example_fault_trees", ) @@ -751,6 +751,7 @@ dependability_analysis( failure_modes( name = "aou_root_cause_failure_modes", srcs = ["fixtures/aou_root_cause/failure_modes.trlc"], + deps = [":arch_design_public_api_example"], ) fault_trees( @@ -767,7 +768,6 @@ assumptions_of_use( safety_analysis( name = "aou_root_cause_safety_analysis", - arch_design = ":arch_design_public_api_example", assessments = ["fixtures/aou_root_cause/assessments.trlc"], failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", @@ -791,7 +791,6 @@ component_requirements( safety_analysis( name = "comp_req_measure_safety_analysis", - arch_design = ":arch_design_public_api_example", assessments = ["fixtures/comp_req_measure/assessments.trlc"], failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", @@ -811,7 +810,6 @@ build_test( # A measure target with its own spec: the assembler needs the extra RSL to parse it. safety_analysis( name = "measure_spec_safety_analysis", - arch_design = ":arch_design_public_api_example", failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", safety_measures = ["//fixtures/spec_extras:spec_extras_comp_req"], @@ -1405,6 +1403,7 @@ sh_test( architectural_design( name = "arch_design_dynamic_example", dynamic = ["fixtures/clickable_example/dynamic_overview.puml"], + package = "dynamic_example_api", public_api = ["fixtures/clickable_example/dynamic_public_api_detail.puml"], static = ["fixtures/clickable_example/dynamic_component_detail.puml"], ) @@ -1467,6 +1466,7 @@ component( failure_modes( name = "dynamic_example_failure_modes", srcs = ["fixtures/clickable_example/dynamic_public_api_failure_modes.trlc"], + deps = [":arch_design_dynamic_example"], ) fault_trees( @@ -1477,7 +1477,6 @@ fault_trees( safety_analysis( name = "dynamic_example_safety_analysis", - arch_design = ":arch_design_dynamic_example", failure_modes = [":dynamic_example_failure_modes"], fault_trees = ":dynamic_example_fault_trees", ) @@ -1823,7 +1822,6 @@ aou_derived_test( # AoU target contribute it once; only the first assesses the root cause. safety_analysis( name = "aou_root_cause_safety_analysis_dup", - arch_design = ":arch_design_public_api_example", failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", safety_measures = [":aou_root_cause_aous"], @@ -2594,7 +2592,6 @@ lobster_ci_report_test( safety_analysis( name = "unassessed_root_cause_safety_analysis", - arch_design = ":arch_design_public_api_example", failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", ) @@ -2630,7 +2627,6 @@ lobster_ci_report_test( safety_analysis( name = "unused_aou_safety_analysis", - arch_design = ":arch_design_public_api_example", assessments = ["fixtures/aou_root_cause/assessments_unused_aou.trlc"], failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", @@ -2668,7 +2664,6 @@ lobster_ci_report_test( safety_analysis( name = "stale_measure_version_safety_analysis", - arch_design = ":arch_design_public_api_example", assessments = ["fixtures/aou_root_cause/assessments_stale_version.trlc"], failure_modes = [":aou_root_cause_failure_modes"], fault_trees = ":aou_root_cause_fault_trees", diff --git a/bazel/rules/rules_score/test/fixtures/aou_root_cause/failure_modes.trlc b/bazel/rules/rules_score/test/fixtures/aou_root_cause/failure_modes.trlc index 61bde180..c2b21779 100644 --- a/bazel/rules/rules_score/test/fixtures/aou_root_cause/failure_modes.trlc +++ b/bazel/rules/rules_score/test/fixtures/aou_root_cause/failure_modes.trlc @@ -13,6 +13,7 @@ package AouRootCauseSafetyAnalysis import ScoreReq +import package_pub_api ScoreReq.FailureMode ConfigurationWrong { guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -20,5 +21,5 @@ ScoreReq.FailureMode ConfigurationWrong { failureeffect = "Callers never receive a response" version = 1 safety = ScoreReq.Asil.B - interface = "package_pub.PublicInterface" + interface = [package_pub_api.package_pub_PublicInterface_Send] } 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 index d6e0b5fe..d17f1145 100644 --- 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 @@ -13,6 +13,7 @@ package DynamicExampleSafetyAnalysis import ScoreReq +import dynamic_example_api ScoreReq.FailureMode TaskInterfaceFailure { guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -21,5 +22,5 @@ ScoreReq.FailureMode TaskInterfaceFailure { version = 1 safety = ScoreReq.Asil.B - interface = "TaskInterface.AssignTask" + interface = [dynamic_example_api.TaskInterface_AssignTask] } diff --git a/bazel/rules/rules_score/test/fixtures/clickable_example/multi_failure_mode_failure_modes.trlc b/bazel/rules/rules_score/test/fixtures/clickable_example/multi_failure_mode_failure_modes.trlc index f91ce406..612e368a 100644 --- a/bazel/rules/rules_score/test/fixtures/clickable_example/multi_failure_mode_failure_modes.trlc +++ b/bazel/rules/rules_score/test/fixtures/clickable_example/multi_failure_mode_failure_modes.trlc @@ -13,6 +13,7 @@ package MultiFailureModeExampleSafetyAnalysis import ScoreReq +import package_pub_api ScoreReq.FailureMode MessageLost { guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -20,7 +21,7 @@ ScoreReq.FailureMode MessageLost { failureeffect = "Receiver never gets the message" version = 1 safety = ScoreReq.Asil.B - interface = "package_pub.PublicInterface" + interface = [package_pub_api.package_pub_PublicInterface_Send] } ScoreReq.FailureMode MessagePartiallyDelivered { @@ -29,7 +30,7 @@ ScoreReq.FailureMode MessagePartiallyDelivered { failureeffect = "Receiver processes incomplete data" version = 1 safety = ScoreReq.Asil.B - interface = "package_pub.PublicInterface" + interface = [package_pub_api.package_pub_PublicInterface_Send] } ScoreReq.FailureMode MessageCorrupted { @@ -38,5 +39,5 @@ ScoreReq.FailureMode MessageCorrupted { failureeffect = "Receiver processes wrong data" version = 1 safety = ScoreReq.Asil.B - interface = "package_pub.PublicInterface" + interface = [package_pub_api.package_pub_PublicInterface_Send] } 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 index 1a57890e..b8c68850 100644 --- 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 @@ -14,7 +14,9 @@ @startuml PublicInterface package package_pub { - interface PublicInterface + interface PublicInterface { + + Send(): void + } } @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 index 16197598..b571220e 100644 --- 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 @@ -13,6 +13,7 @@ package PublicApiExampleSafetyAnalysis import ScoreReq +import package_pub_api ScoreReq.FailureMode PublicInterfaceFailure { guidewords = [ScoreReq.Guideword.LossOfFunction] @@ -20,5 +21,5 @@ ScoreReq.FailureMode PublicInterfaceFailure { failureeffect = "Callers never receive a response" version = 1 safety = ScoreReq.Asil.B - interface = "package_pub.PublicInterface" + interface = [package_pub_api.package_pub_PublicInterface_Send] } diff --git a/bazel/rules/rules_score/test/safety_analysis_measures_test.bzl b/bazel/rules/rules_score/test/safety_analysis_measures_test.bzl index 19dac328..3a8d256e 100644 --- a/bazel/rules/rules_score/test/safety_analysis_measures_test.bzl +++ b/bazel/rules/rules_score/test/safety_analysis_measures_test.bzl @@ -58,7 +58,7 @@ def _measure_deps_are_context_only_test_impl(ctx): _basenames(_flag_values(argv, "--assessments")), ) asserts.true(env, "aou.trlc" in _basenames(_flag_values(argv, "--dep-files"))) - asserts.true(env, _action_with_output(env, "assessments.lobster") != None) + asserts.true(env, _action_with_output(env, "comp_req_measure_safety_analysis.lobster") != None) return analysistest.end(env) measure_deps_are_context_only_test = analysistest.make(_measure_deps_are_context_only_test_impl) diff --git a/bazel/rules/rules_score/test/test_safety_analysis_assembler.py b/bazel/rules/rules_score/test/test_safety_analysis_assembler.py index 5062ffc6..4c2fcc0d 100644 --- a/bazel/rules/rules_score/test/test_safety_analysis_assembler.py +++ b/bazel/rules/rules_score/test/test_safety_analysis_assembler.py @@ -46,15 +46,6 @@ def __init__(self, objs): def objects_by_fqn(self): return self._objs - def render_table_to_string(self, columns, fqns=None, name_header="Name", link_fn=None): - if fqns is None: - fqns = list(self._objs) - if link_fn is not None: - rows = "\n".join(link_fn(f, self._objs[f].name) for f in fqns) - else: - rows = "\n".join(self._objs[f].name for f in fqns) - return f"TABLE[{name_header}]\n{rows}\n" - def render_records_to_string(self, fqns, fields): return "RECORDS(" + ",".join(fqns) + ")\n" @@ -71,14 +62,14 @@ def _objs(): "FM_A", "FailureMode", { - "guideword": "LossOfFunction", + "guidewords": ["LossOfFunction", "TooLate"], "safety": "B", - "interface": "Lib.Api", + "interface": ["Lib.Api", "Lib.Api2"], "failureeffect": "world ends", "description": "fm a description", }, ), - "Lib.FM_Orphan": _Obj("FM_Orphan", "FailureMode", {"safety": "QM", "guideword": "TooLate"}), + "Lib.FM_Orphan": _Obj("FM_Orphan", "FailureMode", {"safety": "QM", "guidewords": ["TooLate"]}), "Lib.AoU_1": _Obj("AoU_1", "AoU", {"safety": "B", "description": "aou one", "version": 3}), "Lib.AoU_Orphan": _Obj("AoU_Orphan", "AoU", {"safety": "D"}), "Lib.RC_A": _Obj( @@ -290,6 +281,13 @@ def test_overview_and_failure_mode_section_rendered(self): body = fa._build_body(self.renderer, "Title", "Lib", self.diagrams) self.assertIn("Title\n=====", body) self.assertIn("Overview", body) + # Overview table lists each failure mode with its interface. + overview = body.split("Failure Modes\n-------------")[0] + self.assertIn(" * - Failure Mode\n - Guidewords\n - ASIL\n - Interface\n", overview) + self.assertIn( + ":ref:`FM_A `\n - ``LossOfFunction``, ``TooLate``\n - B\n", overview + ) + self.assertIn(" - ``Lib.Api``, ``Lib.Api2``\n", overview) # Top-level grouping sections. self.assertIn("Failure Modes\n-------------", body) # FM dropdown titled by its full fqn only (no ASIL in the heading). @@ -299,13 +297,14 @@ def test_overview_and_failure_mode_section_rendered(self): # Attributes as a grid of cards; no inner requirement id. self.assertNotIn(".. requirement:definition::", body) self.assertIn(".. grid:: 2", body) - # Guideword / ASIL are bare centred grid items (no card chrome). + # Guidewords / ASIL are bare centred grid items (no card chrome). self.assertIn(".. grid-item::", body) self.assertIn(":class: sd-text-center", body) - self.assertIn(":bdg-info:`LossOfFunction`", body) + self.assertIn(":bdg-info:`LossOfFunction` :bdg-info:`TooLate`", body) self.assertIn(":bdg-warning:`ASIL B`", body) self.assertNotIn(".. grid-item-card:: Guideword", body) self.assertIn(".. grid-item-card:: Interface", body) + self.assertIn("``Lib.Api``, ``Lib.Api2``", body) # Description as a prominent card. self.assertIn(".. grid-item-card:: Description", body) self.assertIn("fm a description", body) @@ -433,10 +432,14 @@ def test_no_fault_trees_renders_all_failure_modes_without_fta(self): D } +type ApiMethod { + signatures String [1..*] +} + type FailureMode { - guideword optional String + guidewords optional String [1..*] safety Asil - interface optional String + interface optional ApiMethod [1..*] failureeffect optional String description optional String } @@ -477,14 +480,22 @@ def test_no_fault_trees_renders_all_failure_modes_without_fta(self): package TestSafetyAnalysis FailureMode FmA { - guideword = "TooLate" + guidewords = ["TooLate"] safety = Asil.D - interface = "Lib.Api" + interface = [TestSafetyAnalysis.ApiA] failureeffect = "downstream timeout" description = "fm a description" } """ +_API_TRLC = """\ +package TestSafetyAnalysis + +ApiMethod ApiA { + signatures = ["void Lib::Api::A()"] +} +""" + _AOU_TRLC = """\ package TestSafetyAnalysis @@ -595,6 +606,7 @@ def _run_main(self, argv): def test_full_page_assembled_from_real_trlc(self): with tempfile.TemporaryDirectory() as tmp: rsl = self._write(tmp, "types.rsl", _RSL) + api = self._write(tmp, "api.trlc", _API_TRLC) fm = self._write(tmp, "fm.trlc", _FM_TRLC) aou = self._write(tmp, "aou.trlc", _AOU_TRLC) cr = self._write(tmp, "cr.trlc", _COMPREQ_TRLC) @@ -627,6 +639,7 @@ def test_full_page_assembled_from_real_trlc(self): cr, "--dep-files", rsl, + api, ] ) @@ -664,6 +677,7 @@ def test_full_page_assembled_from_real_trlc(self): def _assemble(self, tmp, failuremodes=(), assessments=(), safetymeasures=(), spec=()): rsl = self._write(tmp, "types.rsl", _RSL) + api = self._write(tmp, "api.trlc", _API_TRLC) fta = self._write(tmp, "fta_events.trlc", _FTA_TRLC) template = self._write(tmp, "tmpl.rst", "{body}\n") argv = [ @@ -686,6 +700,7 @@ def _assemble(self, tmp, failuremodes=(), assessments=(), safetymeasures=(), spe *safetymeasures, "--dep-files", rsl, + api, *spec, ] self._run_main(argv) diff --git a/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl b/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl index 770721df..0daea711 100644 --- a/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl +++ b/bazel/rules/rules_score/trlc/config/score_requirements_model.rsl @@ -139,6 +139,20 @@ enum Guideword "HAZOP-style guide words used to categorize failure mode." { } +// ApiMethod records are generated by puml_cli from the public API class +// diagrams of an architectural_design target into a per-target `public_api.trlc` +// file -- do not author them by hand. One record exists per public method name +// of a class (all overloads) and per free function. + +type ApiMethod "A public method (all overloads) or free function of a public API class diagram, generated from the diagram." { + diagram "Source .puml diagram of the first declaration." + String + line "Source line of the first declaration within its diagram." + Integer + signatures "Declared signatures, one per overload." + String[1 .. *] +} + type FailureMode "A potential failure of a system function, described using one or more HAZOP guide words." extends ScoreReq.RequirementSafety { guidewords "One or more HAZOP guide words classifying the nature of this failure." Guideword[1 .. *] @@ -146,8 +160,8 @@ type FailureMode "A potential failure of a system function, described using one String rationale "Explanation of why this failure mode is considered relevant." optional String - interface "The system interface or signal that is affected by this failure mode." - optional String + interface "The public API methods or functions affected by this failure mode." + optional ApiMethod[1 .. *] } // RootCause records are generated by puml_cli from the $RootCause(...) macro diff --git a/plantuml/parser/README.md b/plantuml/parser/README.md index ff8980d1..a8149aaf 100644 --- a/plantuml/parser/README.md +++ b/plantuml/parser/README.md @@ -22,7 +22,7 @@ The PlantUML Parser is a multi-file parser that processes PlantUML diagram files The parser takes `.puml` source files as input and produces: - **FlatBuffers binary** (`.fbs.bin`) — primary structured output for downstream consumers -- **LOBSTER traceability JSON** (`.lobster`) — for component diagrams, enables traceability linking via the LOBSTER toolchain +- **Public API TRLC** (`--api-output-file`) — one `ScoreReq.ApiMethod` record per public method (all overloads) and per free function of the class diagrams, in the TRLC package given by `--api-package` - **Debug JSON** — raw and resolved ASTs when `--log-level debug` or higher is set @@ -32,8 +32,7 @@ Rust-based parser that produces an AST for the following PlantUML diagram types: - Sequence Diagram - Component Diagram -Outputs are `.fbs.bin` FlatBuffers binaries (diagram AST) and optionally `.lobster` -traceability files (for component diagrams). +Outputs are `.fbs.bin` FlatBuffers binaries (diagram AST). ## Usage @@ -56,7 +55,8 @@ Options: | `--log-level ` | Logging verbosity | `warn` | | `--diagram-type ` | Diagram type hint | `none` | | `--fbs-output-dir ` | Output directory for `.fbs.bin` FlatBuffers files | none (no output) | -| `--lobster-output-dir ` | Output directory for `.lobster` traceability files | none (no output) | +| `--api-output-file ` | Output file for the public API TRLC (`ApiMethod` records of class diagrams); all inputs must be class diagrams; requires `--api-package` | none (no output) | +| `--api-package ` | TRLC package of the public API file (a TRLC identifier); requires `--api-output-file` | none | | `--output-stem ` | Override the file stem used to name every output file for this run, instead of deriving it from the input file's own basename. Requires exactly one input file. | none (uses the input file's basename) | At least one of `--file` or `--folders` is required. Inputs must have the `.puml` diff --git a/plantuml/parser/docs/README.md b/plantuml/parser/docs/README.md index b1aadbc7..71041aa1 100644 --- a/plantuml/parser/docs/README.md +++ b/plantuml/parser/docs/README.md @@ -130,10 +130,10 @@ Interface( | Crate | Responsibility | |---|---| -| `puml_utils` | Shared utilities: `LogLevel` enum, file writing helpers | +| `puml_utils` | Shared utilities: `LogLevel` enum, file writing helpers, TRLC file writer | | `puml_parser` | Preprocessor (include expansion) + Class / Component / Sequence diagram parsers | | `puml_resolver` | Resolves raw AST into logical model (`ComponentResolver`, `DiagramResolver` trait) | -| `puml_lobster` | Converts the resolved model to LOBSTER traceability JSON | +| `puml_api` | Collects the public methods of the resolved class model and renders them as `ScoreReq.ApiMethod` TRLC records | | `puml_cli` | CLI argument parsing and orchestration of all phases | ### Shared Dependencies diff --git a/plantuml/parser/docs/assets/puml_parser.puml b/plantuml/parser/docs/assets/puml_parser.puml index f66b6009..4703d2f8 100644 --- a/plantuml/parser/docs/assets/puml_parser.puml +++ b/plantuml/parser/docs/assets/puml_parser.puml @@ -21,7 +21,6 @@ participant "PumlClassParser" as class participant "PumlComponentParser" as component participant "ComponentResolver" as resolver participant "ComponentSerializer" as serializer -participant "puml_lobster" as lobster activate Cli @@ -87,11 +86,4 @@ serializer --> Cli: Vec deactivate serializer destroy serializer -opt lobster_output_dir provided - Cli -> lobster: model_to_lobster(&components, source_path) - activate lobster - lobster --> Cli: JSON - deactivate lobster -end - @enduml diff --git a/plantuml/parser/docs/assets/puml_parser_class.puml b/plantuml/parser/docs/assets/puml_parser_class.puml index 3347e7eb..1c16cd40 100644 --- a/plantuml/parser/docs/assets/puml_parser_class.puml +++ b/plantuml/parser/docs/assets/puml_parser_class.puml @@ -73,12 +73,6 @@ package "Puml Parser" { } } - package "Lobster Output" { - class LobsterWriter <> { - + model_to_lobster(model: HashMap, source_path: &str) : Value - + write_lobster_to_file(model: HashMap, source_path: &str, output_dir: &Path) - } - } } ' Data Flow @@ -86,6 +80,5 @@ CLI --> Preprocessor : preprocess CLI --> DiagramParser : dispatch CLI --> ComponentResolver : visit_document CLI --> ComponentSerializer : serialize -CLI --> LobsterWriter : write lobster trace @enduml diff --git a/plantuml/parser/puml_lobster/BUILD b/plantuml/parser/puml_api/BUILD similarity index 75% rename from plantuml/parser/puml_lobster/BUILD rename to plantuml/parser/puml_api/BUILD index c0e69c8d..6faf45cb 100644 --- a/plantuml/parser/puml_lobster/BUILD +++ b/plantuml/parser/puml_api/BUILD @@ -14,21 +14,25 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( - name = "puml_lobster", - srcs = ["src/lib.rs"], + name = "puml_api", + srcs = [ + "src/collector.rs", + "src/lib.rs", + "src/naming.rs", + "src/signature.rs", + ], visibility = ["//plantuml/parser:__subpackages__"], deps = [ + "//plantuml/parser/puml_utils", "//tools/metamodel/class:class_diagram", - "//tools/metamodel/component:component_diagram", - "@crates_score_tooling//:serde_json", + "@crates_score_tooling//:thiserror", ], ) rust_test( - name = "puml_lobster_test", - crate = ":puml_lobster", + name = "puml_api_test", + crate = ":puml_api", deps = [ "//tools/metamodel/class:class_diagram", - "//tools/metamodel/component:component_diagram", ], ) diff --git a/plantuml/parser/puml_api/src/collector.rs b/plantuml/parser/puml_api/src/collector.rs new file mode 100644 index 00000000..1f3d7205 --- /dev/null +++ b/plantuml/parser/puml_api/src/collector.rs @@ -0,0 +1,538 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Collects API items diagram by diagram. + +use std::collections::BTreeMap; + +use class_diagram::{ClassDiagram, FreeFunctionDecl, SimpleEntity, Visibility}; + +use crate::naming::{method_part, name_key, record_name}; +use crate::signature::{render_signature, render_template_parameters, SignatureParts}; +use crate::{ApiError, ApiMethod}; + +struct Entry { + method: ApiMethod, + /// Declaration the item stems from (`ns::Cls::method`); overloads share it. + origin: String, +} + +/// Accumulates the API items of class diagrams: the public methods of every +/// entity and every free function. +#[derive(Default)] +pub struct ApiCollector { + /// Items by [`name_key`] (TRLC's name uniqueness rule). + entries: BTreeMap, +} + +impl ApiCollector { + /// Adds the items of `model`, declared in the source file `diagram`. + pub fn add_diagram(&mut self, diagram: &str, model: &ClassDiagram) -> Result<(), ApiError> { + for entity in &model.entities { + self.add_entity(diagram, entity)?; + } + for function in &model.free_functions { + self.add_free_function(diagram, function)?; + } + Ok(()) + } + + /// The collected items, sorted by record name. + pub fn into_methods(self) -> Vec { + let mut methods: Vec = self.entries.into_values().map(|e| e.method).collect(); + methods.sort_by(|a, b| a.name.cmp(&b.name)); + methods + } + + fn add_entity(&mut self, diagram: &str, entity: &SimpleEntity) -> Result<(), ApiError> { + let scope = entity.id.replace('.', "::"); + let mut qualified_scope = scope.clone(); + if let Some(parameters) = &entity.template_parameters { + qualified_scope.push_str(&render_template_parameters(parameters)); + } + for method in entity + .methods + .iter() + .filter(|m| m.visibility == Visibility::Public) + { + let origin = format!("{scope}::{}", method.name); + let member = method_part(&method.name, &method.modifiers, &origin)?; + let record = record_name(Some(&entity.id), &member, &origin)?; + let signature = render_signature(&SignatureParts { + qualified_name: &format!("{qualified_scope}::{}", method.name), + return_type: method.return_type.as_deref(), + parameters: &method.parameters, + template_parameters: method.template_parameters.as_deref(), + modifiers: &method.modifiers, + }); + self.add( + record, + origin, + signature, + diagram, + method.source_location.line, + )?; + } + Ok(()) + } + + fn add_free_function( + &mut self, + diagram: &str, + function: &FreeFunctionDecl, + ) -> Result<(), ApiError> { + let namespace = function + .enclosing_namespace_id + .as_deref() + .filter(|ns| !ns.is_empty()); + let qualified = match namespace { + Some(ns) => format!("{}::{}", ns.replace('.', "::"), function.name), + None => function.name.clone(), + }; + let record = record_name(namespace, &function.name, &qualified)?; + let signature = render_signature(&SignatureParts { + qualified_name: &qualified, + return_type: function.return_type.as_deref(), + parameters: &function.parameters, + template_parameters: function.template_parameters.as_deref(), + modifiers: &[], + }); + self.add( + record, + qualified, + signature, + diagram, + function.source_location.line, + ) + } + + /// Adds `signature` to the item `record`, creating it on first sight. + fn add( + &mut self, + record: String, + origin: String, + signature: String, + diagram: &str, + line: u32, + ) -> Result<(), ApiError> { + let key = name_key(&record); + match self.entries.get_mut(&key) { + None => { + self.entries.insert( + key, + Entry { + method: ApiMethod { + name: record, + signatures: vec![signature], + diagram: diagram.to_string(), + line, + }, + origin, + }, + ); + } + Some(entry) if entry.origin == origin => { + // const and non-const overloads yield equal signatures. + if !entry.method.signatures.contains(&signature) { + entry.method.signatures.push(signature); + } + } + Some(entry) => { + return Err(ApiError::NameCollision { + record, + first: entry.origin.clone(), + first_location: format!("{}:{}", entry.method.diagram, entry.method.line), + second: origin, + second_location: format!("{diagram}:{line}"), + }); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use class_diagram::{ + FunctionArgument, Method, MethodModifier, SourceLocation, TemplateParameter, + }; + + fn method(name: &str, line: u32) -> Method { + Method { + name: name.to_string(), + source_location: SourceLocation::new("d.puml", line), + ..Default::default() + } + } + + fn param(ty: &str) -> FunctionArgument { + FunctionArgument { + param_type: Some(ty.to_string()), + ..Default::default() + } + } + + fn entity(id: &str, methods: Vec) -> SimpleEntity { + SimpleEntity { + id: id.to_string(), + name: id.rsplit('.').next().unwrap().to_string(), + methods, + ..Default::default() + } + } + + fn diagram(entities: Vec, free_functions: Vec) -> ClassDiagram { + ClassDiagram { + name: "d".to_string(), + entities, + free_functions, + } + } + + fn collect_all(diagrams: &[(&str, &ClassDiagram)]) -> Result, ApiError> { + let mut collector = ApiCollector::default(); + for (file, model) in diagrams { + collector.add_diagram(file, model)?; + } + Ok(collector.into_methods()) + } + + fn collect(model: &ClassDiagram) -> Result, ApiError> { + collect_all(&[("pkg/d.puml", model)]) + } + + #[test] + fn overloads_collapse_into_one_record_with_one_signature_each() { + let mut overload = method("OfferService", 15); + overload.return_type = Some("Result".to_string()); + overload.parameters = vec![param("int")]; + let mut first = method("OfferService", 14); + first.return_type = Some("Result".to_string()); + let model = diagram( + vec![entity("score.mw.Skeleton", vec![first, overload])], + vec![], + ); + + let items = collect(&model).unwrap(); + + assert_eq!( + items, + vec![ApiMethod { + name: "score_mw_Skeleton_OfferService".to_string(), + signatures: vec![ + "Result score::mw::Skeleton::OfferService()".to_string(), + "Result score::mw::Skeleton::OfferService(int)".to_string(), + ], + diagram: "pkg/d.puml".to_string(), + line: 14, + }] + ); + } + + #[test] + fn identical_signatures_are_listed_once() { + let model = diagram( + vec![entity("A", vec![method("f", 3), method("f", 4)])], + vec![], + ); + + let items = collect(&model).unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].signatures, vec!["A::f()".to_string()]); + } + + #[test] + fn non_public_methods_are_skipped() { + let mut private = method("hidden", 3); + private.visibility = Visibility::Private; + let mut protected = method("guarded", 4); + protected.visibility = Visibility::Protected; + let model = diagram( + vec![entity("A", vec![private, protected, method("shown", 5)])], + vec![], + ); + + let names: Vec<_> = collect(&model) + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + + assert_eq!(names, vec!["A_shown".to_string()]); + } + + #[test] + fn entities_without_methods_emit_nothing() { + let model = diagram(vec![entity("A", vec![])], vec![]); + + assert!(collect(&model).unwrap().is_empty()); + } + + #[test] + fn operators_use_the_operator_table() { + let model = diagram( + vec![entity( + "ns.A", + vec![ + method("operator==", 3), + method("operator[]", 4), + method("operator()", 5), + method("operator&&", 6), + method("operator&", 7), + ], + )], + vec![], + ); + + let names: Vec<_> = collect(&model) + .unwrap() + .into_iter() + .map(|m| m.name) + .collect(); + + assert_eq!( + names, + vec![ + "ns_A_operator_and", + "ns_A_operator_bitand", + "ns_A_operator_call", + "ns_A_operator_eq", + "ns_A_operator_subscript", + ] + ); + } + + #[test] + fn unknown_operator_is_an_error() { + let model = diagram(vec![entity("A", vec![method("operator@", 3)])], vec![]); + + assert_eq!( + collect(&model), + Err(ApiError::UnsupportedOperator { + origin: "A::operator@".to_string(), + operator: "@".to_string(), + }) + ); + } + + #[test] + fn operator_prefixed_identifier_is_a_plain_method() { + let model = diagram(vec![entity("A", vec![method("operatorX", 3)])], vec![]); + + assert_eq!(collect(&model).unwrap()[0].name, "A_operatorX"); + } + + #[test] + fn constructor_uses_class_name_and_destructor_is_named_destructor() { + let mut ctor = method("A", 3); + ctor.modifiers = vec![MethodModifier::Constructor]; + let mut dtor = method("~A", 4); + dtor.modifiers = vec![MethodModifier::Destructor]; + let model = diagram(vec![entity("ns.A", vec![ctor, dtor])], vec![]); + + let items = collect(&model).unwrap(); + + assert_eq!(items[0].name, "ns_A_A"); + assert_eq!(items[1].name, "ns_A_destructor"); + assert_eq!(items[1].signatures, vec!["ns::A::~A()".to_string()]); + } + + #[test] + fn static_noexcept_and_template_are_part_of_the_signature() { + let mut m = method("Create", 3); + m.return_type = Some("A".to_string()); + m.modifiers = vec![MethodModifier::Static, MethodModifier::Noexcept]; + m.template_parameters = Some(vec![ + TemplateParameter::Type { + name: "T".to_string(), + is_pack: false, + }, + TemplateParameter::Type { + name: "Ts".to_string(), + is_pack: true, + }, + ]); + m.parameters = vec![FunctionArgument { + name: "args".to_string(), + param_type: Some("Ts&&".to_string()), + is_pack_expansion: true, + ..Default::default() + }]; + let model = diagram(vec![entity("A", vec![m])], vec![]); + + assert_eq!( + collect(&model).unwrap()[0].signatures, + vec!["static A A::Create(Ts&&... args) noexcept".to_string()] + ); + } + + #[test] + fn entity_template_parameters_are_part_of_the_signature() { + let mut cls = entity("ns.Box", vec![method("Get", 3)]); + cls.template_parameters = Some(vec![ + TemplateParameter::Type { + name: "T".to_string(), + is_pack: false, + }, + TemplateParameter::NonType { + name: "N".to_string(), + value_type: "int".to_string(), + is_pack: false, + }, + ]); + let model = diagram(vec![cls], vec![]); + + let items = collect(&model).unwrap(); + + assert_eq!(items[0].name, "ns_Box_Get"); + assert_eq!( + items[0].signatures, + vec!["ns::Box::Get()".to_string()] + ); + } + + #[test] + fn c_variadic_parameter_is_rendered() { + let mut m = method("log", 3); + m.parameters = vec![ + param("const char*"), + FunctionArgument { + is_variadic: true, + ..Default::default() + }, + ]; + let model = diagram(vec![entity("A", vec![m])], vec![]); + + assert_eq!( + collect(&model).unwrap()[0].signatures, + vec!["A::log(const char*, ...)".to_string()] + ); + } + + #[test] + fn free_functions_are_named_after_their_namespace() { + let model = diagram( + vec![], + vec![ + FreeFunctionDecl { + name: "clamp".to_string(), + enclosing_namespace_id: Some("amp.algorithm".to_string()), + return_type: Some("int".to_string()), + parameters: vec![param("int")], + source_location: SourceLocation::new("d.puml", 7), + ..Default::default() + }, + FreeFunctionDecl { + name: "main".to_string(), + source_location: SourceLocation::new("d.puml", 9), + ..Default::default() + }, + ], + ); + + let items = collect(&model).unwrap(); + + assert_eq!(items[0].name, "Free_main"); + assert_eq!(items[0].signatures, vec!["main()".to_string()]); + assert_eq!(items[1].name, "amp_algorithm_clamp"); + assert_eq!( + items[1].signatures, + vec!["int amp::algorithm::clamp(int)".to_string()] + ); + assert_eq!(items[1].line, 7); + } + + #[test] + fn free_function_overloads_collapse() { + let overload = |line| FreeFunctionDecl { + name: "f".to_string(), + enclosing_namespace_id: Some("ns".to_string()), + source_location: SourceLocation::new("d.puml", line), + ..Default::default() + }; + let mut second = overload(8); + second.parameters = vec![param("int")]; + let model = diagram(vec![], vec![overload(7), second]); + + let items = collect(&model).unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].signatures.len(), 2); + } + + #[test] + fn names_differing_only_in_case_or_underscores_collide() { + let model = diagram( + vec![entity("A", vec![method("GetX", 3), method("get_x", 4)])], + vec![], + ); + + let error = collect(&model).unwrap_err(); + + assert!(matches!(error, ApiError::NameCollision { .. }), "{error}"); + assert!(error.to_string().contains("A_get_x")); + } + + #[test] + fn different_origins_mapping_to_one_record_collide() { + let model = diagram( + vec![ + entity("ns.A", vec![method("b_c", 3)]), + entity("ns.A_b", vec![method("c", 4)]), + ], + vec![], + ); + + assert!(matches!( + collect(&model), + Err(ApiError::NameCollision { .. }) + )); + } + + #[test] + fn invalid_identifier_characters_are_an_error() { + let model = diagram(vec![entity("ns.Foo", vec![method("f", 3)])], vec![]); + + assert!(matches!(collect(&model), Err(ApiError::InvalidName { .. }))); + } + + #[test] + fn leading_underscore_is_an_error() { + let model = diagram(vec![entity("_A", vec![method("f", 3)])], vec![]); + + assert!(matches!(collect(&model), Err(ApiError::InvalidName { .. }))); + } + + #[test] + fn qualified_method_names_are_joined_with_underscores() { + let model = diagram(vec![entity("A", vec![method("ns::f", 3)])], vec![]); + + assert_eq!(collect(&model).unwrap()[0].name, "A_ns_f"); + } + + #[test] + fn items_of_several_diagrams_are_merged_and_sorted() { + let a = diagram(vec![entity("B", vec![method("f", 3)])], vec![]); + let b = diagram(vec![entity("A", vec![method("f", 4)])], vec![]); + + let items = collect_all(&[("a.puml", &a), ("b.puml", &b)]).unwrap(); + + let located: Vec<_> = items + .iter() + .map(|m| (m.name.as_str(), m.diagram.as_str())) + .collect(); + assert_eq!(located, vec![("A_f", "b.puml"), ("B_f", "a.puml")]); + } +} diff --git a/plantuml/parser/puml_api/src/lib.rs b/plantuml/parser/puml_api/src/lib.rs new file mode 100644 index 00000000..0029d155 --- /dev/null +++ b/plantuml/parser/puml_api/src/lib.rs @@ -0,0 +1,140 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Public API items of resolved class diagrams, rendered as `ScoreReq.ApiMethod` +//! TRLC records (`public_api.trlc`, see [`render_trlc`]). +//! +//! One record is generated per public method name of an entity (all overloads +//! collapsed into one record, one signature each) and per free function. The +//! record name is ` '_'>_`; free functions use their +//! namespace instead of the entity id (`Free_` without a namespace). +//! Operators and destructors are named by an operator table / `destructor`. +//! Constructors and destructors are items like any other public method. +//! Record names are compared like TRLC does (ignoring case and `_`); two items +//! mapping to the same name are an error. +//! +//! The `const` qualifier is not part of a signature yet: const and non-const +//! overloads of a method render identically and are merged. + +mod collector; +mod naming; +mod signature; + +pub use collector::ApiCollector; +use puml_utils::{render_trlc_file, TrlcRecord, TrlcValue}; + +/// One generated `ApiMethod` record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiMethod { + /// TRLC record name. + pub name: String, + /// Declared signatures, one per overload, in diagram order. + pub signatures: Vec, + /// Source diagram of the first declaration. + pub diagram: String, + /// Line of the first declaration. + pub line: u32, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ApiError { + #[error( + "{origin}: `{part}` cannot be part of a TRLC record name; only letters, digits and `_` \ + are allowed and the name must start with a letter" + )] + InvalidName { origin: String, part: String }, + #[error("{origin}: operator `{operator}` has no TRLC record name")] + UnsupportedOperator { origin: String, operator: String }, + #[error( + "`{first}` ({first_location}) and `{second}` ({second_location}) both map to the TRLC \ + record name `{record}`; record names are compared ignoring case and `_`" + )] + NameCollision { + record: String, + first: String, + first_location: String, + second: String, + second_location: String, + }, +} + +/// Renders `methods` as one TRLC file of `ScoreReq.ApiMethod` records in +/// `package`. Every record carries a `:` comment pointing back +/// at its source declaration. Each signature is wrapped in backticks: reports +/// join the signatures of a record with `, `, which signatures contain too. +pub fn render_trlc(package: &str, methods: &[ApiMethod]) -> String { + let records: Vec = methods + .iter() + .map(|method| TrlcRecord { + record_type: "ScoreReq.ApiMethod".to_string(), + name: method.name.clone(), + origin: format!("{}:{}", method.diagram, method.line), + fields: vec![ + ("diagram", TrlcValue::Text(method.diagram.clone())), + ("line", TrlcValue::Integer(u64::from(method.line))), + ( + "signatures", + TrlcValue::List( + method + .signatures + .iter() + .map(|s| TrlcValue::Text(format!("`{s}`"))) + .collect(), + ), + ), + ], + }) + .collect(); + render_trlc_file("public API class diagrams", package, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_emits_one_record_per_item() { + let items = vec![ApiMethod { + name: "A_f".to_string(), + signatures: vec!["void A::f()".to_string(), "void A::f(\"x\")".to_string()], + diagram: "pkg/d.puml".to_string(), + line: 14, + }]; + + assert_eq!( + render_trlc("Pkg_api", &items), + "// GENERATED by puml_cli from public API class diagrams -- do not edit\n\ + package Pkg_api\n\ + \n\ + import ScoreReq\n\ + \n\ + // pkg/d.puml:14\n\ + ScoreReq.ApiMethod A_f {\n\ + \x20 diagram = \"pkg/d.puml\"\n\ + \x20 line = 14\n\ + \x20 signatures = [\"`void A::f()`\", \"`void A::f(\\\"x\\\")`\"]\n\ + }\n\ + \n" + ); + } + + #[test] + fn render_without_items_has_no_import() { + assert_eq!( + render_trlc("Pkg_api", &[]), + "// GENERATED by puml_cli from public API class diagrams -- do not edit\n\ + package Pkg_api\n\ + \n" + ); + } +} diff --git a/plantuml/parser/puml_api/src/naming.rs b/plantuml/parser/puml_api/src/naming.rs new file mode 100644 index 00000000..761ec77d --- /dev/null +++ b/plantuml/parser/puml_api/src/naming.rs @@ -0,0 +1,175 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! TRLC record names of API items. + +use class_diagram::MethodModifier; +use puml_utils::is_trlc_identifier; + +use crate::ApiError; + +/// Record name part of the C++ operator `symbol` (the text after `operator`). +fn operator_name(symbol: &str) -> Option<&'static str> { + Some(match symbol { + "->*" => "arrow_star", + "->" => "arrow", + "()" => "call", + "[]" => "subscript", + "<=>" => "spaceship", + "<<" => "shl", + ">>" => "shr", + "<=" => "le", + ">=" => "ge", + "==" => "eq", + "!=" => "ne", + "&&" => "and", + "||" => "or", + "+=" => "add_assign", + "-=" => "sub_assign", + "*=" => "mul_assign", + "/=" => "div_assign", + "%=" => "mod_assign", + "^=" => "xor_assign", + "&=" => "and_assign", + "|=" => "or_assign", + "++" => "inc", + "--" => "dec", + "+" => "add", + "-" => "sub", + "*" => "mul", + "/" => "div", + "%" => "mod", + "^" => "xor", + "&" => "bitand", + "|" => "bitor", + "~" => "compl", + "!" => "not", + "=" => "assign", + "<" => "lt", + ">" => "gt", + "," => "comma", + _ => return None, + }) +} + +/// Member part of a record name: `destructor`, `operator_` or the method +/// name (a `::` qualification becomes `_`). +pub(crate) fn method_part( + name: &str, + modifiers: &[MethodModifier], + origin: &str, +) -> Result { + if modifiers.contains(&MethodModifier::Destructor) { + return Ok("destructor".to_string()); + } + if let Some(rest) = name.strip_prefix("operator") { + // `operatorX` is a plain identifier; any other continuation (a symbol or + // a separated keyword/type as in `operator bool`) names an operator. + let is_operator = rest + .chars() + .next() + .is_some_and(|c| c.is_whitespace() || !(c.is_ascii_alphanumeric() || c == '_')); + if is_operator { + let symbol = rest.trim(); + return match operator_name(symbol) { + Some(part) => Ok(format!("operator_{part}")), + None => Err(ApiError::UnsupportedOperator { + origin: origin.to_string(), + operator: symbol.to_string(), + }), + }; + } + } + Ok(name.replace("::", "_")) +} + +/// Record name `_` (`Free_` without a scope); checks +/// that it is a valid TRLC identifier. +pub(crate) fn record_name( + scope: Option<&str>, + member: &str, + origin: &str, +) -> Result { + let name = match scope { + Some(scope) => format!("{}_{member}", scope.replace('.', "_")), + None => format!("Free_{member}"), + }; + if is_trlc_identifier(&name) { + Ok(name) + } else { + Err(ApiError::InvalidName { + origin: origin.to_string(), + part: name, + }) + } +} + +/// Key under which TRLC compares record names: ignoring case and `_`. +pub(crate) fn name_key(record: &str) -> String { + record.to_lowercase().replace('_', "") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operator_table_has_no_duplicate_names() { + let symbols = [ + "->*", "->", "()", "[]", "<=>", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "+=", + "-=", "*=", "/=", "%=", "^=", "&=", "|=", "++", "--", "+", "-", "*", "/", "%", "^", + "&", "|", "~", "!", "=", "<", ">", ",", + ]; + let mut seen = std::collections::HashSet::new(); + for symbol in symbols { + let name = operator_name(symbol).expect("operator is in the table"); + assert!(seen.insert(name), "duplicate operator name {name}"); + } + } + + #[test] + fn name_key_ignores_case_and_underscores() { + assert_eq!(name_key("A_get_X"), name_key("agetx")); + } + + #[test] + fn method_part_names_operators_and_plain_identifiers() { + for (name, part) in [ + ("operator()", "operator_call"), + ("operator ==", "operator_eq"), + ("operatorX", "operatorX"), + ("operator_x", "operator_x"), + ("ns::f", "ns_f"), + ] { + assert_eq!(method_part(name, &[], "A::f").as_deref(), Ok(part)); + } + } + + #[test] + fn method_part_rejects_operators_without_a_record_name() { + for (name, operator) in [ + ("operator bool", "bool"), + ("operator new", "new"), + ("operator delete[]", "delete[]"), + ("operator co_await", "co_await"), + ] { + assert_eq!( + method_part(name, &[], "A::f"), + Err(ApiError::UnsupportedOperator { + origin: "A::f".to_string(), + operator: operator.to_string(), + }) + ); + } + } +} diff --git a/plantuml/parser/puml_api/src/signature.rs b/plantuml/parser/puml_api/src/signature.rs new file mode 100644 index 00000000..43215919 --- /dev/null +++ b/plantuml/parser/puml_api/src/signature.rs @@ -0,0 +1,75 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! C++ signature text of API items. + +use class_diagram::{FunctionArgument, MethodModifier, TemplateParameter}; + +/// Everything a signature is made of. +pub(crate) struct SignatureParts<'a> { + /// Name including its scope (`ns::Cls::method`). + pub qualified_name: &'a str, + pub return_type: Option<&'a str>, + pub parameters: &'a [FunctionArgument], + pub template_parameters: Option<&'a [TemplateParameter]>, + pub modifiers: &'a [MethodModifier], +} + +pub(crate) fn render_signature(parts: &SignatureParts) -> String { + let mut out = String::new(); + if parts.modifiers.contains(&MethodModifier::Static) { + out.push_str("static "); + } + if let Some(return_type) = parts.return_type { + out.push_str(return_type); + out.push(' '); + } + out.push_str(parts.qualified_name); + if let Some(parameters) = parts.template_parameters { + out.push_str(&render_template_parameters(parameters)); + } + let rendered: Vec = parts.parameters.iter().map(render_parameter).collect(); + out.push_str(&format!("({})", rendered.join(", "))); + if parts.modifiers.contains(&MethodModifier::Noexcept) { + out.push_str(" noexcept"); + } + out +} + +/// ``: the parameter names, as used in a template-id. +pub(crate) fn render_template_parameters(parameters: &[TemplateParameter]) -> String { + let rendered: Vec = parameters.iter().map(render_template_parameter).collect(); + format!("<{}>", rendered.join(", ")) +} + +fn render_template_parameter(parameter: &TemplateParameter) -> String { + let pack = match parameter { + TemplateParameter::Type { is_pack, .. } + | TemplateParameter::NonType { is_pack, .. } + | TemplateParameter::Template { is_pack, .. } => *is_pack, + }; + format!("{}{}", parameter.name(), if pack { "..." } else { "" }) +} + +fn render_parameter(parameter: &FunctionArgument) -> String { + let mut ty = parameter.param_type.clone().unwrap_or_default(); + if parameter.is_variadic || parameter.is_pack_expansion { + ty.push_str("..."); + } + [ty.as_str(), parameter.name.as_str()] + .iter() + .filter(|part| !part.is_empty()) + .copied() + .collect::>() + .join(" ") +} diff --git a/plantuml/parser/puml_cli/BUILD b/plantuml/parser/puml_cli/BUILD index 2689e959..5f7bba63 100644 --- a/plantuml/parser/puml_cli/BUILD +++ b/plantuml/parser/puml_cli/BUILD @@ -12,15 +12,24 @@ # ******************************************************************************* load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +_SRCS = [ + "src/fta.rs", + "src/inputs.rs", + "src/main.rs", + "src/parsing.rs", + "src/resolving.rs", + "src/sinks.rs", +] + rust_binary( name = "puml_cli", - srcs = ["src/main.rs"], + srcs = _SRCS, crate_root = "src/main.rs", visibility = ["//visibility:public"], deps = [ + "//plantuml/parser/puml_api", "//plantuml/parser/puml_fta", "//plantuml/parser/puml_idmap", - "//plantuml/parser/puml_lobster", "//plantuml/parser/puml_parser", "//plantuml/parser/puml_resolver", "//plantuml/parser/puml_utils", @@ -42,12 +51,12 @@ rust_binary( rust_test( name = "puml_cli_test", - srcs = ["src/main.rs"], + srcs = _SRCS, crate_root = "src/main.rs", deps = [ + "//plantuml/parser/puml_api", "//plantuml/parser/puml_fta", "//plantuml/parser/puml_idmap", - "//plantuml/parser/puml_lobster", "//plantuml/parser/puml_parser", "//plantuml/parser/puml_resolver", "//plantuml/parser/puml_utils", diff --git a/plantuml/parser/puml_cli/src/fta.rs b/plantuml/parser/puml_cli/src/fta.rs new file mode 100644 index 00000000..e6ace0e1 --- /dev/null +++ b/plantuml/parser/puml_cli/src/fta.rs @@ -0,0 +1,119 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! `--fta-output-dir` mode: Fault-Tree-Analysis diagrams to `RootCause` TRLC. + +use log::debug; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; + +use puml_fta::{merge_root_causes, render_trlc_stub, FaultTree, FtaModel}; +use puml_idmap::{write_idmap_to_file, IdMapModel}; +use puml_parser::{DiagramParser, ProcedureParserService}; +use puml_utils::LogLevel; + +use crate::inputs::{collect_input_files, source_path_for_output}; +use crate::sinks::{create_output_dir, IdmapOutputs}; +use crate::Args; + +/// FTA processing pipeline: parse the fault-tree macro calls straight from +/// each source diagram (the metamodel's ``!procedure`` definitions carry no +/// fault-tree topology themselves) and emit the generated ``fta_events.trlc`` +/// stub. Diagrams are analysis-only here; they are staged/rendered as-authored +/// by the Bazel rule, not rewritten by this pipeline. +pub fn run_fta( + args: &Args, + output_dir: &str, + fta_package: &str, + log_level: LogLevel, +) -> Result<(), Box> { + let out = create_output_dir(output_dir)?; + + let inputs = collect_input_files(&args.file, args.folders.as_deref())?; + if inputs.is_empty() { + return Err("No valid PUML files found.".into()); + } + + // Process inputs in a deterministic order for reproducible output. + let mut sorted: Vec> = inputs.into_iter().collect(); + sorted.sort(); + + let mut all_trees: Vec = Vec::new(); + // Fault trees reference each diagram by basename; two inputs sharing a + // basename (in different directories) would be indistinguishable downstream. + let mut seen_basenames: HashSet = HashSet::new(); + // Independent of the basename check above, which only fires on an exact + // basename match: two inputs with the same file *stem* would overwrite + // each other's `.idmap.json`. + let mut idmap_outputs = args + .idmap_output_dir + .as_deref() + .map(|dir| create_output_dir(dir).map(IdmapOutputs::new)) + .transpose()?; + + for file in &sorted { + let basename = file.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + format!( + "input path has no valid UTF-8 file name: {}", + file.display() + ) + })?; + // The metamodel carries only `!procedure` definitions, no fault tree. + if basename == "fta_metamodel.puml" { + continue; + } + if !seen_basenames.insert(basename.to_string()) { + return Err(format!( + "duplicate FTA diagram basename {:?}: inputs in different directories \ + would be indistinguishable in {}", + basename, output_dir, + ) + .into()); + } + + // Analysis only: the fault-tree topology comes entirely from the + // `$FailureMode(...)` / `$RootCause(...)` / gate macro *calls*, which the + // procedure parser reads straight from the source (the `!include + // fta_metamodel.puml` line is inert text). The diagram is rendered + // as-authored by Sphinx/PlantUML, which finds the metamodel via the + // toolchain's global `plantuml.include.path`. + let source = fs::read_to_string(file.as_path())?; + let parsed = ProcedureParserService.parse_file(file, &source, log_level)?; + let model = + FtaModel::from_procedure_file(&parsed).map_err(|e| format!("FTA {basename}: {e}"))?; + all_trees.extend(model.fault_trees(basename)?); + + if let Some(outputs) = idmap_outputs.as_mut() { + outputs.claim(file, "FTA input")?; + let source_file = source_path_for_output(file); + write_idmap_to_file( + IdMapModel::Fta(&model), + file, + Some(&source_file), + None, + outputs.dir(), + )?; + } + + debug!("Processed FTA diagram: {}", file.display()); + } + + let root_causes = merge_root_causes(&all_trees)?; + fs::write( + out.join("fta_events.trlc"), + render_trlc_stub(fta_package, &root_causes), + )?; + Ok(()) +} diff --git a/plantuml/parser/puml_cli/src/inputs.rs b/plantuml/parser/puml_cli/src/inputs.rs new file mode 100644 index 00000000..199a88f3 --- /dev/null +++ b/plantuml/parser/puml_cli/src/inputs.rs @@ -0,0 +1,263 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Input files and the per-file / per-run flags that describe them. + +use std::borrow::Cow; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use puml_utils::is_trlc_identifier; + +/// Collects the input files, deduplicated: every `files` entry in the given +/// order, then the files of `folder` sorted. All of them are resolved as one +/// group per diagram kind. +pub fn collect_input_files( + files: &[String], + folder: Option<&str>, +) -> Result>, Box> { + let mut ordered: Vec> = Vec::new(); + let mut seen: HashSet> = HashSet::new(); + + for file_path in files { + let abs_path = Rc::new(resolve_puml_file(Path::new(file_path))?); + if !seen.insert(Rc::clone(&abs_path)) { + return Err(format!("Duplicate --file entry: {file_path}").into()); + } + ordered.push(abs_path); + } + + if let Some(folder_path) = folder { + let mut folder_files: HashSet> = HashSet::new(); + collect_puml_files_from_folder(Path::new(folder_path), &mut folder_files)?; + let mut folder_files: Vec> = folder_files.into_iter().collect(); + folder_files.sort(); + for path in folder_files { + if seen.insert(Rc::clone(&path)) { + ordered.push(path); + } + } + } + + Ok(ordered) +} + +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 = match std::env::var("BUILD_WORKSPACE_DIRECTORY") { + Ok(dir) => PathBuf::from(dir), + Err(_) => std::env::current_dir()?, + }; + + Ok(base_dir.join(path)) +} + +/// Rejects a per-file flag given neither zero times nor once per input file. +pub fn require_one_per_file_or_none( + values: &[String], + file_count: usize, + flag_name: &str, +) -> Result<(), Box> { + if !values.is_empty() && values.len() != file_count { + return Err(format!( + "--{flag_name} must be given exactly once per input file (in the same \ + order as --file) or not at all; got {file_count} input file(s), \ + {} --{flag_name}", + values.len(), + ) + .into()); + } + Ok(()) +} + +/// 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. +pub 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(()) +} + +/// Validate a user-supplied `--output-stem` value. +/// +/// Unlike `--source-name` (a full relative path embedded *inside* outputs), +/// this becomes the output *filename* stem itself, so it must be a single +/// path component: rejects empty strings and anything containing a `/` or +/// resolving to `.`/`..`. +pub fn validate_output_stem(stem: &str) -> Result<(), Box> { + if stem.is_empty() { + return Err("--output-stem must not be empty".into()); + } + let mut components = Path::new(stem).components(); + let is_single_normal_component = + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + if !is_single_normal_component { + return Err(format!( + "--output-stem must be a single path component (no '/', '.', or '..'), got: {stem}" + ) + .into()); + } + Ok(()) +} + +/// Validate a `--*-package` value: the generated TRLC file is only valid for a +/// plain TRLC identifier. +pub fn validate_trlc_package(flag: &str, package: &str) -> Result<(), Box> { + if is_trlc_identifier(package) { + Ok(()) + } else { + Err(format!( + "--{flag} must be a TRLC identifier (an ASCII letter followed by letters, digits \ + and '_'), got: {package:?}" + ) + .into()) + } +} + +pub fn source_path_for_output(path: &Path) -> String { + let workspace_root = std::env::var("BUILD_WORKSPACE_DIRECTORY") + .ok() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()); + + // 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()) +} + +/// Compute the corrected, stable `source_file` string (embedded *inside* +/// outputs) and the `Rc` that parsers use to stamp `SourceLocation`s, +/// for one input `path`. +/// +/// With `--source-name`, both derive from that name so every `SourceLocation` +/// carries the corrected path too. Without it, `source_file` is +/// `source_path_for_output(path)` and `SourceLocation`s keep using `path` +/// itself. +pub fn output_source_path(source_name: Option<&str>, path: &Rc) -> (String, Rc) { + match source_name { + Some(name) => { + let source_file = name.to_string(); + let source_path = Rc::new(PathBuf::from(&source_file)); + (source_file, source_path) + } + None => (source_path_for_output(path), Rc::clone(path)), + } +} + +/// Compute the path whose file stem drives every output *filename* for one +/// input `path` (the `write_*_to_file` helpers all derive their output name +/// from a passed-in path's file stem alone). +/// +/// `output_stem` (`--output-stem`) overrides the stem outright; otherwise +/// `path` itself is used unchanged. +pub fn output_naming_path<'a>(output_stem: Option<&str>, path: &'a Path) -> Cow<'a, Path> { + match output_stem { + Some(stem) => Cow::Owned(path.with_file_name(stem)), + None => Cow::Borrowed(path), + } +} + +/// Resolve a `--file`/folder-entry path to an absolute path, checking it is +/// an existing `.puml` file. +fn resolve_puml_file(path: &Path) -> Result> { + let abs_path = resolve_path(path)?; + + if !abs_path.is_file() { + return Err(format!("Path is not a file: {}", path.display()).into()); + } + if abs_path.extension().and_then(|ext| ext.to_str()) != Some("puml") { + return Err(format!("File is not a .puml file: {}", path.display()).into()); + } + Ok(abs_path) +} + +fn collect_puml_files_from_folder( + dir: &Path, + file_list: &mut HashSet>, +) -> Result<(), Box> { + let abs_dir = resolve_path(dir)?; + + if !abs_dir.is_dir() { + return Err(format!("Path is not a directory: {}", dir.display()).into()); + } + collect_puml_files(&abs_dir, file_list)?; + Ok(()) +} + +fn collect_puml_files( + dir: &Path, + file_list: &mut HashSet>, +) -> Result<(), Box> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_puml_files(&path, file_list)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("puml") { + file_list.insert(Rc::new(path.to_path_buf())); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_must_be_a_trlc_identifier() { + assert!(validate_trlc_package("api-package", "Pkg_api").is_ok()); + + let error = validate_trlc_package("api-package", "_pkg").unwrap_err(); + assert!(error + .to_string() + .starts_with("--api-package must be a TRLC identifier")); + assert!(validate_trlc_package("api-package", "").is_err()); + assert!(validate_trlc_package("api-package", "a.b").is_err()); + assert!(validate_trlc_package("api-package", "1a").is_err()); + } +} diff --git a/plantuml/parser/puml_cli/src/main.rs b/plantuml/parser/puml_cli/src/main.rs index 91e28f36..d844c9fc 100644 --- a/plantuml/parser/puml_cli/src/main.rs +++ b/plantuml/parser/puml_cli/src/main.rs @@ -11,34 +11,32 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* +mod fta; +mod inputs; +mod parsing; +mod resolving; +mod sinks; + use clap::{ArgGroup, Parser, ValueEnum}; use env_logger::Builder; use log::debug; -use serde::Serialize; -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::path::PathBuf; use std::rc::Rc; -use activity_diagram::ActivityDiagram; -use activity_serializer::ActivitySerializer; -use class_serializer::ClassSerializer; -use component_serializer::ComponentSerializer; -use sequence_serializer::SequenceSerializer; - -use puml_fta::{merge_root_causes, render_trlc_stub, FaultTree, 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, - PumlClassParser, PumlComponentParser, PumlSequenceParser, +use puml_parser::Preprocessor; +use puml_utils::LogLevel; + +use crate::fta::run_fta; +use crate::inputs::{ + collect_input_files, output_naming_path, output_source_path, require_one_per_file_or_none, + validate_output_stem, validate_source_name, validate_trlc_package, }; -use puml_resolver::{ - ActivityResolver, ClassResolver, ComponentResolver, DiagramResolver, DiagramSource, - SequenceResolver, +use crate::parsing::{parse_puml_file, DiagramType, ParsedDiagram}; +use crate::resolving::resolve_parsed_diagrams; +use crate::sinks::{ + create_output_dir, DiagramOutput, FbsSink, IdmapSink, OutputSink, PublicApiSink, }; -use puml_utils::{write_fbs_to_file, write_json_to_file, LogLevel}; /// CLI wrapper for LogLevel that implements ValueEnum #[derive(Copy, Clone, ValueEnum, Debug)] @@ -95,13 +93,17 @@ struct Args { #[arg(long)] fbs_output_dir: Option, - /// Output directory for generated lobster files (optional). - /// When set, a .lobster is written for each diagram that resolves - /// to a Component or Class model (independent of --fbs-output-dir). - /// On resolve errors a placeholder empty .lobster is written so the - /// build output set is always complete. - #[arg(long)] - lobster_output_dir: Option, + /// Output file for the generated public API TRLC (optional). + /// When set, it holds the ``ApiMethod`` records of the public methods and + /// free functions of all input class diagrams (see ``--api-package``); + /// any other diagram kind among the inputs is an error. + #[arg(long, requires = "api_package")] + api_output_file: Option, + + /// TRLC package name of the ``--api-output-file``; a TRLC identifier. + /// Required together with ``--api-output-file``. + #[arg(long, requires = "api_output_file")] + api_package: Option, /// Output directory for Fault-Tree-Analysis artifacts (optional). /// When set, every input diagram is treated as an FTA and @@ -110,12 +112,12 @@ struct Args { /// FlatBuffers / component processing is performed in this mode. #[arg( long, - conflicts_with_all = ["fbs_output_dir", "lobster_output_dir", "source_name", "output_stem"] + conflicts_with_all = ["fbs_output_dir", "api_output_file", "source_name", "output_stem"] )] fta_output_dir: Option, - /// TRLC package name for the generated ``fta_events.trlc`` stub file. - /// Required together with ``--fta-output-dir``. + /// TRLC package name for the generated ``fta_events.trlc`` stub file; a + /// TRLC identifier. Required together with ``--fta-output-dir``. #[arg(long)] fta_package: Option, @@ -140,24 +142,6 @@ struct Args { output_stem: Vec, } -#[derive(Copy, Clone, ValueEnum, Debug)] -enum DiagramType { - None, - Activity, - Component, - Deployment, - Class, - Sequence, -} - -#[derive(Debug, Serialize)] -enum ParsedDiagram { - Activity(puml_parser::RawActivityDiagram), - Component(puml_parser::CompPumlDocument), - Class(puml_parser::ClassUmlFile), - Sequence(puml_parser::SeqPumlDocument), -} - fn main() { if let Err(e) = run() { eprintln!("{e}"); @@ -180,6 +164,7 @@ fn execute(args: &Args, log_level: LogLevel) -> Result<(), Box Result<(), Box= log::LevelFilter::Debug; + debug!("Resolving {} diagram(s)", parsed.len()); + let source_files: Vec = inputs.iter().map(|d| d.source_file.clone()).collect(); + let resolved_diagrams = resolve_parsed_diagrams(&source_files, &parsed) + .map_err(|e| format!("Resolve error: {e}"))?; - // 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(); + for (input, resolved) in inputs.iter().zip(&resolved_diagrams) { + debug!( + "Successfully resolved PlantUML document: {}", + input.source_file + ); + let output = DiagramOutput { + naming_path: &input.naming_path, + source_file: &input.source_file, + diagram_name: input.diagram_name.as_deref(), + resolved, + }; + for sink in sinks.iter_mut() { + sink.write(&output)?; + } + } + for sink in sinks.iter_mut() { + sink.finish()?; + } - let file_list = collect_files_from_args(args)?; + debug!("Parsing completed"); + Ok(()) +} + +/// The sink of every output flag that is set. +fn build_sinks( + args: &Args, + log_level: LogLevel, +) -> Result>, Box> { + let mut sinks: Vec> = Vec::new(); + + if let Some(dir) = &args.fbs_output_dir { + let emit_debug_json = log_level.to_level_filter() >= log::LevelFilter::Debug; + sinks.push(Box::new(FbsSink::new( + create_output_dir(dir)?, + emit_debug_json, + ))); + } + if let (Some(file), Some(package)) = (&args.api_output_file, &args.api_package) { + validate_trlc_package("api-package", package)?; + sinks.push(Box::new(PublicApiSink::new( + PathBuf::from(file), + package.clone(), + ))); + } + if let Some(dir) = &args.idmap_output_dir { + sinks.push(Box::new(IdmapSink::new(create_output_dir(dir)?))); + } + + Ok(sinks) +} + +/// What the outputs need to know about an input besides its resolved model. +struct InputDiagram { + /// Path whose file stem names the output files. + naming_path: PathBuf, + /// Source path embedded in the outputs. + source_file: String, + /// The `@startuml ` of a component diagram; the resolver drops it. + diagram_name: Option, +} + +/// Collects, preprocesses and parses the input files, showing each parsed +/// diagram to `sinks`. Both results hold one entry per input, in input order. +fn load_diagrams( + args: &Args, + log_level: LogLevel, + sinks: &mut [Box], +) -> Result<(Vec, Vec), Box> { + let file_list = collect_input_files(&args.file, args.folders.as_deref())?; if file_list.is_empty() { return Err("No valid PUML files found.".into()); @@ -228,726 +278,40 @@ fn execute(args: &Args, log_level: LogLevel) -> Result<(), Box = Vec::with_capacity(file_list.len()); - let mut source_files: Vec = Vec::with_capacity(file_list.len()); - let mut diagram_names: Vec> = Vec::with_capacity(file_list.len()); - let mut parsed_diagrams: Vec = Vec::with_capacity(file_list.len()); - + let mut inputs = Vec::with_capacity(file_list.len()); + let mut parsed_diagrams = Vec::with_capacity(file_list.len()); for (i, (path, content)) in preprocessed.iter().enumerate() { - // `naming_path` names the output files; `source_file` is the path - // embedded in outputs. Parsers only stamp `source_path` into - // `SourceLocation`s, so a path built from `source_file` suffices. + // Parsers only stamp `source_path` into `SourceLocation`s, so a path + // built from `source_file` suffices. let output_stem = args.output_stem.get(i).map(String::as_str); let source_name = args.source_name.get(i).map(String::as_str); let naming_path = output_naming_path(output_stem, path).into_owned(); let (source_file, source_path) = output_source_path(source_name, path); - let parsed_content = parse_puml_file(&source_path, content, log_level, args.diagram_type) + let parsed = parse_puml_file(&source_path, content, log_level, args.diagram_type) .map_err(|e| std::io::Error::other(e.to_string()))?; - if emit_debug_json { - if let Some(ref dir) = fbs_output_dir { - write_json_to_file(&parsed_content, &naming_path, dir, "raw.ast")?; - } + for sink in sinks.iter_mut() { + sink.parsed(&naming_path, &parsed)?; } - // The resolver discards the `@startuml `; keep it. - diagram_names.push(match &parsed_content { + let diagram_name = match &parsed { ParsedDiagram::Component(doc) => doc.name.clone(), _ => None, + }; + inputs.push(InputDiagram { + naming_path, + source_file, + diagram_name, }); - naming_paths.push(naming_path); - source_files.push(source_file); - parsed_diagrams.push(parsed_content); - } - - debug!("Resolving {} diagram(s)", parsed_diagrams.len()); - let resolved_diagrams = resolve_parsed_diagrams(&source_files, &parsed_diagrams) - .map_err(|e| format!("Resolve error: {e}"))?; - - for (i, logic_result) in resolved_diagrams.into_iter().enumerate() { - let path = &file_list[i]; - let naming_path = &naming_paths[i]; - let source_file = &source_files[i]; - - debug!( - "Successfully resolved PlantUML document: {}", - path.display() - ); - if emit_debug_json { - if let Some(ref dir) = fbs_output_dir { - write_json_to_file(&logic_result, naming_path, dir, "logic.ast")?; - } - } - - let fbs_buffer = serialize_resolved_diagram(&logic_result); - if let Some(ref dir) = fbs_output_dir { - write_fbs_to_file(&fbs_buffer, naming_path, dir)?; - } - - if let Some(ldir) = &lobster_output_dir { - let lobster_model = match &logic_result { - ResolvedDiagram::Component(model) => LobsterModel::Component(model), - ResolvedDiagram::Class(model) => LobsterModel::Class(model), - ResolvedDiagram::Activity(_) => LobsterModel::Empty, - ResolvedDiagram::Sequence(_) => LobsterModel::Empty, - }; - write_lobster_to_file(lobster_model, naming_path, source_file, ldir)?; - } - - if let Some(idir) = &idmap_output_dir { - claim_idmap_output(naming_path, idir, &mut seen_idmap_outputs, "input")?; - - match idmap_model_for(&logic_result) { - Some(idmap_model) => { - write_idmap_to_file( - idmap_model, - naming_path, - Some(source_file), - diagram_names[i].as_deref(), - idir, - )?; - } - None => { - write_empty_idmap_to_file(naming_path, Some(source_file), idir)?; - } - } - } - } - - debug!("Parsing completed"); - Ok(()) -} - -/// Resolves the diagrams of each kind in `parsed` as one group; `names` (one -/// per diagram) attribute errors to files. Results keep the order of `parsed`. -fn resolve_parsed_diagrams( - names: &[String], - parsed: &[ParsedDiagram], -) -> Result, Box> { - let mut activity = Vec::new(); - let mut component = Vec::new(); - let mut class = Vec::new(); - let mut sequence = Vec::new(); - for (i, doc) in parsed.iter().enumerate() { - match doc { - ParsedDiagram::Activity(d) => activity.push((i, d)), - ParsedDiagram::Component(d) => component.push((i, d)), - ParsedDiagram::Class(d) => class.push((i, d)), - ParsedDiagram::Sequence(d) => sequence.push((i, d)), - } - } - - let mut results: Vec> = (0..parsed.len()).map(|_| None).collect(); - resolve_bucket::(names, &activity, ResolvedDiagram::Activity, &mut results)?; - resolve_bucket::( - names, - &component, - ResolvedDiagram::Component, - &mut results, - )?; - resolve_bucket::(names, &class, ResolvedDiagram::Class, &mut results)?; - resolve_bucket::(names, &sequence, ResolvedDiagram::Sequence, &mut results)?; - - Ok(results - .into_iter() - .map(|r| r.expect("every diagram is in exactly one bucket")) - .collect()) -} - -/// Resolves the `(index, document)` entries of one kind as a group and stores -/// each result at its index. -fn resolve_bucket( - names: &[String], - bucket: &[(usize, &R::Document)], - wrap: fn(R::Output) -> ResolvedDiagram, - results: &mut [Option], -) -> Result<(), Box> -where - R: DiagramResolver + Default, -{ - if bucket.is_empty() { - return Ok(()); - } - let sources: Vec> = bucket - .iter() - .map(|&(i, document)| DiagramSource { - name: names[i].as_str(), - document, - }) - .collect(); - let resolved = R::resolve_group(&sources)?; - for (&(i, _), output) in bucket.iter().zip(resolved) { - results[i] = Some(wrap(output)); - } - Ok(()) -} - -/// Creates the output directory of an `--*-output-dir` flag, if given. -fn prepare_output_dir(dir: &Option) -> Result, Box> { - match dir { - Some(dir) => { - let p = PathBuf::from(dir); - fs::create_dir_all(&p)?; - Ok(Some(p)) - } - None => Ok(None), - } -} - -/// Claims `naming_path`'s `.idmap.json` path; errors if another -/// `input_kind` file claimed it this run. -fn claim_idmap_output( - naming_path: &Path, - idir: &Path, - seen_idmap_outputs: &mut HashSet, - input_kind: &str, -) -> Result<(), Box> { - let output_path = puml_idmap::idmap_output_path(naming_path, idir); - if !seen_idmap_outputs.insert(output_path.clone()) { - return Err(format!( - "duplicate idmap output {}: multiple {} files map to the \ - same .idmap.json in {}; rename one of the inputs so \ - their file stems differ", - output_path.display(), - input_kind, - idir.display(), - ) - .into()); - } - Ok(()) -} - -/// FTA processing pipeline: parse the fault-tree macro calls straight from -/// each source diagram (the metamodel's ``!procedure`` definitions carry no -/// fault-tree topology themselves) and emit the generated ``fta_events.trlc`` -/// stub. Diagrams are analysis-only here; they are staged/rendered as-authored -/// by the Bazel rule, not rewritten by this pipeline. -fn run_fta( - args: &Args, - output_dir: &str, - fta_package: &str, - log_level: LogLevel, -) -> Result<(), Box> { - let out = PathBuf::from(output_dir); - fs::create_dir_all(&out)?; - - let inputs = collect_files_from_args(args)?; - if inputs.is_empty() { - return Err("No valid PUML files found.".into()); + parsed_diagrams.push(parsed); } - - // Process inputs in a deterministic order for reproducible output. - let mut sorted: Vec> = inputs.into_iter().collect(); - sorted.sort(); - - let mut all_trees: Vec = Vec::new(); - // Fault trees 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(|| { - format!( - "input path has no valid UTF-8 file name: {}", - file.display() - ) - })?; - // The metamodel carries only `!procedure` definitions, no fault tree. - if basename == "fta_metamodel.puml" { - continue; - } - if !seen_basenames.insert(basename.to_string()) { - return Err(format!( - "duplicate FTA diagram basename {:?}: inputs in different directories \ - would be indistinguishable in {}", - basename, output_dir, - ) - .into()); - } - - // Analysis only: the fault-tree topology comes entirely from the - // `$FailureMode(...)` / `$RootCause(...)` / gate macro *calls*, which the - // procedure parser reads straight from the source (the `!include - // fta_metamodel.puml` line is inert text). The diagram is rendered - // as-authored by Sphinx/PlantUML, which finds the metamodel via the - // toolchain's global `plantuml.include.path`. - let source = fs::read_to_string(file.as_path())?; - let parsed = ProcedureParserService.parse_file(file, &source, log_level)?; - let model = - FtaModel::from_procedure_file(&parsed).map_err(|e| format!("FTA {basename}: {e}"))?; - all_trees.extend(model.fault_trees(basename)?); - - if let Some(ref idir_str) = args.idmap_output_dir { - let idir = PathBuf::from(idir_str); - claim_idmap_output(file, &idir, &mut seen_idmap_outputs, "FTA input")?; - 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()); - } - - let root_causes = merge_root_causes(&all_trees)?; - fs::write( - out.join("fta_events.trlc"), - render_trlc_stub(fta_package, &root_causes), - )?; - Ok(()) -} - -fn serialize_resolved_diagram(resolved_content: &ResolvedDiagram) -> Vec { - match resolved_content { - ResolvedDiagram::Activity(resolved_content) => { - ActivitySerializer::serialize(resolved_content) - } - ResolvedDiagram::Component(resolved_content) => { - ComponentSerializer::serialize(resolved_content) - } - ResolvedDiagram::Class(resolved_content) => ClassSerializer::serialize(resolved_content), - ResolvedDiagram::Sequence(resolved_content) => { - SequenceSerializer::serialize(resolved_content) - } - } -} - -#[derive(Debug, Serialize)] -pub enum ResolvedDiagram { - Activity(ActivityDiagram), - Component(HashMap), - Class(class_diagram::ClassDiagram), - 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, - } -} - -/// Resolves one diagram alone. -#[cfg(test)] -fn resolve_single(parsed: ParsedDiagram) -> Result> { - resolve_parsed_diagrams(&[String::new()], &[parsed]).map(|mut resolved| resolved.remove(0)) -} - -fn parse_with_parser

( - parser: &mut P, - path: &Rc, - content: &str, - log_level: LogLevel, -) -> Result> -where - P: DiagramParser, - P::Output: std::fmt::Debug, - P::Error: std::error::Error + 'static, -{ - let parsed_content = parser - .parse_file(path, content, log_level) - .map_err(|e| Box::new(e) as Box)?; - - debug!("Successfully parsed PlantUML document: {}", path.display()); - Ok(parsed_content) -} - -// lobster-trace: Tools.ArchitectureModelingSyntax -fn parse_puml_file( - path: &Rc, - content: &str, - log_level: LogLevel, - diagram_type: DiagramType, -) -> Result> { - match diagram_type { - DiagramType::Activity => { - parse_with_parser(&mut PumlActivityParser, path, content, log_level) - .map(ParsedDiagram::Activity) - } - DiagramType::Component | DiagramType::Deployment => { - match PumlComponentParser.parse_file(path, content, log_level) { - Ok(doc) => { - debug!("Successfully parsed PlantUML document: {}", path.display()); - Ok(ParsedDiagram::Component(doc)) - } - Err(component_err) => { - Err(diagnose_wrong_kind(path, content, log_level, component_err)) - } - } - } - DiagramType::Class => parse_with_parser(&mut PumlClassParser, path, content, log_level) - .map(ParsedDiagram::Class), - DiagramType::Sequence => { - parse_with_parser(&mut PumlSequenceParser, path, content, log_level) - .map(ParsedDiagram::Sequence) - } - DiagramType::None => parse_in_order(path, content, log_level), - } -} - -type ParseAttempt<'a> = (&'a str, Box, Option<(usize, usize)>); - -/// Names the kind of a file that is not a Component diagram, when another -/// parser accepts it; otherwise returns the component parse error. -fn diagnose_wrong_kind( - path: &Rc, - content: &str, - log_level: LogLevel, - component_err: ::Error, -) -> Box -where - ::Error: std::error::Error + 'static, -{ - let kind = if PumlClassParser.parse_file(path, content, log_level).is_ok() { - "Class" - } else if PumlActivityParser - .parse_file(path, content, log_level) - .is_ok() - { - "Activity" - } else if PumlSequenceParser - .parse_file(path, content, log_level) - .is_ok() - { - "Sequence" - } else { - return Box::new(component_err); - }; - format!( - "{}: expected a Component diagram, found a {kind} diagram", - path.display() - ) - .into() -} - -fn parse_in_order( - path: &Rc, - content: &str, - log_level: LogLevel, -) -> Result> { - // Each attempt records the parser name, the boxed error, and the source - // location extracted from the concrete type before boxing. - let mut attempts: Vec> = Vec::new(); - - match PumlComponentParser.parse_file(path, content, log_level) { - Ok(doc) => { - debug!("Successfully detected as Component diagram"); - return Ok(ParsedDiagram::Component(doc)); - } - Err(e) => { - let loc = e.error_location(); - debug!("Component parser failed at {:?}: {}", loc, e); - attempts.push(("Component", Box::new(e), loc)); - } - } - - match PumlActivityParser.parse_file(path, content, log_level) { - Ok(doc) => { - debug!("Successfully detected as Activity diagram"); - return Ok(ParsedDiagram::Activity(doc)); - } - Err(e) => { - let loc = e.error_location(); - debug!("Activity parser failed at {:?}: {}", loc, e); - attempts.push(("Activity", Box::new(e), loc)); - } - } - - match PumlClassParser.parse_file(path, content, log_level) { - Ok(doc) => { - debug!("Successfully detected as Class diagram"); - return Ok(ParsedDiagram::Class(doc)); - } - Err(e) => { - let loc = e.error_location(); - debug!("Class parser failed at {:?}: {}", loc, e); - attempts.push(("Class", Box::new(e), loc)); - } - } - - match PumlSequenceParser.parse_file(path, content, log_level) { - Ok(doc) => { - debug!("Successfully detected as Sequence diagram"); - return Ok(ParsedDiagram::Sequence(doc)); - } - Err(e) => { - let loc = e.error_location(); - debug!("Sequence parser failed at {:?}: {}", loc, e); - attempts.push(("Sequence", Box::new(e), loc)); - } - } - - // The parser that reached the furthest line is the most informative one. - let best = attempts - .iter() - .max_by_key(|(_, _, loc)| loc.map_or(0, |(line, _)| line)); - - let tried_names: Vec<&str> = attempts.iter().map(|(n, _, _)| *n).collect(); - - let detail = match best { - Some((best_name, best_err, Some((line_no, _col)))) => { - let source_line = content - .lines() - .nth(line_no - 1) - .unwrap_or("") - .trim(); - format!( - "\n Parsers tried: {}\n Parser with longest match: {}\n Failed at line {}: {}\n Error: {}", - tried_names.join(", "), - best_name, - line_no, - source_line, - best_err, - ) - } - Some((best_name, best_err, None)) => { - format!( - "\n Parsers tried: {}\n Parser with longest match: {}\n Error: {}", - tried_names.join(", "), - best_name, - best_err, - ) - } - None => String::new(), - }; - - Err(format!( - "Failed to parse {} with any available parser{}", - path.display(), - detail, - ) - .into()) -} - -/// Collects the input files, deduplicated: every `--file` in the given order, -/// then the `--folders` files sorted. All of them are resolved as one group -/// per diagram kind. -fn collect_files_from_args(args: &Args) -> Result>, Box> { - let mut ordered: Vec> = Vec::new(); - let mut seen: HashSet> = HashSet::new(); - - for file_path in &args.file { - let abs_path = Rc::new(resolve_puml_file(Path::new(file_path))?); - if !seen.insert(Rc::clone(&abs_path)) { - return Err(format!("Duplicate --file entry: {file_path}").into()); - } - ordered.push(abs_path); - } - - if let Some(folder_path) = &args.folders { - let mut folder_files: HashSet> = HashSet::new(); - collect_puml_files_from_folder(Path::new(folder_path), &mut folder_files)?; - let mut folder_files: Vec> = folder_files.into_iter().collect(); - folder_files.sort(); - for path in folder_files { - if seen.insert(Rc::clone(&path)) { - ordered.push(path); - } - } - } - - Ok(ordered) -} - -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 = match std::env::var("BUILD_WORKSPACE_DIRECTORY") { - Ok(dir) => PathBuf::from(dir), - Err(_) => std::env::current_dir()?, - }; - - Ok(base_dir.join(path)) -} - -/// Rejects a per-file flag given neither zero times nor once per input file. -fn require_one_per_file_or_none( - values: &[String], - file_count: usize, - flag_name: &str, -) -> Result<(), Box> { - if !values.is_empty() && values.len() != file_count { - return Err(format!( - "--{flag_name} must be given exactly once per input file (in the same \ - order as --file) or not at all; got {file_count} input file(s), \ - {} --{flag_name}", - values.len(), - ) - .into()); - } - Ok(()) -} - -/// 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) - .or_else(|| std::env::current_dir().ok()); - - // 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()) -} - -/// Validate a user-supplied `--output-stem` value. -/// -/// Unlike `--source-name` (a full relative path embedded *inside* outputs), -/// this becomes the output *filename* stem itself, so it must be a single -/// path component: rejects empty strings and anything containing a `/` or -/// resolving to `.`/`..`. -fn validate_output_stem(stem: &str) -> Result<(), Box> { - if stem.is_empty() { - return Err("--output-stem must not be empty".into()); - } - let mut components = Path::new(stem).components(); - let is_single_normal_component = - matches!(components.next(), Some(std::path::Component::Normal(_))) - && components.next().is_none(); - if !is_single_normal_component { - return Err(format!( - "--output-stem must be a single path component (no '/', '.', or '..'), got: {stem}" - ) - .into()); - } - Ok(()) -} - -/// Compute the corrected, stable `source_file` string (embedded *inside* -/// outputs) and the `Rc` that parsers use to stamp `SourceLocation`s, -/// for one input `path`. -/// -/// With `--source-name`, both derive from that name so every `SourceLocation` -/// carries the corrected path too. Without it, `source_file` is -/// `source_path_for_output(path)` and `SourceLocation`s keep using `path` -/// itself. -fn output_source_path(source_name: Option<&str>, path: &Rc) -> (String, Rc) { - match source_name { - Some(name) => { - let source_file = name.to_string(); - let source_path = Rc::new(PathBuf::from(&source_file)); - (source_file, source_path) - } - None => (source_path_for_output(path), Rc::clone(path)), - } -} - -/// Compute the path whose file stem drives every output *filename* for one -/// input `path` (the `write_*_to_file` helpers all derive their output name -/// from a passed-in path's file stem alone). -/// -/// `output_stem` (`--output-stem`) overrides the stem outright; otherwise -/// `path` itself is used unchanged. -fn output_naming_path<'a>(output_stem: Option<&str>, path: &'a Path) -> Cow<'a, Path> { - match output_stem { - Some(stem) => Cow::Owned(path.with_file_name(stem)), - None => Cow::Borrowed(path), - } -} - -/// Resolve a `--file`/folder-entry path to an absolute path, checking it is -/// an existing `.puml` file. -fn resolve_puml_file(path: &Path) -> Result> { - let abs_path = resolve_path(path)?; - - if !abs_path.is_file() { - return Err(format!("Path is not a file: {}", path.display()).into()); - } - if abs_path.extension().and_then(|ext| ext.to_str()) != Some("puml") { - return Err(format!("File is not a .puml file: {}", path.display()).into()); - } - Ok(abs_path) -} - -fn collect_puml_files_from_folder( - dir: &Path, - file_list: &mut HashSet>, -) -> Result<(), Box> { - let abs_dir = resolve_path(dir)?; - - if !abs_dir.is_dir() { - return Err(format!("Path is not a directory: {}", dir.display()).into()); - } - collect_puml_files(&abs_dir, file_list)?; - Ok(()) -} - -fn collect_puml_files( - dir: &Path, - file_list: &mut HashSet>, -) -> Result<(), Box> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_puml_files(&path, file_list)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("puml") { - file_list.insert(Rc::new(path.to_path_buf())); - } - } - Ok(()) + Ok((inputs, parsed_diagrams)) } #[cfg(test)] mod sequence_pipeline_tests { use super::*; + use crate::resolving::resolve_single; /// Parsing a sequence diagram must succeed end-to-end (parse → resolve). /// Before the fix this returned Err("Sequence diagrams not implemented"). @@ -977,6 +341,7 @@ B --> A : reply #[cfg(test)] mod group_pipeline_tests { use super::*; + use crate::resolving::ResolvedDiagram; fn parse(name: &str, content: &str, diagram_type: DiagramType) -> ParsedDiagram { let path = Rc::new(PathBuf::from(name)); @@ -1077,6 +442,8 @@ mod group_pipeline_tests { mod cli_input_tests { use super::*; use clap::Parser; + use std::fs; + use std::path::Path; fn unique_dir(tag: &str) -> PathBuf { let nanos = std::time::SystemTime::now() @@ -1118,7 +485,7 @@ mod cli_input_tests { dir.to_str().unwrap(), "--diagram-type", "component", - "--lobster-output-dir", + "--fbs-output-dir", out.to_str().unwrap(), ]); @@ -1217,9 +584,11 @@ mod cli_input_tests { .unwrap_or_else(|| panic!("{kind} diagram must not parse as Component")) .to_string(); - assert_eq!( - error, - format!("d.puml: expected a Component diagram, found a {kind} diagram") + assert!( + error.starts_with(&format!( + "d.puml: expected a Component diagram, found a {kind} diagram\n Component parser error: " + )), + "{error}" ); } } @@ -1230,6 +599,8 @@ mod fta_pipeline_tests { use super::*; use clap::Parser; use puml_utils::LogLevel; + use std::fs; + use std::path::Path; // Minimal metamodel: just enough procedure definitions for the include // expander to inline. Topology is read from the original macro calls, so @@ -1522,6 +893,12 @@ mod fta_pipeline_tests { #[cfg(test)] mod idmap_wiring_tests { use super::*; + use crate::inputs::source_path_for_output; + use crate::resolving::{resolve_single, ResolvedDiagram}; + use crate::sinks::{idmap_model_for, serialize_resolved_diagram}; + use puml_idmap::{write_empty_idmap_to_file, write_idmap_to_file}; + use std::fs; + use std::path::Path; fn unique_dir(tag: &str) -> PathBuf { let nanos = std::time::SystemTime::now() @@ -1911,3 +1288,182 @@ mod idmap_wiring_tests { cleanup_dir(&dir); } } + +#[cfg(test)] +mod public_api_tests { + use super::*; + use clap::Parser; + use std::fs; + use std::path::Path; + + const CLASS_DIAGRAM: &str = "@startuml\n\ + namespace score.mw {\n\ + interface Skeleton {\n\ + + OfferService() : Result\n\ + + OfferService(int) : Result\n\ + - Hidden()\n\ + }\n\ + }\n\ + @enduml\n"; + + 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_cli_api_{}_{}_{}", + tag, + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn run(file: &Path, out: &Path, diagram_type: &str) -> Result<(), String> { + run_with_package(file, out, diagram_type, "Pkg_api") + } + + fn run_with_package( + file: &Path, + out: &Path, + diagram_type: &str, + package: &str, + ) -> Result<(), String> { + let args = Args::parse_from([ + "puml_cli", + "--file", + file.to_str().unwrap(), + "--source-name", + "pkg/api.puml", + "--diagram-type", + diagram_type, + "--api-output-file", + out.join("public_api.trlc").to_str().unwrap(), + "--api-package", + package, + ]); + execute(&args, LogLevel::Warn).map_err(|e| e.to_string()) + } + + #[test] + fn class_diagram_emits_api_method_records() { + let dir = unique_dir("emit"); + let file = dir.join("api.puml"); + fs::write(&file, CLASS_DIAGRAM).unwrap(); + let out = dir.join("out"); + + run(&file, &out, "class").unwrap(); + + let trlc = fs::read_to_string(out.join("public_api.trlc")).unwrap(); + fs::remove_dir_all(&dir).ok(); + assert!(trlc.contains("package Pkg_api\n"), "got: {trlc}"); + assert!(trlc.contains("import ScoreReq\n"), "got: {trlc}"); + assert!( + trlc.contains("ScoreReq.ApiMethod score_mw_Skeleton_OfferService {"), + "got: {trlc}" + ); + assert!(trlc.contains("diagram = \"pkg/api.puml\""), "got: {trlc}"); + assert!( + trlc.contains( + "signatures = [\"`Result score::mw::Skeleton::OfferService()`\", \ + \"`Result score::mw::Skeleton::OfferService(int)`\"]" + ), + "got: {trlc}" + ); + assert!(!trlc.contains("Hidden"), "got: {trlc}"); + } + + #[test] + fn non_class_diagrams_are_rejected() { + let dir = unique_dir("non_class"); + let file = dir.join("c.puml"); + fs::write(&file, "@startuml\ncomponent A <>\n@enduml").unwrap(); + + let result = run(&file, &dir.join("out"), "none"); + + fs::remove_dir_all(&dir).ok(); + assert_eq!( + result.unwrap_err(), + "pkg/api.puml: the public API is collected from class diagrams, found a Component diagram" + ); + } + + #[test] + fn explicit_class_type_rejects_other_diagram_kinds() { + let dir = unique_dir("explicit_type"); + let file = dir.join("c.puml"); + fs::write(&file, "@startuml\ncomponent A <>\n@enduml").unwrap(); + + let result = run(&file, &dir.join("out"), "class"); + + fs::remove_dir_all(&dir).ok(); + let error = result.unwrap_err(); + assert!( + error.starts_with( + "pkg/api.puml: expected a Class diagram, found a Component diagram\n Class parser error: " + ), + "{error}" + ); + } + + #[test] + fn package_must_be_a_trlc_identifier() { + let dir = unique_dir("package"); + let file = dir.join("api.puml"); + fs::write(&file, CLASS_DIAGRAM).unwrap(); + + let result = run_with_package(&file, &dir.join("out"), "class", "_pkg"); + + fs::remove_dir_all(&dir).ok(); + assert!(result + .unwrap_err() + .starts_with("--api-package must be a TRLC identifier")); + } + + #[test] + fn colliding_record_names_fail_the_run() { + let dir = unique_dir("collision"); + let file = dir.join("api.puml"); + fs::write( + &file, + "@startuml\nclass A {\n+ GetX()\n+ get_x()\n}\n@enduml\n", + ) + .unwrap(); + + let result = run(&file, &dir.join("out"), "class"); + + fs::remove_dir_all(&dir).ok(); + assert!(result.unwrap_err().contains("A_get_x")); + } + + #[test] + fn api_flags_require_each_other() { + assert!( + Args::try_parse_from(["puml_cli", "--file", "a.puml", "--api-output-file", "out"]) + .is_err() + ); + assert!( + Args::try_parse_from(["puml_cli", "--file", "a.puml", "--api-package", "P"]).is_err() + ); + } + + #[test] + fn api_output_conflicts_with_fta_mode() { + assert!(Args::try_parse_from([ + "puml_cli", + "--file", + "a.puml", + "--fta-output-dir", + "out", + "--fta-package", + "F", + "--api-output-file", + "out", + "--api-package", + "P", + ]) + .is_err()); + } +} diff --git a/plantuml/parser/puml_cli/src/parsing.rs b/plantuml/parser/puml_cli/src/parsing.rs new file mode 100644 index 00000000..8a678206 --- /dev/null +++ b/plantuml/parser/puml_cli/src/parsing.rs @@ -0,0 +1,274 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Parsing of one preprocessed diagram into its raw AST. + +use clap::ValueEnum; +use log::debug; +use serde::Serialize; +use std::path::PathBuf; +use std::rc::Rc; + +use puml_parser::{ + DiagramParser, ErrorLocation, PumlActivityParser, PumlClassParser, PumlComponentParser, + PumlSequenceParser, +}; +use puml_utils::LogLevel; + +/// Grammar / diagram type selected with `--diagram-type`. +#[derive(Copy, Clone, ValueEnum, Debug)] +pub enum DiagramType { + None, + Activity, + Component, + Deployment, + Class, + Sequence, +} + +#[derive(Debug, Serialize)] +pub enum ParsedDiagram { + Activity(puml_parser::RawActivityDiagram), + Component(puml_parser::CompPumlDocument), + Class(puml_parser::ClassUmlFile), + Sequence(puml_parser::SeqPumlDocument), +} + +/// A kind of diagram with a parser of its own. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DiagramKind { + Component, + Activity, + Class, + Sequence, +} + +impl DiagramKind { + /// Order in which `--diagram-type none` tries the kinds. + const DETECTION_ORDER: [DiagramKind; 4] = [ + DiagramKind::Component, + DiagramKind::Activity, + DiagramKind::Class, + DiagramKind::Sequence, + ]; + + fn name(self) -> &'static str { + match self { + Self::Component => "Component", + Self::Activity => "Activity", + Self::Class => "Class", + Self::Sequence => "Sequence", + } + } + + fn parse( + self, + path: &Rc, + content: &str, + log_level: LogLevel, + ) -> Result { + match self { + Self::Component => attempt( + PumlComponentParser, + path, + content, + log_level, + ParsedDiagram::Component, + ), + Self::Activity => attempt( + PumlActivityParser, + path, + content, + log_level, + ParsedDiagram::Activity, + ), + Self::Class => attempt( + PumlClassParser, + path, + content, + log_level, + ParsedDiagram::Class, + ), + Self::Sequence => attempt( + PumlSequenceParser, + path, + content, + log_level, + ParsedDiagram::Sequence, + ), + } + } +} + +impl DiagramType { + /// The kind to parse as, or `None` to detect it. + fn kind(self) -> Option { + match self { + Self::None => None, + Self::Activity => Some(DiagramKind::Activity), + Self::Component | Self::Deployment => Some(DiagramKind::Component), + Self::Class => Some(DiagramKind::Class), + Self::Sequence => Some(DiagramKind::Sequence), + } + } +} + +/// A failed parse: the parser's error and, when it knows, where it stopped +/// (line, column). +struct ParseFailure { + error: Box, + location: Option<(usize, usize)>, +} + +fn attempt

( + mut parser: P, + path: &Rc, + content: &str, + log_level: LogLevel, + wrap: fn(P::Output) -> ParsedDiagram, +) -> Result +where + P: DiagramParser, + P::Error: ErrorLocation + std::error::Error + 'static, +{ + parser + .parse_file(path, content, log_level) + .map(wrap) + .map_err(|error| ParseFailure { + location: error.error_location(), + error: Box::new(error), + }) +} + +// lobster-trace: Tools.ArchitectureModelingSyntax +pub fn parse_puml_file( + path: &Rc, + content: &str, + log_level: LogLevel, + diagram_type: DiagramType, +) -> Result> { + match diagram_type.kind() { + Some(kind) => parse_as(kind, path, content, log_level), + None => parse_in_order(path, content, log_level), + } +} + +/// Parses as `kind`; a file of another kind is named as such, followed by +/// the `kind` parser's own error. +fn parse_as( + kind: DiagramKind, + path: &Rc, + content: &str, + log_level: LogLevel, +) -> Result> { + match kind.parse(path, content, log_level) { + Ok(parsed) => { + debug!("Successfully parsed PlantUML document: {}", path.display()); + Ok(parsed) + } + Err(failure) => Err(match detect_other_kind(kind, path, content, log_level) { + Some(found) => format!( + "{}: expected a {} diagram, found a {} diagram\n {} parser error: {}", + path.display(), + kind.name(), + found.name(), + kind.name(), + failure.error + ) + .into(), + None => failure.error, + }), + } +} + +/// The first kind other than `expected` whose parser accepts the file. +fn detect_other_kind( + expected: DiagramKind, + path: &Rc, + content: &str, + log_level: LogLevel, +) -> Option { + DiagramKind::DETECTION_ORDER + .into_iter() + .filter(|&kind| kind != expected) + .find(|kind| kind.parse(path, content, log_level).is_ok()) +} + +/// Parses as the first kind of [`DiagramKind::DETECTION_ORDER`] that accepts +/// the file; otherwise reports the parsers' errors. +fn parse_in_order( + path: &Rc, + content: &str, + log_level: LogLevel, +) -> Result> { + let mut failures: Vec<(DiagramKind, ParseFailure)> = Vec::new(); + + for kind in DiagramKind::DETECTION_ORDER { + match kind.parse(path, content, log_level) { + Ok(parsed) => { + debug!("Successfully detected as {} diagram", kind.name()); + return Ok(parsed); + } + Err(failure) => { + debug!( + "{} parser failed at {:?}: {}", + kind.name(), + failure.location, + failure.error + ); + failures.push((kind, failure)); + } + } + } + + // The parser that reached the furthest line is the most informative one. + let best = failures + .iter() + .max_by_key(|(_, failure)| failure.location.map_or(0, |(line, _)| line)); + + let tried_names: Vec<&str> = failures.iter().map(|(kind, _)| kind.name()).collect(); + + let detail = match best { + Some((kind, failure)) => match failure.location { + Some((line_no, _col)) => { + let source_line = content + .lines() + .nth(line_no - 1) + .unwrap_or("") + .trim(); + format!( + "\n Parsers tried: {}\n Parser with longest match: {}\n Failed at line {}: {}\n Error: {}", + tried_names.join(", "), + kind.name(), + line_no, + source_line, + failure.error, + ) + } + None => format!( + "\n Parsers tried: {}\n Parser with longest match: {}\n Error: {}", + tried_names.join(", "), + kind.name(), + failure.error, + ), + }, + None => String::new(), + }; + + Err(format!( + "Failed to parse {} with any available parser{}", + path.display(), + detail, + ) + .into()) +} diff --git a/plantuml/parser/puml_cli/src/resolving.rs b/plantuml/parser/puml_cli/src/resolving.rs new file mode 100644 index 00000000..3ed3a9f2 --- /dev/null +++ b/plantuml/parser/puml_cli/src/resolving.rs @@ -0,0 +1,117 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Resolution of raw diagram ASTs into logical models. + +use serde::Serialize; +use std::collections::HashMap; + +use activity_diagram::ActivityDiagram; +use puml_resolver::{ + ActivityResolver, ClassResolver, ComponentResolver, DiagramResolver, DiagramSource, + SequenceResolver, +}; + +use crate::parsing::ParsedDiagram; + +#[derive(Debug, Serialize)] +pub enum ResolvedDiagram { + Activity(ActivityDiagram), + Component(HashMap), + Class(class_diagram::ClassDiagram), + Sequence(sequence_logic::SequenceTree), +} + +impl ResolvedDiagram { + /// Kind name used in diagnostics. + pub fn kind_name(&self) -> &'static str { + match self { + Self::Activity(_) => "Activity", + Self::Component(_) => "Component", + Self::Class(_) => "Class", + Self::Sequence(_) => "Sequence", + } + } +} + +/// Resolves the diagrams of each kind in `parsed` as one group; `names` (one +/// per diagram) attribute errors to files. Results keep the order of `parsed`. +pub fn resolve_parsed_diagrams( + names: &[String], + parsed: &[ParsedDiagram], +) -> Result, Box> { + let mut activity = Vec::new(); + let mut component = Vec::new(); + let mut class = Vec::new(); + let mut sequence = Vec::new(); + for (i, doc) in parsed.iter().enumerate() { + match doc { + ParsedDiagram::Activity(d) => activity.push((i, d)), + ParsedDiagram::Component(d) => component.push((i, d)), + ParsedDiagram::Class(d) => class.push((i, d)), + ParsedDiagram::Sequence(d) => sequence.push((i, d)), + } + } + + let mut results: Vec> = (0..parsed.len()).map(|_| None).collect(); + resolve_bucket::(names, &activity, ResolvedDiagram::Activity, &mut results)?; + resolve_bucket::( + names, + &component, + ResolvedDiagram::Component, + &mut results, + )?; + resolve_bucket::(names, &class, ResolvedDiagram::Class, &mut results)?; + resolve_bucket::(names, &sequence, ResolvedDiagram::Sequence, &mut results)?; + + Ok(results + .into_iter() + .map(|r| r.expect("every diagram is in exactly one bucket")) + .collect()) +} + +/// Resolves the `(index, document)` entries of one kind as a group and stores +/// each result at its index. +fn resolve_bucket( + names: &[String], + bucket: &[(usize, &R::Document)], + wrap: fn(R::Output) -> ResolvedDiagram, + results: &mut [Option], +) -> Result<(), Box> +where + R: DiagramResolver + Default, +{ + if bucket.is_empty() { + return Ok(()); + } + let sources: Vec> = bucket + .iter() + .map(|&(i, document)| DiagramSource { + name: names[i].as_str(), + document, + }) + .collect(); + let resolved = R::resolve_group(&sources)?; + for (&(i, _), output) in bucket.iter().zip(resolved) { + results[i] = Some(wrap(output)); + } + Ok(()) +} + +/// Resolves one diagram alone. +#[cfg(test)] +pub fn resolve_single( + parsed: ParsedDiagram, +) -> Result> { + resolve_parsed_diagrams(&[String::new()], &[parsed]).map(|mut resolved| resolved.remove(0)) +} diff --git a/plantuml/parser/puml_cli/src/sinks.rs b/plantuml/parser/puml_cli/src/sinks.rs new file mode 100644 index 00000000..ed621fff --- /dev/null +++ b/plantuml/parser/puml_cli/src/sinks.rs @@ -0,0 +1,317 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Outputs written for the resolved diagrams of a run. Each output is an +//! [`OutputSink`]; the run feeds every enabled sink the same diagrams. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use activity_serializer::ActivitySerializer; +use class_serializer::ClassSerializer; +use component_serializer::ComponentSerializer; +use sequence_serializer::SequenceSerializer; + +use puml_api::{render_trlc, ApiCollector}; +use puml_idmap::{write_empty_idmap_to_file, write_idmap_to_file, IdMapModel}; +use puml_utils::{write_fbs_to_file, write_json_to_file}; + +use crate::parsing::ParsedDiagram; +use crate::resolving::ResolvedDiagram; + +type SinkResult = Result<(), Box>; + +/// One resolved diagram as handed to the sinks. +pub struct DiagramOutput<'a> { + /// Path whose file stem names the output files. + pub naming_path: &'a Path, + /// Source path embedded in the outputs. + pub source_file: &'a str, + /// `@startuml ` of the diagram, if it has one. + pub diagram_name: Option<&'a str>, + pub resolved: &'a ResolvedDiagram, +} + +pub trait OutputSink { + /// Sees the raw AST of a diagram, before the run resolves any. + fn parsed(&mut self, _naming_path: &Path, _parsed: &ParsedDiagram) -> SinkResult { + Ok(()) + } + + fn write(&mut self, diagram: &DiagramOutput<'_>) -> SinkResult; + + /// Called once after the last diagram. + fn finish(&mut self) -> SinkResult { + Ok(()) + } +} + +/// Creates the output directory of an `--*-output-dir` flag. +pub fn create_output_dir(dir: &str) -> Result> { + let path = PathBuf::from(dir); + fs::create_dir_all(&path)?; + Ok(path) +} + +/// FlatBuffers binary per diagram; with `debug_json` also the raw and resolved +/// ASTs as JSON. +pub struct FbsSink { + dir: PathBuf, + debug_json: bool, +} + +impl FbsSink { + pub fn new(dir: PathBuf, debug_json: bool) -> Self { + Self { dir, debug_json } + } +} + +impl OutputSink for FbsSink { + fn parsed(&mut self, naming_path: &Path, parsed: &ParsedDiagram) -> SinkResult { + if self.debug_json { + write_json_to_file(parsed, naming_path, &self.dir, "raw.ast")?; + } + Ok(()) + } + + fn write(&mut self, diagram: &DiagramOutput<'_>) -> SinkResult { + if self.debug_json { + write_json_to_file( + diagram.resolved, + diagram.naming_path, + &self.dir, + "logic.ast", + )?; + } + let buffer = serialize_resolved_diagram(diagram.resolved); + write_fbs_to_file(&buffer, diagram.naming_path, &self.dir)?; + Ok(()) + } +} + +pub fn serialize_resolved_diagram(resolved: &ResolvedDiagram) -> Vec { + match resolved { + ResolvedDiagram::Activity(model) => ActivitySerializer::serialize(model), + ResolvedDiagram::Component(model) => ComponentSerializer::serialize(model), + ResolvedDiagram::Class(model) => ClassSerializer::serialize(model), + ResolvedDiagram::Sequence(model) => SequenceSerializer::serialize(model), + } +} + +/// The `.idmap.json` paths written so far in `dir`. Two inputs with the +/// same file stem (in different directories) would silently overwrite each +/// other's file, so a second claim of a path fails. +pub struct IdmapOutputs { + dir: PathBuf, + claimed: HashSet, +} + +impl IdmapOutputs { + pub fn new(dir: PathBuf) -> Self { + Self { + dir, + claimed: HashSet::new(), + } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Claims the idmap path of `naming_path`; `input_kind` names the inputs + /// in the error of a collision. + pub fn claim(&mut self, naming_path: &Path, input_kind: &str) -> SinkResult { + let output_path = puml_idmap::idmap_output_path(naming_path, &self.dir); + if !self.claimed.insert(output_path.clone()) { + return Err(format!( + "duplicate idmap output {}: multiple {} files map to the \ + same .idmap.json in {}; rename one of the inputs so \ + their file stems differ", + output_path.display(), + input_kind, + self.dir.display(), + ) + .into()); + } + Ok(()) + } +} + +/// `.idmap.json` per diagram: the defines/references the +/// clickable_plantuml Sphinx extension uses to resolve cross-diagram links. +pub struct IdmapSink { + outputs: IdmapOutputs, +} + +impl IdmapSink { + pub fn new(dir: PathBuf) -> Self { + Self { + outputs: IdmapOutputs::new(dir), + } + } +} + +impl OutputSink for IdmapSink { + fn write(&mut self, diagram: &DiagramOutput<'_>) -> SinkResult { + self.outputs.claim(diagram.naming_path, "input")?; + let dir = self.outputs.dir(); + match idmap_model_for(diagram.resolved) { + Some(model) => { + write_idmap_to_file( + model, + diagram.naming_path, + Some(diagram.source_file), + diagram.diagram_name, + dir, + )?; + } + None => { + write_empty_idmap_to_file(diagram.naming_path, Some(diagram.source_file), dir)?; + } + } + Ok(()) + } +} + +/// 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. +pub 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, + } +} + +/// One TRLC file with the `ApiMethod` records of the public methods and free +/// functions of all class diagrams. Other diagram kinds are an error: a +/// silently empty API would hide a wrongly wired input. +pub struct PublicApiSink { + file: PathBuf, + package: String, + collector: ApiCollector, +} + +impl PublicApiSink { + pub fn new(file: PathBuf, package: String) -> Self { + Self { + file, + package, + collector: ApiCollector::default(), + } + } +} + +impl OutputSink for PublicApiSink { + fn write(&mut self, diagram: &DiagramOutput<'_>) -> SinkResult { + match diagram.resolved { + ResolvedDiagram::Class(model) => { + self.collector.add_diagram(diagram.source_file, model)?; + Ok(()) + } + other => Err(format!( + "{}: the public API is collected from class diagrams, found a {} diagram", + diagram.source_file, + other.kind_name() + ) + .into()), + } + } + + fn finish(&mut self) -> SinkResult { + let methods = std::mem::take(&mut self.collector).into_methods(); + if let Some(parent) = self.file.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&self.file, render_trlc(&self.package, &methods))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parsing::{parse_puml_file, DiagramType}; + use crate::resolving::resolve_single; + use puml_utils::LogLevel; + use std::rc::Rc; + + fn resolved(content: &str, diagram_type: DiagramType) -> ResolvedDiagram { + let path = Rc::new(PathBuf::from("d.puml")); + let parsed = parse_puml_file(&path, content, LogLevel::Warn, diagram_type).unwrap(); + resolve_single(parsed).unwrap() + } + + fn output<'a>(resolved: &'a ResolvedDiagram, source_file: &'a str) -> DiagramOutput<'a> { + DiagramOutput { + naming_path: Path::new("d.puml"), + source_file, + diagram_name: None, + resolved, + } + } + + #[test] + fn idmap_claim_rejects_a_second_input_with_the_same_stem() { + let mut outputs = IdmapOutputs::new(PathBuf::from("out")); + + outputs.claim(Path::new("a/x.puml"), "input").unwrap(); + let error = outputs.claim(Path::new("b/x.puml"), "input").unwrap_err(); + + assert!(error.to_string().starts_with("duplicate idmap output")); + } + + #[test] + fn public_api_sink_rejects_non_class_diagrams() { + let component = resolved( + "@startuml\ncomponent A <>\n@enduml", + DiagramType::Component, + ); + let mut sink = PublicApiSink::new(PathBuf::from("unused.trlc"), "Pkg".to_string()); + + let error = sink.write(&output(&component, "pkg/c.puml")).unwrap_err(); + + assert_eq!( + error.to_string(), + "pkg/c.puml: the public API is collected from class diagrams, found a Component diagram" + ); + } + + #[test] + fn public_api_sink_writes_one_file_over_all_diagrams() { + let dir = std::env::temp_dir().join(format!("puml_cli_sink_{}", std::process::id())); + let file = dir.join("nested").join("api.trlc"); + let first = resolved( + "@startuml\nclass A {\n+ F()\n}\n@enduml", + DiagramType::Class, + ); + let second = resolved( + "@startuml\nclass B {\n+ G()\n}\n@enduml", + DiagramType::Class, + ); + let mut sink = PublicApiSink::new(file.clone(), "Pkg".to_string()); + + sink.write(&output(&first, "a.puml")).unwrap(); + sink.write(&output(&second, "b.puml")).unwrap(); + sink.finish().unwrap(); + + let trlc = fs::read_to_string(&file).unwrap(); + fs::remove_dir_all(&dir).ok(); + assert!(trlc.contains("ScoreReq.ApiMethod A_F {"), "got: {trlc}"); + assert!(trlc.contains("ScoreReq.ApiMethod B_G {"), "got: {trlc}"); + } +} diff --git a/plantuml/parser/puml_fta/src/lib.rs b/plantuml/parser/puml_fta/src/lib.rs index 87606df4..0dcf0765 100644 --- a/plantuml/parser/puml_fta/src/lib.rs +++ b/plantuml/parser/puml_fta/src/lib.rs @@ -22,6 +22,7 @@ use std::collections::{BTreeMap, HashMap}; use log::warn; use procedure_preprocessor::{Arg, MacroCallDef, ProcedureFile, Statement}; +use puml_utils::{render_trlc_file, TrlcRecord, TrlcValue}; use serde::Serialize; /// Procedure macro names recognised in an FTA diagram. @@ -691,62 +692,35 @@ pub fn merge_root_causes(trees: &[FaultTree]) -> Result, FtaE Ok(merged.into_values().collect()) } -/// Escape a Rust string as a TRLC string literal (including the surrounding -/// quotes). -fn trlc_string_literal(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - _ => out.push(c), - } - } - out.push('"'); - out -} - /// Render merged [`RootCauseStub`]s (see [`merge_root_causes`]) as one TRLC /// file: a `RootCause` record per entry, importing `ScoreReq` plus every -/// package referenced by a `failure_modes` fully-qualified name. Output order -/// is deterministic and every record carries a `:` comment -/// pointing back at its source macro call. +/// package referenced by a `failure_modes` fully-qualified name (no imports +/// without records). Output order is deterministic and every record carries a +/// `:` comment pointing back at its source macro call. pub fn render_trlc_stub(package: &str, root_causes: &[RootCauseStub]) -> String { - let mut fm_packages: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for rc in root_causes { - for fm in &rc.failure_modes { - if let Some((pkg, _)) = fm.rsplit_once('.') { - fm_packages.insert(pkg); - } - } - } - - let mut out = String::new(); - out.push_str("// GENERATED by puml_cli from FTA diagrams -- do not edit\n"); - out.push_str(&format!("package {}\n\n", package)); - // With zero records `import ScoreReq` would be flagged as unused by TRLC. - if !root_causes.is_empty() { - out.push_str("import ScoreReq\n"); - } - for pkg in fm_packages { - out.push_str(&format!("import {}\n", pkg)); - } - out.push('\n'); - - for rc in root_causes { - out.push_str(&format!("// {}:{}\n", rc.diagram, rc.line)); - out.push_str(&format!( - "ScoreReq.RootCause {} {{\n title = {}\n diagram = {}\n line = {}\n failure_modes = [{}]\n}}\n\n", - rc.name, - trlc_string_literal(&rc.title), - trlc_string_literal(&rc.diagram), - rc.line, - rc.failure_modes.join(", "), - )); - } - out + let records: Vec = root_causes + .iter() + .map(|rc| TrlcRecord { + record_type: "ScoreReq.RootCause".to_string(), + name: rc.name.clone(), + origin: format!("{}:{}", rc.diagram, rc.line), + fields: vec![ + ("title", TrlcValue::Text(rc.title.clone())), + ("diagram", TrlcValue::Text(rc.diagram.clone())), + ("line", TrlcValue::Integer(rc.line as u64)), + ( + "failure_modes", + TrlcValue::List( + rc.failure_modes + .iter() + .map(|fm| TrlcValue::Reference(fm.clone())) + .collect(), + ), + ), + ], + }) + .collect(); + render_trlc_file("FTA diagrams", package, &records) } #[cfg(test)] diff --git a/plantuml/parser/puml_idmap/src/lib.rs b/plantuml/parser/puml_idmap/src/lib.rs index 39782e37..11d2a2d0 100644 --- a/plantuml/parser/puml_idmap/src/lib.rs +++ b/plantuml/parser/puml_idmap/src/lib.rs @@ -20,9 +20,6 @@ //! 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; diff --git a/plantuml/parser/puml_lobster/src/lib.rs b/plantuml/parser/puml_lobster/src/lib.rs deleted file mode 100644 index f5786511..00000000 --- a/plantuml/parser/puml_lobster/src/lib.rs +++ /dev/null @@ -1,328 +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 -// ******************************************************************************* - -//! Converts the resolved PlantUML logical model into a `lobster-imp-trace` -//! JSON file compatible with the LOBSTER traceability toolchain. -//! -//! For component diagrams only [`ComponentType::Interface`] elements are emitted. -//! For class diagrams, [`EntityType::Interface`] entities emit one item per method; -//! all other entity types emit one item per entity. - -use class_diagram::{ClassDiagram, EntityType}; -use component_diagram::{ComponentType, LogicComponent}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; - -pub enum LobsterModel<'a> { - Component(&'a HashMap), - Class(&'a ClassDiagram), - Empty, -} - -/// Convert an in-memory resolved component model to a `lobster-imp-trace` -/// JSON [`Value`]. -/// -/// `source_path` is embedded in the `location.file` field of every emitted -/// item so that LOBSTER can trace items back to their source diagram. -fn comp_model_to_lobster(model: &HashMap, source_path: &str) -> Value { - let items: Vec = model - .values() - .filter(|element| element.element_type == ComponentType::Interface) - .map(|element| build_lobster_item(&element.id, source_path, None, "Interface")) - .collect(); - - lobster_document_from_items(items) -} - -/// Convert an in-memory resolved class model to a `lobster-imp-trace` -/// JSON [`Value`]. -/// -/// For [`EntityType::Interface`] entities every method becomes its own lobster -/// item with id `{entity.id}.{method.name}` and kind `"Method"`. All other -/// entity types are emitted as a single item (one per entity). -fn class_model_to_lobster(model: &ClassDiagram) -> Value { - let items: Vec = model - .entities - .iter() - .flat_map(|entity| { - let source_file = entity.source_location.file.as_ref(); - let line = entity.source_location.line; - let source_line = (line != 0).then_some(line); - - if entity.entity_type == EntityType::Interface { - entity - .methods - .iter() - .map(|method| { - let method_id = format!("{}.{}", entity.id, method.name); - build_lobster_item(&method_id, source_file, source_line, "Method") - }) - .collect::>() - } else { - vec![build_lobster_item( - &entity.id, - source_file, - source_line, - map_entity_type_to_kind(entity.entity_type), - )] - } - }) - .collect(); - - lobster_document_from_items(items) -} - -fn lobster_document_from_items(mut items: Vec) -> Value { - // Sort by tag for deterministic output - items.sort_by(|a, b| { - a["tag"] - .as_str() - .unwrap_or("") - .cmp(b["tag"].as_str().unwrap_or("")) - }); - - json!({ - "schema": "lobster-imp-trace", - "version": 3, - "generator": "puml_lobster", - "data": items, - }) -} - -fn empty_lobster_document() -> Value { - lobster_document_from_items(Vec::new()) -} - -fn build_lobster_item( - name: &str, - source_file: &str, - source_line: Option, - kind: &str, -) -> Value { - json!({ - "tag": format!("req {}", name), - "location": { - "kind": "file", - "file": source_file, - "line": source_line, - "column": null, - }, - "name": name, - "messages": [], - "just_up": [], - "just_down": [], - "just_global": [], - "refs": [], - "language": "Architecture", - "kind": kind, - }) -} - -fn map_entity_type_to_kind(entity_type: EntityType) -> &'static str { - match entity_type { - EntityType::Class => "Class", - EntityType::Struct => "Struct", - EntityType::Interface => "Interface", - EntityType::Enum => "Enum", - EntityType::AbstractClass => "AbstractClass", - } -} - -/// Write a `lobster-imp-trace` JSON file derived from `model` into `output_dir`. -/// -/// The output filename is `.lobster` where `` is the file stem of -/// `input_path` (the original `.puml` source file). -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) => { - comp_model_to_lobster(component_model, source_path) - } - LobsterModel::Class(class_model) => class_model_to_lobster(class_model), - LobsterModel::Empty => empty_lobster_document(), - }; - - write_lobster_value_to_file(&lobster, input_path, output_dir) -} - -fn write_lobster_value_to_file( - lobster: &Value, - input_path: &Path, - output_dir: &Path, -) -> io::Result { - let file_stem = input_path - .file_stem() - .unwrap_or_else(|| OsStr::new("output")); - - let output_path = output_dir.join(file_stem).with_extension("lobster"); - - let content = serde_json::to_string_pretty(&lobster) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - 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], - free_functions: vec![], - }; - 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], - free_functions: vec![], - }; - 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/parser/puml_utils/BUILD b/plantuml/parser/puml_utils/BUILD index 94601c5a..62b3c54b 100644 --- a/plantuml/parser/puml_utils/BUILD +++ b/plantuml/parser/puml_utils/BUILD @@ -18,6 +18,7 @@ rust_library( "src/label_markup.rs", "src/lib.rs", "src/log.rs", + "src/trlc.rs", "src/write_files.rs", ], visibility = ["//plantuml/parser:__subpackages__"], diff --git a/plantuml/parser/puml_utils/src/lib.rs b/plantuml/parser/puml_utils/src/lib.rs index 7db0306f..fa6b8989 100644 --- a/plantuml/parser/puml_utils/src/lib.rs +++ b/plantuml/parser/puml_utils/src/lib.rs @@ -12,10 +12,12 @@ // ******************************************************************************* mod label_markup; mod log; +mod trlc; mod write_files; pub use label_markup::{ decode_newline_escapes, normalize_identity_label, strip_style_markup, style_markup_tag_length, }; pub use log::LogLevel; +pub use trlc::{is_trlc_identifier, render_trlc_file, TrlcRecord, TrlcValue}; pub use write_files::{write_fbs_to_file, write_json_to_file, write_placeholder_file}; diff --git a/plantuml/parser/puml_utils/src/trlc.rs b/plantuml/parser/puml_utils/src/trlc.rs new file mode 100644 index 00000000..d404442f --- /dev/null +++ b/plantuml/parser/puml_utils/src/trlc.rs @@ -0,0 +1,210 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Writer for generated TRLC files: one package of records of a single model. + +use std::collections::BTreeSet; + +/// Value of a record field. +pub enum TrlcValue { + /// String literal. + Text(String), + Integer(u64), + /// Qualified record name `Package.Name`; its package is imported. + Reference(String), + List(Vec), +} + +impl TrlcValue { + /// The TRLC text of the value; the package of a reference is added to `imports`. + fn render<'a>(&'a self, imports: &mut BTreeSet<&'a str>) -> String { + match self { + Self::Text(text) => trlc_string_literal(text), + Self::Integer(value) => value.to_string(), + Self::Reference(name) => { + imports.extend(package_of(name)); + name.clone() + } + Self::List(values) => { + let values: Vec = values.iter().map(|v| v.render(imports)).collect(); + format!("[{}]", values.join(", ")) + } + } + } +} + +/// One record of a generated file. +pub struct TrlcRecord { + /// Qualified type, e.g. `ScoreReq.ApiMethod`; its package is imported. + pub record_type: String, + pub name: String, + /// Where the record comes from (`:`), emitted as a comment. + pub origin: String, + pub fields: Vec<(&'static str, TrlcValue)>, +} + +/// Renders `records` as the TRLC file of `package`. The imports follow from the +/// record types and references, so a file without records imports nothing +/// (TRLC flags unused imports). +pub fn render_trlc_file(generated_from: &str, package: &str, records: &[TrlcRecord]) -> String { + let mut imports: BTreeSet<&str> = BTreeSet::new(); + let mut body = String::new(); + for record in records { + imports.extend(package_of(&record.record_type)); + body.push_str(&format!("// {}\n", record.origin)); + body.push_str(&format!("{} {} {{\n", record.record_type, record.name)); + for (field, value) in &record.fields { + body.push_str(&format!(" {field} = {}\n", value.render(&mut imports))); + } + body.push_str("}\n\n"); + } + + let mut out = format!("// GENERATED by puml_cli from {generated_from} -- do not edit\n"); + out.push_str(&format!("package {package}\n\n")); + for imported in &imports { + out.push_str(&format!("import {imported}\n")); + } + if !imports.is_empty() { + out.push('\n'); + } + out.push_str(&body); + out +} + +/// Whether `name` is a plain TRLC identifier: an ASCII letter followed by +/// letters, digits and `_`. Keywords are not checked. +pub fn is_trlc_identifier(name: &str) -> bool { + name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn package_of(qualified: &str) -> Option<&str> { + qualified.rsplit_once('.').map(|(package, _)| package) +} + +/// Escape a Rust string as a TRLC string literal (including the surrounding +/// quotes). +fn trlc_string_literal(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(name: &str, fields: Vec<(&'static str, TrlcValue)>) -> TrlcRecord { + TrlcRecord { + record_type: "Model.Kind".to_string(), + name: name.to_string(), + origin: "d.puml:3".to_string(), + fields, + } + } + + #[test] + fn quotes_backslashes_and_newlines_are_escaped() { + assert_eq!(trlc_string_literal("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\""); + } + + #[test] + fn file_without_records_has_no_imports() { + assert_eq!( + render_trlc_file("things", "Pkg", &[]), + "// GENERATED by puml_cli from things -- do not edit\npackage Pkg\n\n" + ); + } + + #[test] + fn records_render_fields_in_order() { + let records = [record( + "R", + vec![ + ("title", TrlcValue::Text("a \"b\"".to_string())), + ("line", TrlcValue::Integer(7)), + ( + "items", + TrlcValue::List(vec![ + TrlcValue::Text("x".to_string()), + TrlcValue::Text("y".to_string()), + ]), + ), + ], + )]; + + assert_eq!( + render_trlc_file("things", "Pkg", &records), + "// GENERATED by puml_cli from things -- do not edit\n\ + package Pkg\n\ + \n\ + import Model\n\ + \n\ + // d.puml:3\n\ + Model.Kind R {\n\ + \x20 title = \"a \\\"b\\\"\"\n\ + \x20 line = 7\n\ + \x20 items = [\"x\", \"y\"]\n\ + }\n\ + \n" + ); + } + + #[test] + fn imports_cover_record_types_and_references_sorted_once() { + let records = [ + record( + "A", + vec![( + "refs", + TrlcValue::List(vec![ + TrlcValue::Reference("Lib.A".to_string()), + TrlcValue::Reference("Lib.B".to_string()), + TrlcValue::Reference("Other.C".to_string()), + ]), + )], + ), + record( + "B", + vec![("ref", TrlcValue::Reference("Lib.D".to_string()))], + ), + ]; + + let out = render_trlc_file("things", "Pkg", &records); + + assert!( + out.contains("package Pkg\n\nimport Lib\nimport Model\nimport Other\n\n"), + "got: {out}" + ); + assert!(out.contains("refs = [Lib.A, Lib.B, Other.C]"), "got: {out}"); + } + + #[test] + fn identifiers_start_with_a_letter() { + assert!(is_trlc_identifier("a_B1")); + assert!(!is_trlc_identifier("")); + assert!(!is_trlc_identifier("_a")); + assert!(!is_trlc_identifier("1a")); + assert!(!is_trlc_identifier("a-b")); + assert!(!is_trlc_identifier("a.b")); + } +} diff --git a/validation/core/integration_test/component_public_api/negative_case_sensitive/BUILD b/validation/core/integration_test/component_public_api/negative_case_sensitive/BUILD index 071e5aec..b36eb62c 100644 --- a/validation/core/integration_test/component_public_api/negative_case_sensitive/BUILD +++ b/validation/core/integration_test/component_public_api/negative_case_sensitive/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_case_sensitive_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/negative_public_api_lack_of_relationship/BUILD b/validation/core/integration_test/component_public_api/negative_public_api_lack_of_relationship/BUILD index 071e5aec..c61e7795 100644 --- a/validation/core/integration_test/component_public_api/negative_public_api_lack_of_relationship/BUILD +++ b/validation/core/integration_test/component_public_api/negative_public_api_lack_of_relationship/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_public_api_lack_of_relationship_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/negative_public_api_missing/BUILD b/validation/core/integration_test/component_public_api/negative_public_api_missing/BUILD index 071e5aec..3cbbf7f5 100644 --- a/validation/core/integration_test/component_public_api/negative_public_api_missing/BUILD +++ b/validation/core/integration_test/component_public_api/negative_public_api_missing/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_public_api_missing_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestion/BUILD b/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestion/BUILD index 071e5aec..4a27bf6e 100644 --- a/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestion/BUILD +++ b/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestion/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_public_api_missing_with_suggestion_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestions/BUILD b/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestions/BUILD index 071e5aec..8bc77c1b 100644 --- a/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestions/BUILD +++ b/validation/core/integration_test/component_public_api/negative_public_api_missing_with_suggestions/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_public_api_missing_with_suggestions_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/negative_public_api_wrong_type/BUILD b/validation/core/integration_test/component_public_api/negative_public_api_wrong_type/BUILD index 071e5aec..4d9f49b6 100644 --- a/validation/core/integration_test/component_public_api/negative_public_api_wrong_type/BUILD +++ b/validation/core/integration_test/component_public_api/negative_public_api_wrong_type/BUILD @@ -17,6 +17,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", maturity = "development", + package = "negative_public_api_wrong_type_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/positive_aliased_public_api_interface/BUILD b/validation/core/integration_test/component_public_api/positive_aliased_public_api_interface/BUILD index c473da70..5ad3e51b 100644 --- a/validation/core/integration_test/component_public_api/positive_aliased_public_api_interface/BUILD +++ b/validation/core/integration_test/component_public_api/positive_aliased_public_api_interface/BUILD @@ -16,6 +16,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", + package = "positive_aliased_public_api_interface_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"], diff --git a/validation/core/integration_test/component_public_api/positive_overview_seooc_relation_merge/BUILD b/validation/core/integration_test/component_public_api/positive_overview_seooc_relation_merge/BUILD index 9b03d374..56d66a50 100644 --- a/validation/core/integration_test/component_public_api/positive_overview_seooc_relation_merge/BUILD +++ b/validation/core/integration_test/component_public_api/positive_overview_seooc_relation_merge/BUILD @@ -16,6 +16,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", + package = "positive_overview_seooc_relation_merge_api", public_api = ["public_api.puml"], static = [ "static_design.puml", diff --git a/validation/core/integration_test/component_public_api/positive_public_api_match/BUILD b/validation/core/integration_test/component_public_api/positive_public_api_match/BUILD index c473da70..c8987fe0 100644 --- a/validation/core/integration_test/component_public_api/positive_public_api_match/BUILD +++ b/validation/core/integration_test/component_public_api/positive_public_api_match/BUILD @@ -16,6 +16,7 @@ load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixtur architectural_design( name = "design", + package = "positive_public_api_match_api", public_api = ["public_api.puml"], static = ["static_design.puml"], visibility = ["//visibility:private"],