From 6621a05c99b944f65aec6d860d046a174e257f4c Mon Sep 17 00:00:00 2001 From: xiangu Date: Mon, 20 Apr 2026 14:16:35 +0800 Subject: [PATCH] validation framework --- bazel/rules/rules_score/README.md | 4 +- .../private/dependable_element.bzl | 50 +-- plantuml/parser/puml_serializer/src/fbs/BUILD | 1 + validation/archver/BUILD | 38 --- validation/archver/README.md | 82 ----- validation/archver/design/static_design.puml | 64 ---- validation/archver/src/main.rs | 97 ------ validation/archver/src/models.rs | 323 ------------------ validation/core/BUILD | 59 ++++ validation/core/README.md | 105 ++++++ .../docs/assets/validation_core_flow.puml | 70 ++++ .../docs/assets/validation_core_overview.puml | 96 ++++++ validation/core/src/lib.rs | 35 ++ validation/core/src/main.rs | 250 ++++++++++++++ validation/core/src/models/bazel_models.rs | 143 ++++++++ .../core/src/models/class_diagram_models.rs | 86 +++++ .../src/models/component_diagram_models.rs | 168 +++++++++ validation/core/src/models/error_models.rs | 29 ++ validation/core/src/models/mod.rs | 32 ++ validation/core/src/models/shared.rs | 30 ++ .../src => core/src/readers}/bazel_reader.rs | 11 + .../core/src/readers/class_diagram_reader.rs | 107 ++++++ .../src/readers/component_diagram_reader.rs} | 35 +- validation/core/src/readers/mod.rs | 50 +++ .../validators/bazel_component_validator.rs} | 152 ++++----- .../validators/component_class_validator.rs | 319 +++++++++++++++++ .../component_sequence_validator.rs | 23 ++ validation/core/src/validators/mod.rs | 65 ++++ 28 files changed, 1805 insertions(+), 719 deletions(-) delete mode 100644 validation/archver/BUILD delete mode 100644 validation/archver/README.md delete mode 100644 validation/archver/design/static_design.puml delete mode 100644 validation/archver/src/main.rs delete mode 100644 validation/archver/src/models.rs create mode 100644 validation/core/BUILD create mode 100644 validation/core/README.md create mode 100644 validation/core/docs/assets/validation_core_flow.puml create mode 100644 validation/core/docs/assets/validation_core_overview.puml create mode 100644 validation/core/src/lib.rs create mode 100644 validation/core/src/main.rs create mode 100644 validation/core/src/models/bazel_models.rs create mode 100644 validation/core/src/models/class_diagram_models.rs create mode 100644 validation/core/src/models/component_diagram_models.rs create mode 100644 validation/core/src/models/error_models.rs create mode 100644 validation/core/src/models/mod.rs create mode 100644 validation/core/src/models/shared.rs rename validation/{archver/src => core/src/readers}/bazel_reader.rs (82%) create mode 100644 validation/core/src/readers/class_diagram_reader.rs rename validation/{archver/src/diagram_reader.rs => core/src/readers/component_diagram_reader.rs} (72%) create mode 100644 validation/core/src/readers/mod.rs rename validation/{archver/src/validation.rs => core/src/validators/bazel_component_validator.rs} (80%) create mode 100644 validation/core/src/validators/component_class_validator.rs create mode 100644 validation/core/src/validators/component_sequence_validator.rs create mode 100644 validation/core/src/validators/mod.rs diff --git a/bazel/rules/rules_score/README.md b/bazel/rules/rules_score/README.md index 3e51029d..5546d0c2 100644 --- a/bazel/rules/rules_score/README.md +++ b/bazel/rules/rules_score/README.md @@ -87,7 +87,7 @@ architectural_design( ``` **`bazel build`** — runs `puml_parser` on every `.puml` file, producing: -- a `.fbs.bin` FlatBuffers binary (diagram AST) — consumed by archver validation +- a `.fbs.bin` FlatBuffers binary (diagram AST) — consumed by validation/core checks - a `.lobster` traceability file (Interface elements only) — consumed by LOBSTER - a `plantuml_links.json` — consumed by the `clickable_plantuml` Sphinx extension @@ -195,7 +195,7 @@ dependable_element( **`bazel build`** — generates a complete HTML documentation zip via Sphinx. Internally: 1. `_dependable_element_index` generates an `index.rst` aggregating all - artifacts, runs archver architecture validation as a subrule, and + artifacts, runs validation/core architecture checks as a subrule, and produces a DE-level LOBSTER report (`lobster_de.conf` template covering Feature Req → Component Req → Architecture → Public API → Failure Modes). 2. `sphinx_module` compiles all RST sources + diagrams into an HTML zip. diff --git a/bazel/rules/rules_score/private/dependable_element.bzl b/bazel/rules/rules_score/private/dependable_element.bzl index 3dbc8344..2986ebab 100644 --- a/bazel/rules/rules_score/private/dependable_element.bzl +++ b/bazel/rules/rules_score/private/dependable_element.bzl @@ -622,7 +622,7 @@ def _collect_architecture_components(ctx): return all_components -def _run_archver_validation(ctx, arch_json, static_fbs_files): +def _run_validation(ctx, arch_json, static_fbs_files): """Run the architecture verifier tool against a pre-built JSON file. Args: @@ -631,27 +631,27 @@ def _run_archver_validation(ctx, arch_json, static_fbs_files): static_fbs_files: List of static FlatBuffer files to verify against Returns: - archver_log File object + validation_log File object """ - archver_log = ctx.actions.declare_file(ctx.label.name + "/archver.log") + validation_log = ctx.actions.declare_file(ctx.label.name + "/validation.log") - archver_args = ctx.actions.args() - archver_args.add("--architecture-json", arch_json) - archver_args.add_all("--static-fbs", static_fbs_files) - archver_args.add("--output", archver_log) + validation_args = ctx.actions.args() + validation_args.add("--architecture-json", arch_json) + validation_args.add_all("--component-fbs", static_fbs_files) + validation_args.add("--output", validation_log) - # ctx.actions.run will fail the build if archver returns non-zero exit code + # ctx.actions.run will fail the build if validation_cli returns non-zero exit code ctx.actions.run( inputs = [arch_json] + static_fbs_files, - outputs = [archver_log], - executable = ctx.executable._archver, - arguments = [archver_args], - progress_message = "Verifying architecture: %s" % ctx.label.name, - mnemonic = "ArchVerify", + outputs = [validation_log], + executable = ctx.executable._validation_cli, + arguments = [validation_args], + progress_message = "Running validation: %s" % ctx.label.name, + mnemonic = "ArchitectureValidate", ) - return archver_log + return validation_log # ============================================================================ # Index Generation Rule Implementation @@ -780,7 +780,7 @@ def _dependable_element_index_impl(ctx): ) # ========================================================================= - # Architecture Verification: build current-architecture JSON and run archver + # Architecture Verification: build current-architecture JSON and run validation # ========================================================================= # Collect the current architecture from all components (via aspect) and @@ -800,13 +800,13 @@ def _dependable_element_index_impl(ctx): if ArchitecturalDesignInfo in ad: static_fbs_files.extend(ad[ArchitecturalDesignInfo].static.to_list()) - # Run architecture verifier; build fails automatically on non-zero exit - archver_log = _run_archver_validation(ctx, arch_json, static_fbs_files) + # Run validation; build fails automatically on non-zero exit + validation_log = _run_validation(ctx, arch_json, static_fbs_files) - # Both outputs are included so archver always runs in a default build. - # archver_log is also exposed in the debug output group for explicit access. + # Both outputs are included so validation always runs in a default build. + # validation_log is also exposed in the debug output group for explicit access. output_files.append(arch_json) - output_files.append(archver_log) + output_files.append(validation_log) # ========================================================================= # Safety Certification Validation: certified scope and integrity level checks @@ -961,7 +961,7 @@ def _dependable_element_index_impl(ctx): lobster_report = lobster_report_file, lobster_html_report = lobster_html_report, ), - OutputGroupInfo(debug = depset([archver_log])), + OutputGroupInfo(debug = depset([validation_log])), ] _dependable_element_index = rule( @@ -1024,11 +1024,11 @@ _dependable_element_index = rule( values = _INTEGRITY_LEVELS, doc = "Integrity level of the dependable element. Allowed values: 'A', 'B', 'C', 'D' (D > C > B > A).", ), - "_archver": attr.label( - default = Label("//validation/archver"), + "_validation_cli": attr.label( + default = Label("//validation/core:validation_cli"), executable = True, cfg = "exec", - doc = "Architecture verifier tool", + doc = "Validation CLI tool", ), "_lobster_de_template": attr.label( default = Label("//bazel/rules/rules_score/lobster/config:lobster_de_template"), @@ -1201,7 +1201,7 @@ def dependable_element( processed_deps.append("{}_index".format(dep)) # Step 1: Generate index.rst and collect all artifacts - # Note: archver validation runs as a subrule within the index generation + # Note: validation runs as a subrule within the index generation _dependable_element_index( name = name + "_index", module_name = name, diff --git a/plantuml/parser/puml_serializer/src/fbs/BUILD b/plantuml/parser/puml_serializer/src/fbs/BUILD index 361c00fb..3de84117 100644 --- a/plantuml/parser/puml_serializer/src/fbs/BUILD +++ b/plantuml/parser/puml_serializer/src/fbs/BUILD @@ -72,6 +72,7 @@ rust_library( ], visibility = [ "//plantuml/parser:__subpackages__", + "//validation/core:__subpackages__", ], deps = [ "@crates//:flatbuffers", diff --git a/validation/archver/BUILD b/validation/archver/BUILD deleted file mode 100644 index 21c2550a..00000000 --- a/validation/archver/BUILD +++ /dev/null @@ -1,38 +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 -# https://www.apache.org/licenses/LICENSE-2.0 -# -# SPDX-License-Identifier: Apache-2.0 -# ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") - -rust_binary( - name = "archver", - srcs = [ - "src/bazel_reader.rs", - "src/diagram_reader.rs", - "src/main.rs", - "src/models.rs", - "src/validation.rs", - ], - crate_root = "src/main.rs", - visibility = ["//visibility:public"], - deps = [ - "//plantuml/parser/puml_serializer/src/fbs:component_fbs", - "@crates//:clap", - "@crates//:flatbuffers", - "@crates//:serde", - "@crates//:serde_json", - ], -) - -rust_test( - name = "archver_test", - crate = ":archver", -) diff --git a/validation/archver/README.md b/validation/archver/README.md deleted file mode 100644 index 52e0d1d5..00000000 --- a/validation/archver/README.md +++ /dev/null @@ -1,82 +0,0 @@ - - -# Architecture Verifier (archver) - -Validates that PlantUML component diagrams match the Bazel build graph structure. - -## Overview - -The archver tool ensures architectural consistency by comparing: -- **Bazel build graph**: Dependable element, component and unit hierarchy from the build system -- **PlantUML diagrams**: Static architecture documentation with stereotypes - -## Usage - -### Standalone CLI - -```bash -archver \ - --architecture-json path/to/architecture.json \ - --static-fbs path/to/diagram1.fbs.bin path/to/diagram2.fbs.bin - -# Write a debug log in addition to stderr output -archver \ - --architecture-json path/to/architecture.json \ - --static-fbs path/to/diagram.fbs.bin \ - --output path/to/archver.log -``` - -Exits with code `0` on success, `1` on any validation error or I/O failure. - -## What is Validated - -### Dependable Element -The dependable element is represented in the Bazel JSON as a top-level component -entry. In PlantUML, it corresponds to a `package` with stereotype `<>`. -The alias of the PlantUML package must match the Bazel dependable element name. - -### Component Validation (Check 1) -For each unique `(component_alias, parent_alias)` combination that Bazel expects: -- PlantUML must have the exact same number of components with that combination -- Uses target name only (package path is stripped) -- Components nested under the dependable element have the dependable element as parent -- Entities must have stereotype `<>` - -### Unit Validation (Check 2) -For each unique `(unit_alias, parent_component_alias)` combination that Bazel expects: -- PlantUML must have the exact same number of units with that combination -- Uses target name only (e.g., `unit_1`, `unit_2`) -- Entities must have stereotype `<>` - -### Duplicate Detection -If two Bazel targets resolve to the same `(alias, parent)` key (e.g., same target -name in different packages), or two PlantUML entities have the same key, an error -is emitted. - -### Example -If Bazel build graph has: -- Dependable element: `safety_software_seooc_example` → key: `safety_software_seooc_example` -- Component: `@//bazel/rules/rules_score/examples/seooc:component_example` → key: `component_example` (parent: `safety_software_seooc_example`) -- Unit: `@//bazel/rules/rules_score/examples/seooc/unit_1:unit_1` → key: `unit_1` (parent: `component_example`) -- Unit: `@//bazel/rules/rules_score/examples/seooc/unit_2:unit_2` → key: `unit_2` (parent: `component_example`) - -PlantUML must have: -```plantuml -package "Safety Software SEooC Example" as safety_software_seooc_example <> { - component "ComponentExample" as component_example <> { - component "Unit 1" as unit_1 <> - component "Unit 2" as unit_2 <> - } -} -``` diff --git a/validation/archver/design/static_design.puml b/validation/archver/design/static_design.puml deleted file mode 100644 index cdf8379c..00000000 --- a/validation/archver/design/static_design.puml +++ /dev/null @@ -1,64 +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 -' https://www.apache.org/licenses/LICENSE-2.0 -' -' SPDX-License-Identifier: Apache-2.0 -' ******************************************************************************* - -@startuml - -package "archver" as archver { - - component "main" as main <> { - component "Args" as args <> - } - - component "models" as models <> { - component "BazelInput" as bazel_input <> - component "BazelArchitecture" as bazel_architecture <> - component "DiagramInputs" as diagram_inputs_model <> - component "DiagramInput" as diagram_input_model <> - component "DiagramArchitecture" as diagram_architecture <> - } - - component "bazel_reader" as bazel_reader <> { - component "BazelReader" as bazel_reader_cls <> - } - - component "diagram_reader" as reader <> { - component "DiagramReader" as diagram_reader_cls <> - } - - component "validation" as validation <> { - component "Validator" as validator <> - } - -} - -file "architecture.json\n(from dependable_element rule)" as arch_json_file -file "*.fbs.bin\n(from architectural_design rule)" as fbs_files - -main --> bazel_reader_cls : reads JSON -main --> diagram_reader_cls : reads FBS files -main --> validator : runs validation - -bazel_reader_cls --> bazel_input : produces -diagram_reader_cls --> diagram_inputs_model : produces - -bazel_input --> bazel_architecture : to_bazel_architecture() -diagram_inputs_model --> diagram_architecture : to_diagram_architecture() -validator --> bazel_architecture : compares -validator --> diagram_architecture : compares - -arch_json_file ..> bazel_reader_cls : --architecture-json -fbs_files ..> diagram_reader_cls : --static-fbs - -bazel_architecture -[hidden]r-> diagram_architecture - -@enduml diff --git a/validation/archver/src/main.rs b/validation/archver/src/main.rs deleted file mode 100644 index 3ee82072..00000000 --- a/validation/archver/src/main.rs +++ /dev/null @@ -1,97 +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 -// ******************************************************************************* - -//! Architecture Verifier (archver) -//! -//! Validates that the PlantUML component diagram matches the Bazel build graph. -//! Uses stereotypes (`<>`, `<>`, `<>`) to determine entity type. -//! Package entities (`<>`) are matched against the dependable element. -//! Comparison uses target names only (package paths are stripped). - -mod bazel_reader; -mod diagram_reader; -mod models; -mod validation; - -use std::fs; -use std::process; - -use clap::Parser; - -use bazel_reader::BazelReader; -use diagram_reader::DiagramReader; -use models::Errors; -use validation::validate; - -#[derive(Parser, Debug)] -#[command(name = "archver")] -#[command(version = "1.0")] -#[command(about = "Validate architecture: compare Bazel build graph against PlantUML diagrams")] -struct Args { - #[arg(long)] - architecture_json: String, - - #[arg(long, num_args = 1..)] - static_fbs: Vec, - - #[arg(long)] - output: Option, -} - -fn run(args: Args) -> Result<(), String> { - let architecture = BazelReader::read(&args.architecture_json)?; - let diagram = DiagramReader::read(&args.static_fbs)?; - - // Debug output is always produced; it is written to the log file which - // is exposed via --output_groups=debug. - let errors = validate(&architecture, &diagram); - - if let Some(ref path) = args.output { - write_log(path, &errors)?; - } - - if errors.is_empty() { - Ok(()) - } else { - let mut output = format!( - "Architecture verification FAILED ({} error(s)):\n\n", - errors.messages.len() - ); - for (i, msg) in errors.messages.iter().enumerate() { - output.push_str(&format!(" [{}] {}\n\n", i + 1, msg)); - } - Err(output) - } -} - -fn write_log(path: &str, errors: &Errors) -> Result<(), String> { - let content = if errors.is_empty() { - format!("PASS\n\n{}", errors.debug_output) - } else { - let mut s = format!("FAILED ({} error(s)):\n\n", errors.messages.len()); - for (i, msg) in errors.messages.iter().enumerate() { - s.push_str(&format!("[{}] {}\n\n", i + 1, msg)); - } - s.push_str("\n--- Debug Information ---\n\n"); - s.push_str(&errors.debug_output); - s - }; - fs::write(path, content).map_err(|e| format!("Failed to write output file {path}: {e}")) -} - -fn main() { - if let Err(msg) = run(Args::parse()) { - eprint!("{msg}"); - process::exit(1); - } -} diff --git a/validation/archver/src/models.rs b/validation/archver/src/models.rs deleted file mode 100644 index 9a5e853c..00000000 --- a/validation/archver/src/models.rs +++ /dev/null @@ -1,323 +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 -// ******************************************************************************* - -use std::collections::{BTreeMap, BTreeSet}; - -use serde::Deserialize; - -// --------------------------------------------------------------------------- -// Shared key type -// --------------------------------------------------------------------------- - -/// Composite key: `(canonical_alias, parent_alias)`. `parent_alias` is `None` -/// for top-level entities. Using the parent as part of the key means two -/// identically-named entities under different parents are treated as distinct. -pub type EntityKey = (String, Option); - -/// Extract the target name from a Bazel label like `@//path/to/package:target` -/// → `"target"`. Returns the full label unchanged if it contains no colon. -/// Returns `Err` if the extracted name is empty. -pub(crate) fn label_short_name(label: &str) -> Result<&str, String> { - let name = label.rsplit_once(':').map(|(_, n)| n).unwrap_or(label); - if name.is_empty() { - return Err(format!("Empty target name extracted from label: {label:?}")); - } - Ok(name) -} - -// --------------------------------------------------------------------------- -// Bazel architecture JSON model -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BazelInput { - pub components: BTreeMap, -} - -impl BazelInput { - /// Build a [`BazelArchitecture`] index from this architecture JSON. - /// - /// A pre-pass collects all **full** labels of components that appear as - /// children of another component so that only their exact label is used - /// for child-suppression — preventing a top-level component from being - /// silently treated as nested just because another target in a different - /// package shares the same short name. - pub fn to_bazel_architecture(&self, errors: &mut Errors) -> BazelArchitecture { - let mut seooc_set = BTreeMap::new(); - let mut comp_set = BTreeMap::new(); - let mut unit_set = BTreeMap::new(); - - let child_labels: BTreeSet = self - .components - .values() - .flat_map(|e| e.components.iter()) - .map(|l| l.to_lowercase()) - .collect(); - - for (comp_label, entry) in &self.components { - let comp_key = match label_short_name(comp_label) { - Ok(name) => name.to_lowercase(), - Err(msg) => { - errors.push(msg); - continue; - } - }; - - if !child_labels.contains(&comp_label.to_lowercase()) { - // Top-level entries are dependable elements (SEooC) - let key = (comp_key.clone(), None); - if let Some(prev) = seooc_set.insert(key.clone(), comp_label.clone()) { - errors.push(format!( - "Duplicate dependable element key in Bazel build graph:\n\ - Key : {:?}\n\ - Labels: {} and {}", - key, prev, comp_label - )); - } - } - - for u_label in &entry.units { - let u_key = match label_short_name(u_label) { - Ok(name) => name.to_lowercase(), - Err(msg) => { - errors.push(msg); - continue; - } - }; - let key = (u_key, Some(comp_key.clone())); - if let Some(prev) = unit_set.insert(key.clone(), u_label.clone()) { - errors.push(format!( - "Duplicate unit key in Bazel build graph:\n\ - Key : {:?}\n\ - Labels: {} and {}", - key, prev, u_label - )); - } - } - - for c_label in &entry.components { - let c_key = match label_short_name(c_label) { - Ok(name) => name.to_lowercase(), - Err(msg) => { - errors.push(msg); - continue; - } - }; - let key = (c_key, Some(comp_key.clone())); - if let Some(prev) = comp_set.insert(key.clone(), c_label.clone()) { - errors.push(format!( - "Duplicate component key in Bazel build graph:\n\ - Key : {:?}\n\ - Labels: {} and {}", - key, prev, c_label - )); - } - } - } - - BazelArchitecture { - seooc_set, - comp_set, - unit_set, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BazelInputEntry { - #[serde(default)] - pub units: Vec, - #[serde(default)] - pub components: Vec, -} - -/// Indexed entity key-maps derived from the Bazel build graph. -/// -/// Map values are the original Bazel label strings. -/// Built via [`BazelInput::to_bazel_architecture`]. -pub struct BazelArchitecture { - /// Top-level dependable elements (`<>`), keyed with `parent = None`. - pub seooc_set: BTreeMap, - /// Nested components (`<>`), keyed with `parent = Some(..)`. - pub comp_set: BTreeMap, - pub unit_set: BTreeMap, -} - -// --------------------------------------------------------------------------- -// PlantUML diagram model -// --------------------------------------------------------------------------- - -/// A single component-level entity parsed from a PlantUML `.fbs.bin` file. -#[derive(Debug, Clone, PartialEq)] -pub struct DiagramInput { - pub id: String, - pub alias: Option, - pub parent_id: Option, - pub stereotype: Option, -} - -impl DiagramInput { - /// Canonical match key: alias (lowercased) when present, otherwise raw id. - pub fn match_key(&self) -> String { - self.alias.as_deref().unwrap_or(&self.id).to_lowercase() - } - - pub fn is_component(&self) -> bool { - self.stereotype.as_deref() == Some("component") - } - - pub fn is_unit(&self) -> bool { - self.stereotype.as_deref() == Some("unit") - } - - /// Returns `true` for `<>` package entities (dependable elements). - pub fn is_seooc_package(&self) -> bool { - self.stereotype.as_deref() == Some("SEooC") - } -} - -/// Collection of raw PlantUML entities read from FlatBuffers files. -/// -/// Symmetric peer of [`BazelInput`]: produced by [`DiagramReader`] and -/// consumed by [`to_diagram_architecture`](DiagramInputs::to_diagram_architecture). -pub struct DiagramInputs { - pub entities: Vec, -} - -impl DiagramInputs { - /// Build a [`DiagramArchitecture`] index from these diagram inputs. - pub fn to_diagram_architecture(&self, errors: &mut Errors) -> DiagramArchitecture { - DiagramArchitecture::from_entities(&self.entities, errors) - } -} - -/// Indexed entity key-maps derived from the parsed PlantUML diagram entities. -/// -/// Built via [`DiagramInputs::to_diagram_architecture`]. -pub struct DiagramArchitecture { - /// `<>` package entities, keyed with `parent = None`. - pub seooc_set: BTreeMap, - /// `<>` entities, keyed with `parent = Some(..)`. - pub comp_set: BTreeMap, - pub unit_set: BTreeMap, - /// Full raw entity list, kept for debug output. - pub entities: Vec, - pub filtered_seooc_count: usize, - pub filtered_component_count: usize, - pub filtered_unit_count: usize, -} - -impl DiagramArchitecture { - /// Index `entities` by stereotype and parent alias. - /// - /// `<>` go into `seooc_set`; - /// `<>` go into `comp_set`; - /// `<>` go into `unit_set`. - /// Duplicates (same [`EntityKey`]) are reported via `errors`. - fn from_entities(entities: &[DiagramInput], errors: &mut Errors) -> Self { - // Index by raw id for parent resolution (PlantUML nesting uses id, not alias). - let mut id_index: BTreeMap = BTreeMap::new(); - for e in entities { - let key = e.id.to_lowercase(); - if let Some(prev) = id_index.insert(key.clone(), e) { - errors.push(format!( - "Duplicate entity ID in PlantUML diagram (case-insensitive):\n\ - ID : {key:?}\n\ - IDs: {} and {}", - prev.id, e.id - )); - } - } - - let seoocs: Vec<&DiagramInput> = entities.iter().filter(|e| e.is_seooc_package()).collect(); - let components: Vec<&DiagramInput> = entities.iter().filter(|e| e.is_component()).collect(); - let units: Vec<&DiagramInput> = entities.iter().filter(|e| e.is_unit()).collect(); - - let filtered_seooc_count = seoocs.len(); - let filtered_component_count = components.len(); - let filtered_unit_count = units.len(); - - let seooc_set = Self::build_set(&seoocs, &id_index, errors); - let comp_set = Self::build_set(&components, &id_index, errors); - let unit_set = Self::build_set(&units, &id_index, errors); - - Self { - seooc_set, - comp_set, - unit_set, - entities: entities.to_vec(), - filtered_seooc_count, - filtered_component_count, - filtered_unit_count, - } - } - - fn build_set( - items: &[&DiagramInput], - id_index: &BTreeMap, - errors: &mut Errors, - ) -> BTreeMap { - let mut set = BTreeMap::new(); - for e in items { - let alias = e.match_key(); - let parent_alias = match &e.parent_id { - Some(pid) => match id_index.get(&pid.to_lowercase()) { - Some(p) => Some(p.match_key()), - None => { - errors.push(format!( - "Unresolved parent_id in PlantUML diagram:\n\ - Entity ID : {}\n\ - Parent ID : {}\n\ - Action : Fix the parent reference or add the missing parent entity", - e.id, pid - )); - None - } - }, - None => None, - }; - let key = (alias, parent_alias); - if let Some(prev) = set.insert(key.clone(), (*e).clone()) { - errors.push(format!( - "Duplicate entity in PlantUML diagram:\n\ - Key: {:?}\n\ - IDs: {} and {}", - key, prev.id, e.id - )); - } - } - set - } -} - -// --------------------------------------------------------------------------- -// Error accumulator -// --------------------------------------------------------------------------- - -#[derive(Debug, Default)] -pub struct Errors { - pub messages: Vec, - pub debug_output: String, -} - -impl Errors { - pub fn push(&mut self, message: String) { - self.messages.push(message); - } - - pub fn is_empty(&self) -> bool { - self.messages.is_empty() - } -} diff --git a/validation/core/BUILD b/validation/core/BUILD new file mode 100644 index 00000000..06b468ea --- /dev/null +++ b/validation/core/BUILD @@ -0,0 +1,59 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +rust_library( + name = "validation", + srcs = [ + "src/lib.rs", + "src/models/bazel_models.rs", + "src/models/class_diagram_models.rs", + "src/models/component_diagram_models.rs", + "src/models/error_models.rs", + "src/models/mod.rs", + "src/models/shared.rs", + "src/readers/bazel_reader.rs", + "src/readers/class_diagram_reader.rs", + "src/readers/component_diagram_reader.rs", + "src/readers/mod.rs", + "src/validators/bazel_component_validator.rs", + "src/validators/component_class_validator.rs", + "src/validators/component_sequence_validator.rs", + "src/validators/mod.rs", + ], + crate_root = "src/lib.rs", + visibility = ["//visibility:public"], + deps = [ + "//plantuml/parser/puml_serializer/src/fbs:class_fbs", + "//plantuml/parser/puml_serializer/src/fbs:component_fbs", + "@crates//:flatbuffers", + "@crates//:serde", + "@crates//:serde_json", + ], +) + +rust_binary( + name = "validation_cli", + srcs = ["src/main.rs"], + crate_root = "src/main.rs", + visibility = ["//visibility:public"], + deps = [ + ":validation", + "@crates//:clap", + ], +) + +rust_test( + name = "validation_test", + crate = ":validation", +) diff --git a/validation/core/README.md b/validation/core/README.md new file mode 100644 index 00000000..19eb5c43 --- /dev/null +++ b/validation/core/README.md @@ -0,0 +1,105 @@ + + +# Validation Core + +`validation/core` provides the shared Rust library and CLI used to validate +consistency between Bazel architecture data and PlantUML-derived models. + +The package contains two public targets: + +| Target | Kind | Purpose | +|--------|------|---------| +| `//validation/core:validation` | `rust_library` | Shared readers, models, and validators | +| `//validation/core:validation_cli` | `rust_binary` | CLI entrypoint that infers which validations can run from supplied inputs | + +## What It Validates + +The current implementation supports two validation flows: + +1. `BazelComponent`: compares the indexed Bazel build graph with the indexed + PlantUML component-diagram structure. +2. `ComponentClass`: compares component-diagram unit aliases with enclosing + namespace IDs observed in class diagrams. + +The CLI builds a `ValidationContext` from the provided inputs, infers which of +these flows are executable, and runs all compatible validators in one pass. + +## Layering + +The crate is intentionally split into three layers: + +- `readers/`: deserialize raw input files. +- `models/`: normalize those inputs into indexed structures used by + validations. +- `validators/`: compare prepared model/index structures and accumulate + `Errors`. + +`src/main.rs` is the orchestration boundary. It reads CLI arguments, builds the +shared `ValidationContext`, selects runnable validators, merges their results, +and optionally writes a validation log. + +This keeps validators focused on comparison logic instead of file loading or +model construction. + +## Inputs + +The CLI accepts the following input families: + +- `--architecture-json`: Bazel architecture export consumed by `BazelReader` +- `--component-fbs`: one or more component-diagram FlatBuffers files consumed by + `ComponentDiagramReader` +- `--class-fbs`: one or more class-diagram FlatBuffers files consumed by + `ClassDiagramReader` + +The current inference rules are: + +- `--architecture-json` + `--component-fbs` enables `BazelComponent` +- `--component-fbs` + `--class-fbs` enables `ComponentClass` + +If both combinations are present, both validators are executed. + +## Run + +Build the CLI: + +```bash +bazel build //validation/core:validation_cli +``` + +Run it directly: + +```bash +bazel run //validation/core:validation_cli -- \ + --architecture-json path/to/architecture.json \ + --component-fbs path/to/component.fbs.bin \ + --class-fbs path/to/class.fbs.bin \ + --output path/to/validation.log +``` + +Run unit tests: + +```bash +bazel test //validation/core:validation_test +``` + +## Architectural Overview + +PlantUML source diagrams for the current design are stored in: + +- `docs/assets/validation_core_overview.puml` +- `docs/assets/validation_core_flow.puml` + +The first diagram shows the static module responsibilities. The second shows +the runtime flow from CLI input parsing to validator execution and result +aggregation. diff --git a/validation/core/docs/assets/validation_core_flow.puml b/validation/core/docs/assets/validation_core_flow.puml new file mode 100644 index 00000000..d0ea2511 --- /dev/null +++ b/validation/core/docs/assets/validation_core_flow.puml @@ -0,0 +1,70 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml validation_core_flow +title Validation Core Runtime Flow + +participant "CLI" as cli +participant "BazelReader" as bazel_reader +participant "ComponentDiagramReader" as component_reader +participant "ClassDiagramReader" as class_reader +participant "ValidationContext" as context +participant "validate_bazel_component()" as bazel_validator +participant "validate_component_class()" as class_validator +participant "Errors" as errors + +cli -> cli: parse Args +cli -> cli: build ValidationCliInputs + +opt architecture json provided + cli -> bazel_reader: read(path) + bazel_reader --> cli: BazelInput + cli -> cli: to_bazel_architecture(&mut base_errors) +end + +opt component fbs provided + cli -> component_reader: read(paths) + component_reader --> cli: ComponentDiagramInputs + cli -> cli: to_diagram_architecture(&mut base_errors) +end + +opt class fbs provided + cli -> class_reader: read(paths) + class_reader --> cli: ClassDiagramInputs + cli -> cli: to_class_diagram_index(&mut base_errors) +end + +cli -> context: assemble ValidationContext +cli -> cli: resolve_validators(context) + +opt bazel + component available + cli -> bazel_validator: validate_bazel_component(..., Errors::default()) + bazel_validator --> cli: Errors + cli -> errors: merge_errors(...) +end + +opt component + class available + cli -> class_validator: validate_component_class(..., Errors::default()) + class_validator --> cli: Errors + cli -> errors: merge_errors(...) +end + +cli -> cli: finish_validation(output_path, &errors) + +alt no errors + cli --> cli: return Ok(()) +else errors found + cli --> cli: return Err(formatted report) +end + +@enduml diff --git a/validation/core/docs/assets/validation_core_overview.puml b/validation/core/docs/assets/validation_core_overview.puml new file mode 100644 index 00000000..2462fd3e --- /dev/null +++ b/validation/core/docs/assets/validation_core_overview.puml @@ -0,0 +1,96 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml validation_core_overview +title Validation Core Static Overview + +skinparam packageStyle rectangle +skinparam shadowing false +skinparam linetype ortho + +package "validation/core" { + class "validation_cli\nsrc/main.rs" as cli { + +run(args) + +build_validation_context(inputs) + +resolve_validators(context) + +run_selected_validators(...) + } + + package "readers" { + class "BazelReader" + class "ComponentDiagramReader" + class "ClassDiagramReader" + } + + package "models" { + class "BazelArchitecture" + class "ComponentDiagramArchitecture" + class "ClassDiagramInputs" + class "ClassDiagramIndex" + class "Errors" + } + + package "validators" { + class "validate_bazel_component()" + class "validate_component_class()" + class "validate_component_sequence()" + } + + class "ValidationContext" as context { + +base_errors: Errors + +bazel: Option + +component: Option + +class: Option + } +} + +cli --> BazelReader : reads architecture json +cli --> ComponentDiagramReader : reads component fbs +cli --> ClassDiagramReader : reads class fbs + +BazelReader --> BazelArchitecture +ComponentDiagramReader --> ComponentDiagramArchitecture +ClassDiagramReader --> ClassDiagramInputs +cli --> ClassDiagramIndex : to_class_diagram_index(...) + +cli --> context : builds +context --> Errors +context --> BazelArchitecture +context --> ComponentDiagramArchitecture +context --> ClassDiagramIndex + +cli --> "validate_bazel_component()" : when bazel + component exist +cli --> "validate_component_class()" : when component + class exist + +"validate_bazel_component()" --> BazelArchitecture +"validate_bazel_component()" --> ComponentDiagramArchitecture +"validate_bazel_component()" --> Errors + +"validate_component_class()" --> ComponentDiagramArchitecture +"validate_component_class()" --> ClassDiagramIndex +"validate_component_class()" --> Errors + +note bottom of cli +CLI owns orchestration: +- input loading +- model/index preparation +- validator selection +- error merging and output writing +end note + +note bottom of "validate_component_class()" +Validators consume prepared inputs only. +They do not read files or build indexes. +end note + +@enduml diff --git a/validation/core/src/lib.rs b/validation/core/src/lib.rs new file mode 100644 index 00000000..07134101 --- /dev/null +++ b/validation/core/src/lib.rs @@ -0,0 +1,35 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Unified validation library. +//! +//! This crate contains the shared models, readers, and validators used by the +//! CLI entrypoints for architecture and design verification. + +pub mod models; +pub mod readers; +pub mod validators; + +pub use models::{ + BazelArchitecture, BazelInput, BazelInputEntry, ClassDiagramEntityInput, ClassDiagramIndex, + ClassDiagramInput, ClassDiagramInputs, ClassDiagramRelationshipInput, + ComponentDiagramArchitecture, ComponentDiagramInput, ComponentDiagramInputs, EntityKey, Errors, +}; + +pub use readers::{BazelReader, ClassDiagramReader, ComponentDiagramReader, Reader}; + +pub use validators::{ + validate_bazel_component, validate_component_class, validate_component_sequence, + BazelComponentValidator, ComponentClassValidator, RequiredInput, SelectedValidator, + ValidatorSpec, ALL_VALIDATORS, +}; diff --git a/validation/core/src/main.rs b/validation/core/src/main.rs new file mode 100644 index 00000000..2cb4bac6 --- /dev/null +++ b/validation/core/src/main.rs @@ -0,0 +1,250 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Validation CLI entrypoint. +//! +//! Supports architecture and design validations inferred from provided +//! input types. + +use std::fs; +use std::mem; +use std::process; + +use clap::Parser; +use validation::{ + validate_bazel_component, validate_component_class, BazelArchitecture, BazelInput, BazelReader, + ClassDiagramIndex, ClassDiagramInputs, ClassDiagramReader, ComponentDiagramArchitecture, + ComponentDiagramInputs, ComponentDiagramReader, Errors, Reader, RequiredInput, + SelectedValidator, ValidatorSpec, ALL_VALIDATORS, +}; + +#[derive(Parser, Debug)] +#[command(name = "validation")] +#[command(version = "1.0")] +#[command(about = "Validate architecture and design consistency from PlantUML exports")] +struct Args { + #[arg(long)] + architecture_json: Option, + + #[arg(long = "component-fbs", num_args = 1..)] + component_fbs: Option>, + + #[arg(long = "class-fbs", num_args = 1..)] + class_fbs: Option>, + + #[arg(long)] + output: Option, +} + +struct ValidationCliInputs { + architecture_json: Option, + component_fbs: Vec, + class_fbs: Vec, +} + +struct ValidationContext { + base_errors: Errors, + bazel: Option, + component: Option, + class: Option, +} + +impl ValidationContext { + fn has_input(&self, input: RequiredInput) -> bool { + match input { + RequiredInput::Bazel => self.bazel.is_some(), + RequiredInput::Component => self.component.is_some(), + RequiredInput::Class => self.class.is_some(), + } + } +} + +fn read_and_convert( + input: &R::Input, + errors: &mut Errors, + convert: impl Fn(R::Raw, &mut Errors) -> O, +) -> Result, String> +where + R: Reader, +{ + if !R::is_present(input) { + return Ok(None); + } + + let raw = R::read(input).map_err(|e| e.to_string())?; + Ok(Some(convert(raw, errors))) +} + +fn run(args: Args) -> Result<(), String> { + let inputs = ValidationCliInputs { + architecture_json: args.architecture_json, + component_fbs: args.component_fbs.unwrap_or_default(), + class_fbs: args.class_fbs.unwrap_or_default(), + }; + + let mut context = build_validation_context(inputs)?; + let validators = resolve_validators(&context)?; + + run_selected_validators(args.output.as_deref(), &validators, &mut context) +} + +fn resolve_validators(context: &ValidationContext) -> Result, String> { + let inferred = ALL_VALIDATORS + .iter() + .copied() + .filter(|validator| validator.can_run(|input| context.has_input(input))) + .collect::>(); + + if inferred.is_empty() { + Err( + "Unable to infer any validation to run from inputs. Provide compatible input files (for example: --architecture-json with --component-fbs, or --component-fbs with --class-fbs)." + .to_string(), + ) + } else { + Ok(inferred) + } +} + +fn run_selected_validators( + output_path: Option<&str>, + validators: &[SelectedValidator], + context: &mut ValidationContext, +) -> Result<(), String> { + let mut errors = mem::take(&mut context.base_errors); + + for validator in validators { + merge_errors(&mut errors, run_validator(*validator, context)); + } + + finish_validation(output_path, &errors) +} + +fn run_validator(validator: SelectedValidator, context: &ValidationContext) -> Errors { + match validator { + SelectedValidator::BazelComponent => { + let (bazel, component) = bazel_component_refs(context) + .expect("BazelComponent validator requires Bazel and component inputs"); + validate_bazel_component(bazel, component, Errors::default()) + } + SelectedValidator::ComponentClass => { + let (component, class) = component_class_refs(context) + .expect("ComponentClass validator requires component and class inputs"); + validate_component_class(component, class, Errors::default()) + } + } +} + +fn build_validation_context(inputs: ValidationCliInputs) -> Result { + let mut errors = Errors::default(); + let bazel = match inputs.architecture_json.as_deref() { + Some(path) => read_and_convert::( + path, + &mut errors, + |raw: BazelInput, errs| raw.to_bazel_architecture(errs), + )?, + None => None, + }; + let component = read_and_convert::( + inputs.component_fbs.as_slice(), + &mut errors, + |raw: ComponentDiagramInputs, errs| raw.to_diagram_architecture(errs), + )?; + let class = read_and_convert::( + inputs.class_fbs.as_slice(), + &mut errors, + |raw: ClassDiagramInputs, errs| raw.to_class_diagram_index(errs), + )?; + + Ok(ValidationContext { + base_errors: errors, + bazel, + component, + class, + }) +} + +fn bazel_component_refs( + context: &ValidationContext, +) -> Option<(&BazelArchitecture, &ComponentDiagramArchitecture)> { + Some((context.bazel.as_ref()?, context.component.as_ref()?)) +} + +fn component_class_refs( + context: &ValidationContext, +) -> Option<(&ComponentDiagramArchitecture, &ClassDiagramIndex)> { + Some((context.component.as_ref()?, context.class.as_ref()?)) +} + +fn merge_errors(target: &mut Errors, incoming: Errors) { + target.messages.extend(incoming.messages); + if !incoming.debug_output.is_empty() { + if !target.debug_output.is_empty() { + target.debug_output.push_str("\n\n"); + } + target.debug_output.push_str(&incoming.debug_output); + } +} + +fn finish_validation(output_path: Option<&str>, errors: &Errors) -> Result<(), String> { + if let Some(path) = output_path { + write_log(path, errors)?; + } + + if errors.is_empty() { + Ok(()) + } else { + let details = errors + .messages + .iter() + .enumerate() + .map(|(i, msg)| format!(" [{}] {}", i + 1, msg)) + .collect::>() + .join("\n\n"); + let output = format!( + "Verification FAILED ({} error(s)):\n\n{}", + errors.messages.len(), + details + ); + Err(output) + } +} + +fn write_log(path: &str, errors: &Errors) -> Result<(), String> { + let content = if errors.is_empty() { + format!("PASS\n\n{}", errors.debug_output) + } else { + let details = errors + .messages + .iter() + .enumerate() + .map(|(i, msg)| format!("[{}] {}", i + 1, msg)) + .collect::>() + .join("\n\n"); + let mut s = format!( + "FAILED ({} error(s)):\n\n{}", + errors.messages.len(), + details + ); + s.push_str("\n--- Debug Information ---\n\n"); + s.push_str(&errors.debug_output); + s + }; + fs::write(path, content).map_err(|e| format!("Failed to write output file {path}: {e}")) +} + +fn main() { + if let Err(msg) = run(Args::parse()) { + eprintln!("{msg}"); + process::exit(1); + } +} diff --git a/validation/core/src/models/bazel_models.rs b/validation/core/src/models/bazel_models.rs new file mode 100644 index 00000000..2fb3176a --- /dev/null +++ b/validation/core/src/models/bazel_models.rs @@ -0,0 +1,143 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Deserialize; + +use super::shared::label_short_name; +use super::{EntityKey, Errors}; + +// --------------------------------------------------------------------------- +/// Bazel architecture JSON model produced by the dependable element rule. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BazelInput { + pub components: BTreeMap, +} + +impl BazelInput { + /// Build a [`BazelArchitecture`] index from this architecture JSON. + /// + /// A pre-pass collects all **full** labels of components that appear as + /// children of another component so that only their exact label is used + /// for child-suppression - preventing a top-level component from being + /// silently treated as nested just because another target in a different + /// package shares the same short name. + pub fn to_bazel_architecture(&self, errors: &mut Errors) -> BazelArchitecture { + let mut seooc_set = BTreeMap::new(); + let mut comp_set = BTreeMap::new(); + let mut unit_set = BTreeMap::new(); + + let child_labels: BTreeSet = self + .components + .values() + .flat_map(|entry| entry.components.iter()) + .map(|label| label.to_lowercase()) + .collect(); + + for (comp_label, entry) in &self.components { + let comp_key = match label_short_name(comp_label) { + Ok(name) => name.to_lowercase(), + Err(msg) => { + errors.push(msg); + continue; + } + }; + + if !child_labels.contains(&comp_label.to_lowercase()) { + // Top-level entries are dependable elements (SEooC). + let key = (comp_key.clone(), None); + if let Some(prev) = seooc_set.insert(key.clone(), comp_label.clone()) { + errors.push(format!( + "Duplicate dependable element key in Bazel build graph:\n\ + Key : {:?}\n\ + Labels: {} and {}", + key, prev, comp_label + )); + } + } + + for unit_label in &entry.units { + let unit_key = match label_short_name(unit_label) { + Ok(name) => name.to_lowercase(), + Err(msg) => { + errors.push(msg); + continue; + } + }; + let key = (unit_key, Some(comp_key.clone())); + if let Some(prev) = unit_set.insert(key.clone(), unit_label.clone()) { + errors.push(format!( + "Duplicate unit key in Bazel build graph:\n\ + Key : {:?}\n\ + Labels: {} and {}", + key, prev, unit_label + )); + } + } + + for component_label in &entry.components { + let component_key = match label_short_name(component_label) { + Ok(name) => name.to_lowercase(), + Err(msg) => { + errors.push(msg); + continue; + } + }; + let key = (component_key, Some(comp_key.clone())); + if let Some(prev) = comp_set.insert(key.clone(), component_label.clone()) { + errors.push(format!( + "Duplicate component key in Bazel build graph:\n\ + Key : {:?}\n\ + Labels: {} and {}", + key, prev, component_label + )); + } + } + } + + BazelArchitecture { + seooc_set, + comp_set, + unit_set, + } + } +} + +/// JSON payload for a single architecture entry, including nested components +/// and implementation units. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BazelInputEntry { + #[serde(default)] + pub units: Vec, + #[serde(default)] + pub components: Vec, +} + +/// Indexed entity key-maps derived from the Bazel build graph. +/// +/// Map values are the original Bazel label strings. +/// Built via [`BazelInput::to_bazel_architecture`]. +#[derive(Clone)] +pub struct BazelArchitecture { + /// Top-level dependable elements (`<>`), keyed with `parent = None`. + pub seooc_set: BTreeMap, + /// Nested components (`<>`), keyed with `parent = Some(..)`. + pub comp_set: BTreeMap, + /// Nested units (`<>`), keyed with the enclosing component alias. + pub unit_set: BTreeMap, +} diff --git a/validation/core/src/models/class_diagram_models.rs b/validation/core/src/models/class_diagram_models.rs new file mode 100644 index 00000000..f5279da8 --- /dev/null +++ b/validation/core/src/models/class_diagram_models.rs @@ -0,0 +1,86 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Models for class-diagram FlatBuffer inputs used by design verification. + +use std::collections::BTreeSet; + +use super::Errors; + +/// A single class-diagram entity such as a class, struct, enum, or interface. +#[derive(Debug, Clone, PartialEq)] +pub struct ClassDiagramEntityInput { + pub id: String, + pub name: Option, + pub alias: Option, + pub parent_id: Option, + pub entity_type: String, + pub stereotypes: Vec, + pub template_params: Vec, + pub source_file: Option, + pub source_line: u32, +} + +/// A relationship edge between two class-diagram entities. +#[derive(Debug, Clone, PartialEq)] +pub struct ClassDiagramRelationshipInput { + pub source: String, + pub target: String, + pub relation_type: String, + pub label: Option, + pub stereotype: Option, + pub source_multiplicity: Option, + pub target_multiplicity: Option, + pub source_role: Option, + pub target_role: Option, +} + +/// One parsed class diagram, including entities, containers, and +/// relationships. +#[derive(Debug, Clone, PartialEq)] +pub struct ClassDiagramInput { + pub name: String, + pub entities: Vec, + pub relationships: Vec, + pub source_files: Vec, + pub version: Option, +} + +/// Collection of class diagrams loaded from one or more FlatBuffer files. +#[derive(Debug, Clone, PartialEq)] +pub struct ClassDiagramInputs { + pub diagrams: Vec, +} + +impl ClassDiagramInputs { + /// Build a [`ClassDiagramIndex`] from class diagram inputs. + pub fn to_class_diagram_index(&self, _errors: &mut Errors) -> ClassDiagramIndex { + let observed_namespace_names = self + .diagrams + .iter() + .flat_map(|diagram| diagram.entities.iter()) + .filter_map(|entity| entity.parent_id.clone()) + .filter(|parent_id| !parent_id.is_empty()) + .collect(); + + ClassDiagramIndex { + observed_namespace_names, + } + } +} + +/// Indexed names derived from class-diagram entities. +#[derive(Clone)] +pub struct ClassDiagramIndex { + pub observed_namespace_names: BTreeSet, +} diff --git a/validation/core/src/models/component_diagram_models.rs b/validation/core/src/models/component_diagram_models.rs new file mode 100644 index 00000000..7a1b4062 --- /dev/null +++ b/validation/core/src/models/component_diagram_models.rs @@ -0,0 +1,168 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use std::collections::BTreeMap; + +use super::{EntityKey, Errors}; + +/// A single component-level entity parsed from a PlantUML `.fbs.bin` file. +#[derive(Debug, Clone, PartialEq)] +pub struct ComponentDiagramInput { + pub id: String, + pub alias: Option, + pub parent_id: Option, + pub stereotype: Option, +} + +impl ComponentDiagramInput { + /// Canonical match key: alias (lowercased) when present, otherwise raw id. + pub fn match_key(&self) -> String { + self.alias.as_deref().unwrap_or(&self.id).to_lowercase() + } + + pub fn is_component(&self) -> bool { + self.stereotype.as_deref() == Some("component") + } + + pub fn is_unit(&self) -> bool { + self.stereotype.as_deref() == Some("unit") + } + + /// Returns `true` for `<>` package entities (dependable elements). + pub fn is_seooc_package(&self) -> bool { + self.stereotype.as_deref() == Some("SEooC") + } +} + +/// Collection of raw PlantUML entities read from FlatBuffers files. +/// +/// Symmetric peer of [`BazelInput`]: produced by [`ComponentDiagramReader`] and +/// consumed by [`to_diagram_architecture`](ComponentDiagramInputs::to_diagram_architecture). +pub struct ComponentDiagramInputs { + pub entities: Vec, +} + +impl ComponentDiagramInputs { + /// Build a [`ComponentDiagramArchitecture`] index from these diagram inputs. + pub fn to_diagram_architecture(&self, errors: &mut Errors) -> ComponentDiagramArchitecture { + ComponentDiagramArchitecture::from_entities(&self.entities, errors) + } +} + +/// Indexed entity key-maps derived from the parsed PlantUML diagram entities. +/// +/// Built via [`ComponentDiagramInputs::to_diagram_architecture`]. +#[derive(Clone)] +pub struct ComponentDiagramArchitecture { + /// `<>` package entities, keyed with `parent = None`. + pub seooc_set: BTreeMap, + /// `<>` entities, keyed with `parent = Some(..)`. + pub comp_set: BTreeMap, + pub unit_set: BTreeMap, + /// Full raw entity list, kept for debug output. + pub entities: Vec, + pub filtered_seooc_count: usize, + pub filtered_component_count: usize, + pub filtered_unit_count: usize, +} + +impl ComponentDiagramArchitecture { + /// Index `entities` by stereotype and parent alias. + /// + /// `<>` go into `seooc_set`; + /// `<>` go into `comp_set`; + /// `<>` go into `unit_set`. + /// Duplicates (same [`EntityKey`]) are reported via `errors`. + fn from_entities(entities: &[ComponentDiagramInput], errors: &mut Errors) -> Self { + // Index by raw id for parent resolution; PlantUML nesting uses id, + // not alias. + let mut id_index: BTreeMap = BTreeMap::new(); + for entity in entities { + let key = entity.id.to_lowercase(); + if let Some(prev) = id_index.insert(key.clone(), entity) { + errors.push(format!( + "Duplicate entity ID in PlantUML diagram (case-insensitive):\n\ + ID : {key:?}\n\ + IDs: {} and {}", + prev.id, entity.id + )); + } + } + + let seoocs: Vec<&ComponentDiagramInput> = entities + .iter() + .filter(|entity| entity.is_seooc_package()) + .collect(); + let components: Vec<&ComponentDiagramInput> = entities + .iter() + .filter(|entity| entity.is_component()) + .collect(); + let units: Vec<&ComponentDiagramInput> = + entities.iter().filter(|entity| entity.is_unit()).collect(); + + let filtered_seooc_count = seoocs.len(); + let filtered_component_count = components.len(); + let filtered_unit_count = units.len(); + + let seooc_set = Self::build_set(&seoocs, &id_index, errors); + let comp_set = Self::build_set(&components, &id_index, errors); + let unit_set = Self::build_set(&units, &id_index, errors); + + Self { + seooc_set, + comp_set, + unit_set, + entities: entities.to_vec(), + filtered_seooc_count, + filtered_component_count, + filtered_unit_count, + } + } + + fn build_set( + items: &[&ComponentDiagramInput], + id_index: &BTreeMap, + errors: &mut Errors, + ) -> BTreeMap { + let mut set = BTreeMap::new(); + for entity in items { + let alias = entity.match_key(); + let parent_alias = match &entity.parent_id { + Some(parent_id) => match id_index.get(&parent_id.to_lowercase()) { + Some(parent) => Some(parent.match_key()), + None => { + errors.push(format!( + "Unresolved parent_id in PlantUML diagram:\n\ + Entity ID : {}\n\ + Parent ID : {}\n\ + Action : Fix the parent reference or add the missing parent entity", + entity.id, parent_id + )); + None + } + }, + None => None, + }; + let key = (alias, parent_alias); + if let Some(prev) = set.insert(key.clone(), (*entity).clone()) { + errors.push(format!( + "Duplicate entity in PlantUML diagram:\n\ + Key: {:?}\n\ + IDs: {} and {}", + key, prev.id, entity.id + )); + } + } + set + } +} diff --git a/validation/core/src/models/error_models.rs b/validation/core/src/models/error_models.rs new file mode 100644 index 00000000..ebff271f --- /dev/null +++ b/validation/core/src/models/error_models.rs @@ -0,0 +1,29 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +/// Accumulates validation messages together with optional debug output. +#[derive(Debug, Default)] +pub struct Errors { + pub messages: Vec, + pub debug_output: String, +} + +impl Errors { + pub fn push(&mut self, message: String) { + self.messages.push(message); + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } +} diff --git a/validation/core/src/models/mod.rs b/validation/core/src/models/mod.rs new file mode 100644 index 00000000..303940ab --- /dev/null +++ b/validation/core/src/models/mod.rs @@ -0,0 +1,32 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Shared data models for validation inputs, indexed architectures, and error +//! accumulation. + +mod bazel_models; +mod class_diagram_models; +mod component_diagram_models; +mod error_models; +mod shared; + +pub use bazel_models::{BazelArchitecture, BazelInput, BazelInputEntry}; +pub use class_diagram_models::{ + ClassDiagramEntityInput, ClassDiagramIndex, ClassDiagramInput, ClassDiagramInputs, + ClassDiagramRelationshipInput, +}; +pub use component_diagram_models::{ + ComponentDiagramArchitecture, ComponentDiagramInput, ComponentDiagramInputs, +}; +pub use error_models::Errors; +pub use shared::EntityKey; diff --git a/validation/core/src/models/shared.rs b/validation/core/src/models/shared.rs new file mode 100644 index 00000000..63704261 --- /dev/null +++ b/validation/core/src/models/shared.rs @@ -0,0 +1,30 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Shared helper types used across the split validation models. + +/// Composite key: `(canonical_alias, parent_alias)`. `parent_alias` is `None` +/// for top-level entities. Using the parent as part of the key means two +/// identically-named entities under different parents are treated as distinct. +pub type EntityKey = (String, Option); + +/// Extract the target name from a Bazel label like `@//path/to/package:target` +/// -> `"target"`. Returns the full label unchanged if it contains no colon. +/// Returns `Err` if the extracted name is empty. +pub(super) fn label_short_name(label: &str) -> Result<&str, String> { + let name = label.rsplit_once(':').map(|(_, n)| n).unwrap_or(label); + if name.is_empty() { + return Err(format!("Empty target name extracted from label: {label:?}")); + } + Ok(name) +} diff --git a/validation/archver/src/bazel_reader.rs b/validation/core/src/readers/bazel_reader.rs similarity index 82% rename from validation/archver/src/bazel_reader.rs rename to validation/core/src/readers/bazel_reader.rs index 89f0676d..b62bae73 100644 --- a/validation/archver/src/bazel_reader.rs +++ b/validation/core/src/readers/bazel_reader.rs @@ -14,6 +14,7 @@ use std::fs; use crate::models::BazelInput; +use crate::readers::Reader; /// Reads the `architecture.json` file produced by the `dependable_element` /// Bazel rule and deserializes it into a [`BazelInput`] model. @@ -28,3 +29,13 @@ impl BazelReader { .map_err(|e| format!("Failed to parse architecture JSON: {e}")) } } + +impl Reader for BazelReader { + type Input = str; + type Raw = BazelInput; + type Error = String; + + fn read(input: &Self::Input) -> Result { + BazelReader::read(input) + } +} diff --git a/validation/core/src/readers/class_diagram_reader.rs b/validation/core/src/readers/class_diagram_reader.rs new file mode 100644 index 00000000..b9a7004c --- /dev/null +++ b/validation/core/src/readers/class_diagram_reader.rs @@ -0,0 +1,107 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Reader for class-diagram FlatBuffer exports used by design verification. + +use std::fs; + +use class_fbs::class_metamodel as fb_class; + +use crate::models::{ + ClassDiagramEntityInput, ClassDiagramInput, ClassDiagramInputs, ClassDiagramRelationshipInput, +}; +use crate::readers::Reader; + +pub struct ClassDiagramReader; + +impl ClassDiagramReader { + /// Read all class-diagram files and convert them into validation-friendly + /// Rust models. + pub fn read(paths: &[String]) -> Result { + let mut diagrams = Vec::new(); + + for path in paths { + let data = fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?; + + let diagram = flatbuffers::root::(&data) + .map_err(|e| format!("Failed to parse class FlatBuffer {path}: {e}"))?; + + let mut entities = Vec::new(); + if let Some(raw_entities) = diagram.entities() { + for entity in raw_entities.iter() { + // Rehydrate repeated FlatBuffer string vectors into owned + // Rust values so validators can work without borrow/lifetime + // coupling to the underlying buffer. + let template_params = entity + .template_parameters() + .map(|values| values.iter().map(|p| p.to_string()).collect::>()) + .unwrap_or_default(); + + entities.push(ClassDiagramEntityInput { + id: entity.id().to_string(), + name: Some(entity.name().to_string()), + alias: None, + parent_id: entity.enclosing_namespace_id().map(|s| s.to_string()), + entity_type: format!("{:?}", entity.entity_type()), + stereotypes: Vec::new(), + template_params, + source_file: entity.source_file().map(|s| s.to_string()), + source_line: entity.source_line(), + }); + } + } + + let mut relationships = Vec::new(); + if let Some(raw_rels) = diagram.relationships() { + for rel in raw_rels.iter() { + relationships.push(ClassDiagramRelationshipInput { + source: rel.source().to_string(), + target: rel.target().to_string(), + relation_type: format!("{:?}", rel.relation_type()), + label: None, + stereotype: None, + source_multiplicity: rel.source_multiplicity().map(|s| s.to_string()), + target_multiplicity: rel.target_multiplicity().map(|s| s.to_string()), + source_role: None, + target_role: None, + }); + } + } + + let source_files = diagram + .source_files() + .map(|values| values.iter().map(|f| f.to_string()).collect::>()) + .unwrap_or_default(); + + diagrams.push(ClassDiagramInput { + name: diagram.name().to_string(), + entities, + relationships, + source_files, + version: diagram.version().map(|s| s.to_string()), + }); + } + + Ok(ClassDiagramInputs { diagrams }) + } +} + +impl Reader for ClassDiagramReader { + type Input = [String]; + type Raw = ClassDiagramInputs; + type Error = String; + + fn read(input: &Self::Input) -> Result { + ClassDiagramReader::read(input) + } +} diff --git a/validation/archver/src/diagram_reader.rs b/validation/core/src/readers/component_diagram_reader.rs similarity index 72% rename from validation/archver/src/diagram_reader.rs rename to validation/core/src/readers/component_diagram_reader.rs index 51ccceaf..e4530efa 100644 --- a/validation/archver/src/diagram_reader.rs +++ b/validation/core/src/readers/component_diagram_reader.rs @@ -11,16 +11,22 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* -use crate::models::{DiagramInput, DiagramInputs}; -use component_fbs::component as fb_component; +//! Reader for component-level PlantUML FlatBuffer exports used by architecture +//! validation. + use std::fs; -pub struct DiagramReader; +use component_fbs::component as fb_component; + +use crate::models::{ComponentDiagramInput, ComponentDiagramInputs}; +use crate::readers::Reader; + +pub struct ComponentDiagramReader; -impl DiagramReader { +impl ComponentDiagramReader { /// Read all `Component` and `Package` entities from the given FlatBuffers /// binary files. - pub fn read(paths: &[String]) -> Result { + pub fn read(paths: &[String]) -> Result { let mut out = Vec::new(); for path in paths { @@ -35,15 +41,16 @@ impl DiagramReader { match comp.comp_type() { fb_component::ComponentType::Component | fb_component::ComponentType::Package => { - out.push(DiagramInput { + out.push(ComponentDiagramInput { id: comp.id().unwrap_or_default().to_string(), alias: comp.alias().map(|s| s.to_string()), parent_id: comp.parent_id().map(|s| s.to_string()), stereotype: comp.stereotype().map(|s| s.to_string()), }); } - // Other diagram entity types (Artifact, Database, etc.) - // are not relevant for architecture verification. + // Other diagram entity types (Artifact, Database, + // etc.) are not relevant for architecture + // verification. _ => {} } } else { @@ -56,6 +63,16 @@ impl DiagramReader { } } - Ok(DiagramInputs { entities: out }) + Ok(ComponentDiagramInputs { entities: out }) + } +} + +impl Reader for ComponentDiagramReader { + type Input = [String]; + type Raw = ComponentDiagramInputs; + type Error = String; + + fn read(input: &Self::Input) -> Result { + ComponentDiagramReader::read(input) } } diff --git a/validation/core/src/readers/mod.rs b/validation/core/src/readers/mod.rs new file mode 100644 index 00000000..3f7b4a98 --- /dev/null +++ b/validation/core/src/readers/mod.rs @@ -0,0 +1,50 @@ +// ******************************************************************************* +// 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 readers for Bazel JSON and PlantUML-derived FlatBuffer artifacts. + +mod bazel_reader; +mod class_diagram_reader; +mod component_diagram_reader; + +pub trait InputPresence { + fn is_present(&self) -> bool; +} + +impl InputPresence for str { + fn is_present(&self) -> bool { + !self.is_empty() + } +} + +impl InputPresence for [T] { + fn is_present(&self) -> bool { + !self.is_empty() + } +} + +/// File-reading contract for raw input artifacts. +pub trait Reader { + type Input: ?Sized + InputPresence; + type Raw; + type Error: std::fmt::Display; + + fn is_present(input: &Self::Input) -> bool { + input.is_present() + } + fn read(input: &Self::Input) -> Result; +} + +pub use bazel_reader::BazelReader; +pub use class_diagram_reader::ClassDiagramReader; +pub use component_diagram_reader::ComponentDiagramReader; diff --git a/validation/archver/src/validation.rs b/validation/core/src/validators/bazel_component_validator.rs similarity index 80% rename from validation/archver/src/validation.rs rename to validation/core/src/validators/bazel_component_validator.rs index 4b90662a..2bb93c18 100644 --- a/validation/archver/src/validation.rs +++ b/validation/core/src/validators/bazel_component_validator.rs @@ -11,29 +11,39 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* -//! Validation: compare the indexed Bazel build graph against the indexed PlantUML diagram. +//! Validation: compare the indexed Bazel build graph against the indexed +//! PlantUML component diagram. //! -//! [`Validator`] performs a two-way set-difference between a [`BazelArchitecture`] -//! and a [`DiagramArchitecture`] +//! [`BazelComponentValidator`] performs a two-way set-difference between a +//! [`BazelArchitecture`] and a [`ComponentDiagramArchitecture`]. -use crate::models::{BazelArchitecture, BazelInput, DiagramArchitecture, DiagramInputs, Errors}; +use crate::models::{BazelArchitecture, ComponentDiagramArchitecture, Errors}; -// --------------------------------------------------------------------------- -// Validator -// --------------------------------------------------------------------------- +/// Run bazel-vs-component architecture validation using indexed inputs. +pub fn validate_bazel_component( + bazel: &BazelArchitecture, + diagram: &ComponentDiagramArchitecture, + errors: Errors, +) -> Errors { + BazelComponentValidator::new(bazel, diagram, errors).run() +} -/// Compares a [`BazelArchitecture`] and a [`DiagramArchitecture`], accumulating mismatches into -/// [`Errors`]. -pub struct Validator { - bazel: BazelArchitecture, - diagram: DiagramArchitecture, +/// Compares a [`BazelArchitecture`] and a [`ComponentDiagramArchitecture`], +/// accumulating mismatches into [`Errors`]. +pub struct BazelComponentValidator<'a> { + bazel: &'a BazelArchitecture, + diagram: &'a ComponentDiagramArchitecture, errors: Errors, } -impl Validator { - /// Create a new `Validator` from pre-built sets and already-accumulated - /// errors (which may include duplicates detected during set construction). - pub fn new(bazel: BazelArchitecture, diagram: DiagramArchitecture, errors: Errors) -> Self { +impl<'a> BazelComponentValidator<'a> { + /// Create a new [`BazelComponentValidator`] from pre-built sets and already + /// accumulated indexing errors. + pub fn new( + bazel: &'a BazelArchitecture, + diagram: &'a ComponentDiagramArchitecture, + errors: Errors, + ) -> Self { Self { bazel, diagram, @@ -41,7 +51,8 @@ impl Validator { } } - /// Run the two-way set-difference comparison and return all accumulated errors. + /// Run the two-way set-difference comparison and return all accumulated + /// errors. /// /// The debug log is always built and stored in `errors.debug_output`. pub fn run(mut self) -> Errors { @@ -59,10 +70,10 @@ impl Validator { "DEBUG: Found {} total diagram entities\n", self.diagram.entities.len() )); - for e in &self.diagram.entities { + for entity in &self.diagram.entities { log.push_str(&format!( " Entity: id={:?}, alias={:?}, stereotype={:?}\n", - e.id, e.alias, e.stereotype + entity.id, entity.alias, entity.stereotype )); } log.push_str(&format!( @@ -99,7 +110,7 @@ impl Validator { } fn check_seooc(&mut self) { - // In Bazel but not in PlantUML → MISSING + // In Bazel but not in PlantUML -> MISSING. for (key, label) in &self.bazel.seooc_set { if !self.diagram.seooc_set.contains_key(key) { let (name, _) = key; @@ -112,7 +123,8 @@ impl Validator { )); } } - // In PlantUML but not in Bazel → EXTRA + + // In PlantUML but not in Bazel -> EXTRA. for (key, _) in &self.diagram.seooc_set { if !self.bazel.seooc_set.contains_key(key) { let (name, _) = key; @@ -123,13 +135,13 @@ impl Validator { } fn check_components(&mut self) { - // In Bazel but not in PlantUML → MISSING + // In Bazel but not in PlantUML -> MISSING. for (key, label) in &self.bazel.comp_set { if !self.diagram.comp_set.contains_key(key) { let (name, parent) = key; let parent_str = parent .as_ref() - .map_or("(top-level)".to_string(), |s| s.clone()); + .map_or("(top-level)".to_string(), |value| value.clone()); self.errors.push(Self::format_missing( "component", "component", @@ -139,13 +151,14 @@ impl Validator { )); } } - // In PlantUML but not in Bazel → EXTRA + + // In PlantUML but not in Bazel -> EXTRA. for (key, _) in &self.diagram.comp_set { if !self.bazel.comp_set.contains_key(key) { let (name, parent) = key; let parent_str = parent .as_ref() - .map_or("(top-level)".to_string(), |s| s.clone()); + .map_or("(top-level)".to_string(), |value| value.clone()); self.errors .push(Self::format_extra("component", name, &parent_str)); } @@ -153,13 +166,13 @@ impl Validator { } fn check_units(&mut self) { - // In Bazel but not in PlantUML → MISSING + // In Bazel but not in PlantUML -> MISSING. for (key, label) in &self.bazel.unit_set { if !self.diagram.unit_set.contains_key(key) { let (name, parent) = key; let parent_str = parent .as_ref() - .map_or("(no parent?)".to_string(), |s| s.clone()); + .map_or("(no parent?)".to_string(), |value| value.clone()); self.errors.push(Self::format_missing( "unit", "unit", @@ -169,13 +182,14 @@ impl Validator { )); } } - // In PlantUML but not in Bazel → EXTRA + + // In PlantUML but not in Bazel -> EXTRA. for (key, _) in &self.diagram.unit_set { if !self.bazel.unit_set.contains_key(key) { let (name, parent) = key; let parent_str = parent .as_ref() - .map_or("(no parent?)".to_string(), |s| s.clone()); + .map_or("(no parent?)".to_string(), |value| value.clone()); self.errors .push(Self::format_extra("unit", name, &parent_str)); } @@ -208,29 +222,12 @@ impl Validator { } } -// --------------------------------------------------------------------------- -// Public entry point -// --------------------------------------------------------------------------- - -/// Index both sources and run the comparison. -/// -/// Errors from index construction (duplicates) and from comparison (mismatches) -/// are all collected into a single [`Errors`] value. -pub fn validate(arch: &BazelInput, diagram: &DiagramInputs) -> Errors { - let mut errors = Errors::default(); - let bazel = arch.to_bazel_architecture(&mut errors); - let diag = diagram.to_diagram_architecture(&mut errors); - Validator::new(bazel, diag, errors).run() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; - use crate::models::{BazelInput, BazelInputEntry, DiagramInput, DiagramInputs}; + use crate::models::{ + BazelInput, BazelInputEntry, ComponentDiagramInput, ComponentDiagramInputs, + }; use std::collections::BTreeMap; fn make_arch(entries: Vec<(&str, Vec<&str>, Vec<&str>)>) -> BazelInput { @@ -252,8 +249,8 @@ mod tests { alias: Option<&str>, parent_id: Option<&str>, stereotype: Option<&str>, - ) -> DiagramInput { - DiagramInput { + ) -> ComponentDiagramInput { + ComponentDiagramInput { id: id.to_string(), alias: alias.map(|s| s.to_string()), parent_id: parent_id.map(|s| s.to_string()), @@ -261,8 +258,15 @@ mod tests { } } - fn diagram(entities: Vec) -> DiagramInputs { - DiagramInputs { entities } + fn diagram(entities: Vec) -> ComponentDiagramInputs { + ComponentDiagramInputs { entities } + } + + fn run_arch_validation(arch: &BazelInput, diagram: &ComponentDiagramInputs) -> Errors { + let mut errors = Errors::default(); + let bazel = arch.to_bazel_architecture(&mut errors); + let diag = diagram.to_diagram_architecture(&mut errors); + validate_bazel_component(&bazel, &diag, errors) } #[test] @@ -276,7 +280,7 @@ mod tests { entity("CompA", Some("comp_a"), Some("MyDE"), Some("component")), entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.messages); } @@ -323,7 +327,7 @@ mod tests { Some("unit"), ), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.messages); } @@ -343,7 +347,7 @@ mod tests { entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")), entity("CompA.Unit2", Some("unit_2"), Some("CompA"), Some("unit")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.messages); } @@ -362,7 +366,7 @@ mod tests { entity("CompA", Some("comp_a"), Some("MyDE"), Some("component")), entity("CompA.Unit1", Some("unit_1"), Some("CompA"), Some("unit")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(!errs.is_empty()); assert!(errs.messages.iter().any(|m| m.contains("Missing unit"))); } @@ -374,7 +378,7 @@ mod tests { ("@//pkg2:comp_a", vec![], vec![]), ]); let diagram = diagram(vec![entity("CompA", Some("comp_a"), None, Some("SEooC"))]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(!errs.is_empty()); assert!( errs.messages.iter().any(|m| m.contains("Duplicate")), @@ -385,24 +389,19 @@ mod tests { #[test] fn test_same_short_name_different_packages_one_child() { - // pkg1:comp_a is nested under de; pkg2:comp_a is a separate top-level element. - // Full-label matching in child_labels ensures pkg2:comp_a is never silently - // suppressed from the top-level (None-parent) slot. let arch = make_arch(vec![ ("de", vec![], vec!["@//pkg1:comp_a"]), ("@//pkg1:comp_a", vec![], vec![]), ("@//pkg2:comp_a", vec![], vec![]), ]); - // An empty diagram is sufficient to exercise the Bazel-side set building. - let errs = validate(&arch, &diagram(vec![])); - // No panic. Both labels are considered; a duplicate or mismatch error is expected. + let errs = run_arch_validation(&arch, &diagram(vec![])); let _ = errs; } #[test] fn test_missing_seooc_error_mentions_seooc_stereotype() { let arch = make_arch(vec![("my_de", vec![], vec![])]); - let errs = validate(&arch, &diagram(vec![])); + let errs = run_arch_validation(&arch, &diagram(vec![])); assert!(!errs.is_empty()); let msg = &errs.messages[0]; assert!( @@ -423,7 +422,7 @@ mod tests { Some("component"), ), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(!errs.is_empty()); assert!( errs.messages.iter().any(|m| m.contains("Extra component")), @@ -443,7 +442,7 @@ mod tests { entity("CompA", Some("comp_a"), Some("MyDE"), Some("component")), entity("ExtraUnit", Some("extra_unit"), Some("CompA"), Some("unit")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(!errs.is_empty()); assert!( errs.messages.iter().any(|m| m.contains("Extra unit")), @@ -454,10 +453,9 @@ mod tests { #[test] fn test_component_with_wrong_stereotype_rejected() { - // A <> where <> is expected must not pass. let arch = make_arch(vec![("my_de", vec![], vec![])]); let diagram = diagram(vec![entity("MyDE", Some("my_de"), None, Some("component"))]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!( !errs.is_empty(), "<> should not satisfy <> requirement" @@ -471,7 +469,6 @@ mod tests { #[test] fn test_seooc_where_component_expected_rejected() { - // A <> where <> is expected must not pass. let arch = make_arch(vec![ ("my_de", vec![], vec!["@//pkg:comp_a"]), ("@//pkg:comp_a", vec![], vec![]), @@ -480,7 +477,7 @@ mod tests { entity("MyDE", Some("my_de"), None, Some("SEooC")), entity("CompA", Some("comp_a"), Some("MyDE"), Some("SEooC")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!( !errs.is_empty(), "<> should not satisfy <> requirement" @@ -500,7 +497,7 @@ mod tests { ("my_de", vec![], vec!["@//pkg:comp_a"]), ("@//pkg:comp_a", vec!["@//pkg/u1:unit_1"], vec![]), ]); - let errs = validate(&arch, &diagram(vec![])); + let errs = run_arch_validation(&arch, &diagram(vec![])); assert_eq!( errs.messages.len(), 3, @@ -520,7 +517,7 @@ mod tests { entity("CompA", Some("COMP_A"), Some("MyDE"), Some("component")), entity("Unit1", Some("UNIT_1"), Some("CompA"), Some("unit")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.messages); } @@ -530,24 +527,22 @@ mod tests { ("my_de", vec![], vec!["@//pkg:comp_a"]), ("@//pkg:comp_a", vec![], vec![]), ]); - // No alias set — match_key() should fall back to id (lowercased). let diagram = diagram(vec![ entity("my_de", None, None, Some("SEooC")), entity("comp_a", None, Some("my_de"), Some("component")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.messages); } #[test] fn test_duplicate_diagram_id_detected() { let arch = make_arch(vec![("my_de", vec![], vec![])]); - // Two entities with the same id (case-insensitive). let diagram = diagram(vec![ entity("MyDE", Some("my_de"), None, Some("SEooC")), entity("myDE", Some("other_alias"), None, Some("component")), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!( errs.messages .iter() @@ -560,7 +555,6 @@ mod tests { #[test] fn test_orphaned_parent_id_detected() { let arch = make_arch(vec![("my_de", vec![], vec![])]); - // Entity references a parent_id that doesn't exist in the diagram. let diagram = diagram(vec![ entity("MyDE", Some("my_de"), None, Some("SEooC")), entity( @@ -570,7 +564,7 @@ mod tests { Some("component"), ), ]); - let errs = validate(&arch, &diagram); + let errs = run_arch_validation(&arch, &diagram); assert!( errs.messages .iter() diff --git a/validation/core/src/validators/component_class_validator.rs b/validation/core/src/validators/component_class_validator.rs new file mode 100644 index 00000000..6e35df50 --- /dev/null +++ b/validation/core/src/validators/component_class_validator.rs @@ -0,0 +1,319 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Validation: compare unit names from component diagrams with namespace names +//! found in class diagrams. + +use std::collections::BTreeSet; + +use crate::models::{ClassDiagramIndex, ComponentDiagramArchitecture, Errors}; + +/// Run component-vs-class naming validation using prepared architecture/index +/// inputs. +pub fn validate_component_class( + component_diagram: &ComponentDiagramArchitecture, + class_diagram: &ClassDiagramIndex, + errors: Errors, +) -> Errors { + ComponentClassValidator::new( + build_expected_unit_names(component_diagram), + &class_diagram.observed_namespace_names, + errors, + ) + .run() +} + +/// Verifies naming consistency between component-diagram units and +/// class-diagram namespaces. +pub struct ComponentClassValidator<'a> { + expected_unit_names: BTreeSet, + observed_namespace_names: &'a BTreeSet, + errors: Errors, +} + +impl<'a> ComponentClassValidator<'a> { + fn new( + expected_unit_names: BTreeSet, + observed_namespace_names: &'a BTreeSet, + errors: Errors, + ) -> Self { + Self { + expected_unit_names, + observed_namespace_names, + errors, + } + } + /// Run the consistency check and return accumulated errors. + pub fn run(mut self) -> Errors { + self.errors.debug_output = self.build_debug_log(); + self.check_unit_naming_consistency(); + self.errors + } + + fn build_debug_log(&self) -> String { + let mut log = String::new(); + + log.push_str("DEBUG: Expected unit aliases from component diagrams:\n"); + for name in &self.expected_unit_names { + log.push_str(&format!(" {name}\n")); + } + + log.push_str("DEBUG: Observed namespace IDs from class diagrams:\n"); + for name in self.observed_namespace_names { + log.push_str(&format!(" {name}\n")); + } + + log + } + + fn check_unit_naming_consistency(&mut self) { + // Present in component diagrams but missing as namespaces in class + // diagrams. + for missing_name in self + .expected_unit_names + .difference(&self.observed_namespace_names) + { + self.errors.push(format!( + "Naming consistency violation: missing unit namespace in class diagrams:\n\ + Expected unit name: \"{}\"\n\ + Source : Component diagram unit identifiers\n\ + Action : Add/rename class-diagram namespace to match the unit name", + missing_name + )); + } + + // Present as class-diagram namespaces but not declared as component + // units. + for extra_name in self + .observed_namespace_names + .difference(&self.expected_unit_names) + { + self.errors.push(format!( + "Naming consistency violation: unexpected class-diagram unit namespace:\n\ + Namespace name : \"{}\"\n\ + Source : Unit class diagrams\n\ + Action : Rename namespace to an existing component-diagram unit identifier", + extra_name + )); + } + } +} + +fn build_expected_unit_names(component_diagram: &ComponentDiagramArchitecture) -> BTreeSet { + // Unit aliases define expected logical names directly. Parent hierarchy is + // intentionally ignored. + component_diagram + .entities + .iter() + .filter(|entity| entity.is_unit()) + .filter_map(|entity| entity.alias.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ + ClassDiagramEntityInput, ClassDiagramInput, ClassDiagramInputs, ComponentDiagramInput, + ComponentDiagramInputs, + }; + + fn component_diagrams(units: &[&str]) -> ComponentDiagramInputs { + ComponentDiagramInputs { + entities: units + .iter() + .map(|name| ComponentDiagramInput { + id: (*name).to_string(), + alias: Some((*name).to_string()), + parent_id: None, + stereotype: Some("unit".to_string()), + }) + .collect(), + } + } + + fn component_diagrams_with_hierarchy( + entities: &[(&str, Option<&str>, Option<&str>, &str)], + ) -> ComponentDiagramInputs { + ComponentDiagramInputs { + entities: entities + .iter() + .map(|(id, alias, parent_id, stereotype)| ComponentDiagramInput { + id: (*id).to_string(), + alias: alias.map(str::to_string), + parent_id: parent_id.map(str::to_string), + stereotype: Some((*stereotype).to_string()), + }) + .collect(), + } + } + + fn class_diagrams(namespaces: &[&str]) -> ClassDiagramInputs { + ClassDiagramInputs { + diagrams: vec![ClassDiagramInput { + name: "diagram".to_string(), + entities: namespaces + .iter() + .enumerate() + .map(|(index, parent_id)| ClassDiagramEntityInput { + id: format!("entity_{index}"), + name: Some(format!("entity_{index}")), + alias: None, + parent_id: Some((*parent_id).to_string()), + entity_type: "Class".to_string(), + stereotypes: Vec::new(), + template_params: Vec::new(), + source_file: None, + source_line: 0, + }) + .collect(), + relationships: Vec::new(), + source_files: Vec::new(), + version: None, + }], + } + } + + fn run_component_class_validation( + component_diagrams: &ComponentDiagramInputs, + class_diagrams: &ClassDiagramInputs, + ) -> Errors { + let mut errors = Errors::default(); + let component_arch = component_diagrams.to_diagram_architecture(&mut errors); + let class_index = class_diagrams.to_class_diagram_index(&mut errors); + + validate_component_class(&component_arch, &class_index, errors) + } + + fn class_diagrams_from_entity_parent_ids(parent_ids: &[&str]) -> ClassDiagramInputs { + ClassDiagramInputs { + diagrams: vec![ClassDiagramInput { + name: "diagram".to_string(), + entities: parent_ids + .iter() + .enumerate() + .map(|(index, parent_id)| ClassDiagramEntityInput { + id: format!("entity_{index}"), + name: Some(format!("entity_{index}")), + alias: None, + parent_id: Some((*parent_id).to_string()), + entity_type: "Class".to_string(), + stereotypes: Vec::new(), + template_params: Vec::new(), + source_file: None, + source_line: 0, + }) + .collect(), + relationships: Vec::new(), + source_files: Vec::new(), + version: None, + }], + } + } + + #[test] + fn naming_consistency_passes_for_exact_match() { + let component_diagrams = component_diagrams(&["unit_1", "Unit_2"]); + let class_diagrams = class_diagrams(&["unit_1", "Unit_2"]); + + let errors = run_component_class_validation(&component_diagrams, &class_diagrams); + + assert!(errors.is_empty()); + } + + #[test] + fn naming_consistency_reports_missing_and_extra() { + let component_diagrams = component_diagrams(&["unit_1", "unit_2", "unit_3"]); + let class_diagrams = class_diagrams(&["unit_2", "Unit_3"]); + + let errors = run_component_class_validation(&component_diagrams, &class_diagrams); + + assert!(!errors.is_empty()); + assert_eq!(errors.messages.len(), 3); + + let missing_count = errors + .messages + .iter() + .filter(|message| message.contains("missing unit namespace in class diagrams")) + .count(); + let unexpected_count = errors + .messages + .iter() + .filter(|message| message.contains("unexpected class-diagram unit namespace")) + .count(); + + assert_eq!(missing_count, 2); + assert_eq!(unexpected_count, 1); + } + + #[test] + fn units_without_alias_are_skipped() { + let component_diagrams = ComponentDiagramInputs { + entities: vec![ + ComponentDiagramInput { + id: "unit_with_alias".to_string(), + alias: Some("unit_with_alias".to_string()), + parent_id: None, + stereotype: Some("unit".to_string()), + }, + ComponentDiagramInput { + id: "unit_without_alias".to_string(), + alias: None, + parent_id: None, + stereotype: Some("unit".to_string()), + }, + ], + }; + + let class_diagrams = class_diagrams(&["unit_with_alias"]); + + let errors = run_component_class_validation(&component_diagrams, &class_diagrams); + assert!( + errors.is_empty(), + "Expected pass when unit without alias is ignored, got: {:?}", + errors.messages + ); + } + + #[test] + fn entity_parent_ids_are_used_as_observed_namespaces() { + let component_diagrams = component_diagrams(&["unit_1"]); + let class_diagrams = class_diagrams_from_entity_parent_ids(&["unit_1"]); + + let errors = run_component_class_validation(&component_diagrams, &class_diagrams); + assert!( + errors.is_empty(), + "Expected pass when entity parent IDs match unit aliases, got: {:?}", + errors.messages + ); + } + + #[test] + fn parent_unit_aliases_are_not_prefixed_into_expected_names() { + let component_diagrams = component_diagrams_with_hierarchy(&[ + ("component_1", Some("component_1"), None, "component"), + ("unit_parent", Some("parent"), Some("component_1"), "unit"), + ("unit_child", Some("child"), Some("unit_parent"), "unit"), + ("unit_leaf", Some("leaf"), Some("unit_child"), "unit"), + ]); + let class_diagrams = class_diagrams_from_entity_parent_ids(&["parent", "child", "leaf"]); + + let errors = run_component_class_validation(&component_diagrams, &class_diagrams); + + assert!( + errors.is_empty(), + "Expected pass when only direct unit aliases are compared, got: {:?}", + errors.messages + ); + } +} diff --git a/validation/core/src/validators/component_sequence_validator.rs b/validation/core/src/validators/component_sequence_validator.rs new file mode 100644 index 00000000..7a961c29 --- /dev/null +++ b/validation/core/src/validators/component_sequence_validator.rs @@ -0,0 +1,23 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Sequence verification entrypoint placeholder. + +use crate::models::Errors; + +/// Sequence validation placeholder. +pub fn validate_component_sequence() -> Errors { + let mut errors = Errors::default(); + errors.push("Sequence validation is not implemented yet".to_string()); + errors +} diff --git a/validation/core/src/validators/mod.rs b/validation/core/src/validators/mod.rs new file mode 100644 index 00000000..f4d401fd --- /dev/null +++ b/validation/core/src/validators/mod.rs @@ -0,0 +1,65 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +//! Validator entrypoints for architecture checks. + +pub mod bazel_component_validator; +pub mod component_class_validator; +pub mod component_sequence_validator; + +/// Typed inputs that a validator may require to run. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RequiredInput { + Bazel, + Component, + Class, +} + +/// Validators supported by the current CLI. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum SelectedValidator { + BazelComponent, + ComponentClass, +} + +pub const ALL_VALIDATORS: [SelectedValidator; 2] = [ + SelectedValidator::BazelComponent, + SelectedValidator::ComponentClass, +]; + +/// Validator metadata and execution contract used by orchestrators. +pub trait ValidatorSpec { + fn required_inputs(self) -> &'static [RequiredInput]; + + fn can_run(self, is_available: impl Fn(RequiredInput) -> bool) -> bool + where + Self: Sized, + { + self.required_inputs() + .iter() + .all(|input| is_available(*input)) + } +} + +impl ValidatorSpec for SelectedValidator { + fn required_inputs(self) -> &'static [RequiredInput] { + match self { + SelectedValidator::BazelComponent => &[RequiredInput::Bazel, RequiredInput::Component], + SelectedValidator::ComponentClass => &[RequiredInput::Component, RequiredInput::Class], + } + } +} + +pub use bazel_component_validator::{validate_bazel_component, BazelComponentValidator}; +pub use component_class_validator::{validate_component_class, ComponentClassValidator}; +pub use component_sequence_validator::validate_component_sequence;