diff --git a/validation/core/docs/requirements/tool_requirements.trlc b/validation/core/docs/requirements/tool_requirements.trlc index 87a1daa3..c42a83ff 100644 --- a/validation/core/docs/requirements/tool_requirements.trlc +++ b/validation/core/docs/requirements/tool_requirements.trlc @@ -72,6 +72,16 @@ section "Tool Requirements" { satisfied_by = Verifier } + ToolQualification.ToolRequirement BazelComponentNonEmptyComposition { + description = '''The validator shall report an error when a + Bazel `dependable_element` or `component` target decomposes + into zero nested `component` or `unit` targets, even when the + corresponding PlantUML entity likewise shows no nested + elements.''' + derived_from = [UseCases.Validate_Architecture_Specification_Documents] + satisfied_by = Verifier + } + } section "Component Sequence Validator" { diff --git a/validation/core/docs/specifications/bazel_component.md b/validation/core/docs/specifications/bazel_component.md index 5a02753a..4ac0a381 100644 --- a/validation/core/docs/specifications/bazel_component.md +++ b/validation/core/docs/specifications/bazel_component.md @@ -97,6 +97,25 @@ the dependable element alias as parent. More deeply nested components use their immediate enclosing component alias as parent. *(Requirements: {requirement:downstream-ref}`Tools.BazelComponentNameCaseInsensitive`, {requirement:downstream-ref}`Tools.BazelComponentParentContext`)* +### Non-Empty Composition + +Every Bazel `dependable_element` and `component` target must decompose into +at least one nested `component` or `unit`. A target with an empty +`components` attribute (and, for components, no nested `unit`) is reported +even if the PlantUML diagram agrees and shows the same element as empty -- +the set-difference checks above only compare presence between Bazel and +PlantUML, so a childless entry that matches on both sides would otherwise go +unnoticed. +*(Requirement: {requirement:downstream-ref}`Tools.BazelComponentNonEmptyComposition`)* + +```starlark +# Flagged: component_example has no nested unit() or component(). +component( + name = "component_example", + components = [], +) +``` + ## Failure Cases | Failure case | Validation rule | @@ -107,6 +126,8 @@ immediate enclosing component alias as parent. | Extra component in PlantUML | Component Consistency | | Missing unit in PlantUML | Unit Consistency | | Extra unit in PlantUML | Unit Consistency | +| Empty dependable element in Bazel build graph | Non-Empty Composition | +| Empty component in Bazel build graph | Non-Empty Composition | ## PlantUML Stereotype Reference diff --git a/validation/core/integration_test/bazel_component/BUILD b/validation/core/integration_test/bazel_component/BUILD index 53394300..546a2817 100644 --- a/validation/core/integration_test/bazel_component/BUILD +++ b/validation/core/integration_test/bazel_component/BUILD @@ -16,6 +16,7 @@ load("@rules_rust//rust:defs.bzl", "rust_test") filegroup( name = "bazel_component_test_data", srcs = [ + "//validation/core/integration_test/bazel_component/negative_empty_component:case_data", "//validation/core/integration_test/bazel_component/negative_extra_component:case_data", "//validation/core/integration_test/bazel_component/negative_extra_unit:case_data", "//validation/core/integration_test/bazel_component/negative_missing_component:case_data", diff --git a/validation/core/integration_test/bazel_component/bazel_component_suite.rs b/validation/core/integration_test/bazel_component/bazel_component_suite.rs index e4f4fa5b..dabc45b2 100644 --- a/validation/core/integration_test/bazel_component/bazel_component_suite.rs +++ b/validation/core/integration_test/bazel_component/bazel_component_suite.rs @@ -84,3 +84,8 @@ fn negative_extra_component_suite_case() { fn negative_wrong_stereotype_suite_case() { assert_case("negative_wrong_stereotype"); } + +#[test] +fn negative_empty_component_suite_case() { + assert_case("negative_empty_component"); +} diff --git a/validation/core/integration_test/bazel_component/negative_empty_component/BUILD b/validation/core/integration_test/bazel_component/negative_empty_component/BUILD new file mode 100644 index 00000000..8fe623cf --- /dev/null +++ b/validation/core/integration_test/bazel_component/negative_empty_component/BUILD @@ -0,0 +1,37 @@ +# ******************************************************************************* +# 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("//bazel/rules/rules_score:rules_score.bzl", "architectural_design") +load("//validation/core/integration_test:puml_fixture.bzl", "provider_fbs_fixture_bundle") + +architectural_design( + name = "design", + static = ["component_diagram.puml"], + visibility = ["//visibility:private"], +) + +provider_fbs_fixture_bundle( + name = "fbs", + visibility = ["//visibility:public"], + deps = [":design"], +) + +filegroup( + name = "case_data", + srcs = [ + "architecture.json", + "expected.json", + ":fbs", + ], + visibility = ["//visibility:public"], +) diff --git a/validation/core/integration_test/bazel_component/negative_empty_component/architecture.json b/validation/core/integration_test/bazel_component/negative_empty_component/architecture.json new file mode 100644 index 00000000..1d9b94df --- /dev/null +++ b/validation/core/integration_test/bazel_component/negative_empty_component/architecture.json @@ -0,0 +1,14 @@ +{ + "components": { + "safety_software_seooc_example": { + "units": [], + "components": [ + "@//bazel/rules/rules_score/examples/seooc:component_example" + ] + }, + "@//bazel/rules/rules_score/examples/seooc:component_example": { + "units": [], + "components": [] + } + } +} diff --git a/validation/core/integration_test/bazel_component/negative_empty_component/component_diagram.puml b/validation/core/integration_test/bazel_component/negative_empty_component/component_diagram.puml new file mode 100644 index 00000000..b2a8943f --- /dev/null +++ b/validation/core/integration_test/bazel_component/negative_empty_component/component_diagram.puml @@ -0,0 +1,21 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml component_diagram + +package "Sample Seooc" as safety_software_seooc_example <> { + component "Component Example" as component_example <> { + } +} + +@enduml diff --git a/validation/core/integration_test/bazel_component/negative_empty_component/expected.json b/validation/core/integration_test/bazel_component/negative_empty_component/expected.json new file mode 100644 index 00000000..ce139bf5 --- /dev/null +++ b/validation/core/integration_test/bazel_component/negative_empty_component/expected.json @@ -0,0 +1,7 @@ +{ + "should_pass": false, + "error_contains": [ + "Empty component in Bazel build graph", + "Alias : \"component_example\"" + ] +} diff --git a/validation/core/src/validators/bazel_component_validator.rs b/validation/core/src/validators/bazel_component_validator.rs index 07951b01..600e2176 100644 --- a/validation/core/src/validators/bazel_component_validator.rs +++ b/validation/core/src/validators/bazel_component_validator.rs @@ -17,6 +17,8 @@ //! [`BazelComponentValidator`] performs a two-way set-difference between a //! [`BazelArchitecture`] and a [`ComponentDiagramArchitecture`]. +use std::collections::BTreeSet; + use crate::models::{BazelArchitecture, ComponentDiagramArchitecture}; use crate::{Diagnostics, ValidationResult}; @@ -55,6 +57,7 @@ impl BazelComponentValidator { self.check_seooc(bazel, diagram); self.check_components(bazel, diagram); self.check_units(bazel, diagram); + self.check_non_empty_composition(bazel); self.result } @@ -149,6 +152,52 @@ impl BazelComponentValidator { } } + /// Verify every Bazel `dependable_element`/`component` target decomposes + /// into at least one nested `component` or `unit`. + /// + /// A childless entry (both `units` and `components` empty in the + /// architecture JSON) can silently agree with an equally childless + /// PlantUML entity, so the set-difference checks above never flag it. + /// This scans the Bazel side directly instead, since it reflects what is + /// actually built. + fn check_non_empty_composition(&mut self, bazel: &BazelArchitecture) { + let mut parents_with_children: BTreeSet = BTreeSet::new(); + for (_, parent) in bazel.comp_set.keys() { + if let Some(parent) = parent { + parents_with_children.insert(parent.clone()); + } + } + for (_, parent) in bazel.unit_set.keys() { + if let Some(parent) = parent { + parents_with_children.insert(parent.clone()); + } + } + + for (key, label) in &bazel.seooc_set { + let (name, _) = key; + if !parents_with_children.contains(name) { + self.result + .add_failure(Self::format_empty("dependable element", name, label)); + } + } + for (key, label) in &bazel.comp_set { + let (name, _) = key; + if !parents_with_children.contains(name) { + self.result + .add_failure(Self::format_empty("component", name, label)); + } + } + } + + fn format_empty(display_type: &str, name: &str, label: &str) -> String { + format!( + "Empty {display_type} in Bazel build graph:\n\ + Alias : \"{name}\"\n\ + Bazel label : {label}\n\ + Required : A {display_type} must decompose into at least one nested unit or component; add one or remove the empty {display_type}", + ) + } + fn format_missing( display_type: &str, stereotype: &str, @@ -525,11 +574,73 @@ mod tests { fn test_entity_without_alias_uses_id_as_key() { let arch = make_arch(vec![ ("my_de", vec![], vec!["@//pkg:comp_a"]), - ("@//pkg:comp_a", vec![], vec![]), + ("@//pkg:comp_a", vec!["@//pkg/u1:unit_1"], vec![]), ]); let diagram = diagram(vec![ entity("my_de", None, None, Some("SEooC")), entity("comp_a", None, Some("my_de"), Some("component")), + entity("unit_1", None, Some("comp_a"), Some("unit")), + ]); + let errs = run_arch_validation(&arch, &diagram); + assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.failures); + } + + #[test] + fn test_empty_component_detected() { + let arch = make_arch(vec![ + ("my_de", vec![], vec!["@//pkg:comp_a"]), + ("@//pkg:comp_a", vec![], vec![]), + ]); + let diagram = diagram(vec![ + entity("MyDE", Some("my_de"), None, Some("SEooC")), + entity("CompA", Some("comp_a"), Some("MyDE"), Some("component")), + ]); + let errs = run_arch_validation(&arch, &diagram); + assert!( + errs.failures.iter().any(|m| m.contains("Empty component")), + "Expected empty component error, got: {:?}", + errs.failures + ); + } + + #[test] + fn test_empty_dependable_element_detected() { + let arch = make_arch(vec![("my_de", vec![], vec![])]); + let diagram = diagram(vec![entity("MyDE", Some("my_de"), None, Some("SEooC"))]); + let errs = run_arch_validation(&arch, &diagram); + assert!( + errs.failures + .iter() + .any(|m| m.contains("Empty dependable element")), + "Expected empty dependable element error, got: {:?}", + errs.failures + ); + } + + #[test] + fn test_component_with_nested_component_not_flagged_empty() { + // "bindings" has no direct units but decomposes into a nested + // component ("lola_component") - it must not be reported as empty. + let arch = make_arch(vec![ + ("my_de", vec![], vec!["@//pkg:bindings"]), + ("@//pkg:bindings", vec![], vec!["@//pkg:lola_component"]), + ("@//pkg:lola_component", vec!["@//pkg/u1:unit_1"], vec![]), + ]); + let diagram = diagram(vec![ + entity("MyDE", Some("my_de"), None, Some("SEooC")), + entity( + "Bindings", + Some("bindings"), + Some("MyDE"), + Some("component"), + ), + entity( + "LolaComponent", + Some("lola_component"), + Some("Bindings"), + Some("component"), + ), + entity("Unit1", Some("unit_1"), Some("LolaComponent"), Some("unit")), ]); let errs = run_arch_validation(&arch, &diagram); assert!(errs.is_empty(), "Expected pass, got: {:?}", errs.failures);