1use etdl_parser::ast::{BasicEventType, EtlDocument, EventTree, FaultTree, Gate, GateType, Node};
2use etdl_parser::asyncapi::AsyncApiRegistry;
3use etdl_parser::ecel::Condition;
4use etdl_parser::spanned::SpanKey;
5use std::collections::{BTreeMap, HashMap};
6
7#[derive(Debug, Clone)]
8pub struct Diagnostic {
9 pub code: String,
10 pub severity: DiagnosticSeverity,
11 pub message: String,
12 pub line: Option<u32>,
13 pub column: Option<u32>,
14 pub end_line: Option<u32>,
15 pub end_column: Option<u32>,
16 pub key: Option<SpanKey>,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub enum DiagnosticSeverity {
22 Error,
23 Warning,
24}
25
26impl Diagnostic {
27 pub fn error(code: &str, message: String) -> Self {
28 Diagnostic {
29 code: code.to_string(),
30 severity: DiagnosticSeverity::Error,
31 message,
32 line: None,
33 column: None,
34 end_line: None,
35 end_column: None,
36 key: None,
37 }
38 }
39
40 pub fn warning(code: &str, message: String) -> Self {
41 Diagnostic {
42 code: code.to_string(),
43 severity: DiagnosticSeverity::Warning,
44 message,
45 line: None,
46 column: None,
47 end_line: None,
48 end_column: None,
49 key: None,
50 }
51 }
52
53 pub fn with_position(mut self, line: u32, column: u32) -> Self {
54 self.line = Some(line);
55 self.column = Some(column);
56 self
57 }
58
59 pub fn at(mut self, key: SpanKey) -> Self {
61 self.key = Some(key);
62 self
63 }
64
65 pub fn is_error(&self) -> bool {
66 self.severity == DiagnosticSeverity::Error
67 }
68}
69
70pub fn validate_document(
71 doc: &EtlDocument,
72 registry: &AsyncApiRegistry,
73 diagnostics: &mut Vec<Diagnostic>,
74) {
75 validate_language_version(doc, diagnostics);
76 validate_references(doc, registry, diagnostics);
77 validate_event_trees(doc, registry, diagnostics);
78 validate_fault_trees(doc, diagnostics);
79}
80
81fn validate_language_version(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
85 const SUPPORTED_MAJOR: u64 = 1;
86
87 let parse_major = |v: &str| -> Option<u64> {
88 let trimmed = v.trim();
89 if trimmed.is_empty() {
90 return None;
91 }
92 let major_part = trimmed.split(['.', '+']).next()?;
93 major_part.trim().parse::<u64>().ok()
94 };
95
96 match parse_major(&doc.etdl) {
97 None => {
98 diagnostics.push(Diagnostic::error(
99 "E-100",
100 format!(
101 "document 'etdl' version '{}' is not a valid semantic version",
102 doc.etdl
103 ),
104 ));
105 }
106 Some(major) if major > SUPPORTED_MAJOR => {
107 diagnostics.push(Diagnostic::error(
108 "E-100",
109 format!(
110 "document 'etdl' version '{}' has major version {} which is not supported by this compiler (supports major {})",
111 doc.etdl, major, SUPPORTED_MAJOR
112 ),
113 ));
114 }
115 Some(_) => {
116 }
118 }
119}
120
121fn validate_references(
122 doc: &EtlDocument,
123 registry: &AsyncApiRegistry,
124 diagnostics: &mut Vec<Diagnostic>,
125) {
126 for alias in doc.asyncapi_imports.keys() {
127 if alias
128 .chars()
129 .any(|c| !c.is_ascii_alphanumeric() && c != '_')
130 {
131 diagnostics.push(
132 Diagnostic::error(
133 "E-103",
134 format!("import alias '{}' contains invalid characters", alias),
135 )
136 .at(SpanKey::ImportAlias {
137 alias: alias.clone(),
138 }),
139 );
140 }
141 }
142
143 for (tree_name, tree) in &doc.event_trees {
144 validate_external_ref(
145 &tree.initiating_event.message,
146 doc,
147 registry,
148 diagnostics,
149 "initiatingEvent.message",
150 SpanKey::InitiatingEvent {
151 tree: tree_name.clone(),
152 field: "message",
153 },
154 );
155
156 for (node_id, node) in &tree.nodes {
157 match node {
158 Node::Operation(op) => {
159 if let Some(ref emits_ref) = op.emits {
160 validate_external_ref(
161 emits_ref,
162 doc,
163 registry,
164 diagnostics,
165 &format!("nodes.{}.emits", node_id),
166 SpanKey::NodeField {
167 tree: tree_name.clone(),
168 id: node_id.clone(),
169 field: "emits",
170 },
171 );
172 }
173 }
174 Node::Consequence(cons) => {
175 if let Some(ref channel_ref) = cons.channel {
176 validate_external_ref(
177 channel_ref,
178 doc,
179 registry,
180 diagnostics,
181 &format!("nodes.{}.channel", node_id),
182 SpanKey::NodeField {
183 tree: tree_name.clone(),
184 id: node_id.clone(),
185 field: "channel",
186 },
187 );
188 }
189 if let Some(ref message_ref) = cons.message {
190 validate_external_ref(
191 message_ref,
192 doc,
193 registry,
194 diagnostics,
195 &format!("nodes.{}.message", node_id),
196 SpanKey::NodeField {
197 tree: tree_name.clone(),
198 id: node_id.clone(),
199 field: "message",
200 },
201 );
202 }
203 }
204 _ => {}
205 }
206 }
207 }
208
209 if let Some(ref fault_trees) = doc.fault_trees {
210 for (ft_name, ft) in fault_trees {
211 if let Some(ref msg_ref) = ft.top_event.message {
212 validate_external_ref(
213 msg_ref,
214 doc,
215 registry,
216 diagnostics,
217 "topEvent.message",
218 SpanKey::TopEvent {
219 tree: ft_name.clone(),
220 field: "message",
221 },
222 );
223 }
224 for (be_name, be) in &ft.basic_events {
225 if let Some(ref msg_ref) = be.message {
226 validate_external_ref(
227 msg_ref,
228 doc,
229 registry,
230 diagnostics,
231 "basicEvent.message",
232 SpanKey::BasicEventField {
233 tree: ft_name.clone(),
234 id: be_name.clone(),
235 field: "message",
236 },
237 );
238 }
239 }
240 }
241 }
242}
243
244fn validate_external_ref(
245 ext_ref: &etdl_parser::ast::ExternalRef,
246 doc: &EtlDocument,
247 registry: &AsyncApiRegistry,
248 diagnostics: &mut Vec<Diagnostic>,
249 context: &str,
250 key: SpanKey,
251) {
252 if !doc.asyncapi_imports.contains_key(&ext_ref.alias) {
253 diagnostics.push(
254 Diagnostic::error(
255 "E-103",
256 format!(
257 "{}: import alias '{}' is not a key in asyncapi_imports",
258 context, ext_ref.alias
259 ),
260 )
261 .at(key),
262 );
263 return;
264 }
265
266 if registry.resolve(ext_ref).is_err() {
267 diagnostics.push(
268 Diagnostic::error(
269 "E-104",
270 format!(
271 "{}: JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
272 context, ext_ref.pointer, ext_ref.alias
273 ),
274 )
275 .at(key),
276 );
277 }
278}
279
280fn validate_event_trees(
281 doc: &EtlDocument,
282 _registry: &AsyncApiRegistry,
283 diagnostics: &mut Vec<Diagnostic>,
284) {
285 for (tree_name, tree) in &doc.event_trees {
286 validate_tree_structure(tree_name, tree, diagnostics);
287 }
288}
289
290fn validate_tree_structure(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
291 check_node_references(tree_name, tree, diagnostics);
292 check_dag(tree_name, tree, diagnostics);
293 check_reachability(tree_name, tree, diagnostics);
294 check_terminal_paths(tree_name, tree, diagnostics);
295 check_barrier_rules(tree_name, tree, diagnostics);
296 check_operation_rules(tree_name, tree, diagnostics);
297 check_consequence_rules(tree_name, tree, diagnostics);
298}
299
300fn check_node_references(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
301 if !tree.nodes.contains_key(&tree.initiating_event.next) {
302 diagnostics.push(
303 Diagnostic::error(
304 "V-101",
305 format!(
306 "tree '{}': initiatingEvent.next '{}' does not resolve to a node in this tree",
307 tree_name, tree.initiating_event.next
308 ),
309 )
310 .at(SpanKey::InitiatingEvent {
311 tree: tree_name.to_string(),
312 field: "next",
313 }),
314 );
315 }
316
317 for (node_id, node) in &tree.nodes {
318 let next_targets: Vec<(&str, SpanKey)> = match node {
319 Node::Barrier(barrier) => barrier
320 .branches
321 .iter()
322 .enumerate()
323 .map(|(i, b)| {
324 (
325 b.next.as_str(),
326 SpanKey::BranchField {
327 tree: tree_name.to_string(),
328 id: node_id.clone(),
329 branch: i,
330 field: "next",
331 },
332 )
333 })
334 .collect(),
335 Node::Operation(op) => {
336 let mut targets = vec![(
337 op.next.as_str(),
338 SpanKey::NodeField {
339 tree: tree_name.to_string(),
340 id: node_id.clone(),
341 field: "next",
342 },
343 )];
344 if let Some(ref on_fail) = op.on_failure {
345 targets.push((
346 on_fail.as_str(),
347 SpanKey::NodeField {
348 tree: tree_name.to_string(),
349 id: node_id.clone(),
350 field: "on_failure",
351 },
352 ));
353 }
354 targets
355 }
356 Node::Consequence(_) => continue,
357 };
358
359 for (target, key) in next_targets {
360 if !tree.nodes.contains_key(target) {
361 diagnostics.push(
362 Diagnostic::error(
363 "V-101",
364 format!(
365 "tree '{}': node '{}' references '{}' which does not exist in this tree",
366 tree_name, node_id, target
367 ),
368 )
369 .at(key),
370 );
371 }
372 }
373 }
374}
375
376fn check_dag(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
377 #[derive(Clone, Copy, PartialEq)]
378 enum Color {
379 White,
380 Gray,
381 Black,
382 }
383
384 let mut colors: HashMap<&str, Color> = HashMap::new();
385 for node_id in tree.nodes.keys() {
386 colors.insert(node_id.as_str(), Color::White);
387 }
388
389 fn dfs<'a>(
390 node: &'a str,
391 tree: &'a EventTree,
392 colors: &mut HashMap<&'a str, Color>,
393 diagnostics: &mut Vec<Diagnostic>,
394 tree_name: &str,
395 ) {
396 colors.insert(node, Color::Gray);
397
398 let next_nodes: Vec<&str> = match tree.nodes.get(node) {
399 Some(Node::Barrier(barrier)) => {
400 barrier.branches.iter().map(|b| b.next.as_str()).collect()
401 }
402 Some(Node::Operation(op)) => {
403 let mut targets = vec![op.next.as_str()];
404 if let Some(ref on_fail) = op.on_failure {
405 targets.push(on_fail.as_str());
406 }
407 targets
408 }
409 Some(Node::Consequence(_)) => {
410 colors.insert(node, Color::Black);
413 return;
414 }
415 None => return,
416 };
417
418 for next in next_nodes {
419 match colors.get(next) {
420 Some(Color::Gray) => {
421 diagnostics.push(
422 Diagnostic::error(
423 "V-102",
424 format!(
425 "tree '{}': cycle detected involving node '{}' -> '{}'",
426 tree_name, node, next
427 ),
428 )
429 .at(SpanKey::Node {
430 tree: tree_name.to_string(),
431 id: node.to_string(),
432 }),
433 );
434 }
435 Some(Color::White) => {
436 dfs(next, tree, colors, diagnostics, tree_name);
437 }
438 _ => {}
439 }
440 }
441
442 colors.insert(node, Color::Black);
443 }
444
445 let start_id = tree.initiating_event.next.as_str();
446 if tree.nodes.contains_key(start_id) {
447 dfs(start_id, tree, &mut colors, diagnostics, tree_name);
448 }
449}
450
451fn check_reachability(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
452 let mut reachable: HashMap<&str, bool> = HashMap::new();
453 for node_id in tree.nodes.keys() {
454 reachable.insert(node_id.as_str(), false);
455 }
456
457 let start_id = tree.initiating_event.next.as_str();
458 if tree.nodes.contains_key(start_id) {
459 reachable.insert(start_id, true);
460 propagate_reachability(start_id, tree, &mut reachable);
461 }
462
463 for (node_id, &is_reachable) in &reachable {
464 if !is_reachable {
465 diagnostics.push(
466 Diagnostic::error(
467 "V-103",
468 format!(
469 "tree '{}': node '{}' is unreachable from initiatingEvent",
470 tree_name, node_id
471 ),
472 )
473 .at(SpanKey::Node {
474 tree: tree_name.to_string(),
475 id: node_id.to_string(),
476 }),
477 );
478 }
479 }
480}
481
482fn propagate_reachability<'a>(
483 node_id: &'a str,
484 tree: &'a EventTree,
485 reachable: &mut HashMap<&'a str, bool>,
486) {
487 let next_nodes: Vec<&str> = match tree.nodes.get(node_id) {
488 Some(Node::Barrier(barrier)) => barrier.branches.iter().map(|b| b.next.as_str()).collect(),
489 Some(Node::Operation(op)) => {
490 let mut targets = vec![op.next.as_str()];
491 if let Some(ref on_fail) = op.on_failure {
492 targets.push(on_fail.as_str());
493 }
494 targets
495 }
496 Some(Node::Consequence(_)) => return,
497 None => return,
498 };
499
500 for next in next_nodes {
501 if let Some(was_reachable) = reachable.get_mut(next) {
502 if !*was_reachable {
503 *was_reachable = true;
504 propagate_reachability(next, tree, reachable);
505 }
506 }
507 }
508}
509
510fn check_terminal_paths(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
511 fn check_termination<'a>(
512 node_id: &'a str,
513 tree: &'a EventTree,
514 visited: &mut Vec<&'a str>,
515 tree_name: &str,
516 diagnostics: &mut Vec<Diagnostic>,
517 ) -> bool {
518 if visited.contains(&node_id) {
519 return false;
520 }
521 visited.push(node_id);
522
523 match tree.nodes.get(node_id) {
524 Some(Node::Consequence(_)) => {
525 visited.pop();
526 true
527 }
528 Some(Node::Barrier(barrier)) => {
529 let mut all_terminal = true;
530 for branch in &barrier.branches {
531 if !check_termination(&branch.next, tree, visited, tree_name, diagnostics) {
532 all_terminal = false;
533 }
534 }
535 visited.pop();
536 all_terminal
537 }
538 Some(Node::Operation(op)) => {
539 let mut all_terminal = true;
540 if !check_termination(&op.next, tree, visited, tree_name, diagnostics) {
541 all_terminal = false;
542 }
543 if let Some(ref on_fail) = op.on_failure {
544 if !check_termination(on_fail, tree, visited, tree_name, diagnostics) {
545 all_terminal = false;
546 }
547 }
548 visited.pop();
549 all_terminal
550 }
551 None => {
552 visited.pop();
553 false
554 }
555 }
556 }
557
558 let start_id = tree.initiating_event.next.as_str();
559 if tree.nodes.contains_key(start_id) {
560 let mut visited = Vec::new();
561 let terminates = check_termination(start_id, tree, &mut visited, tree_name, diagnostics);
562 if !terminates {
563 diagnostics.push(
564 Diagnostic::error(
565 "V-104",
566 format!(
567 "tree '{}': a path from initiatingEvent '{}' does not terminate in a consequence node (every path must end in a Consequence)",
568 tree_name, tree.initiating_event.id
569 ),
570 )
571 .at(SpanKey::InitiatingEvent {
572 tree: tree_name.to_string(),
573 field: "next",
574 }),
575 );
576 }
577 }
578}
579
580fn check_barrier_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
581 for (node_id, node) in &tree.nodes {
582 if let Node::Barrier(barrier) = node {
583 if barrier.branches.len() < 2 {
584 diagnostics.push(
585 Diagnostic::error(
586 "V-201",
587 format!(
588 "tree '{}': barrier '{}' has fewer than 2 branches",
589 tree_name, node_id
590 ),
591 )
592 .at(SpanKey::Node {
593 tree: tree_name.to_string(),
594 id: node_id.clone(),
595 }),
596 );
597 }
598
599 let mut default_count = 0;
600 let mut last_is_default = false;
601 for (i, branch) in barrier.branches.iter().enumerate() {
602 if branch.condition == Condition::Default {
603 default_count += 1;
604 if i == barrier.branches.len() - 1 {
605 last_is_default = true;
606 }
607 }
608 }
609 if default_count > 1 {
610 diagnostics.push(
611 Diagnostic::error(
612 "V-202",
613 format!(
614 "tree '{}': barrier '{}' has more than one default branch",
615 tree_name, node_id
616 ),
617 )
618 .at(SpanKey::Node {
619 tree: tree_name.to_string(),
620 id: node_id.clone(),
621 }),
622 );
623 } else if default_count == 1 && !last_is_default {
624 diagnostics.push(
625 Diagnostic::error(
626 "V-202",
627 format!(
628 "tree '{}': barrier '{}' default branch is not the last branch",
629 tree_name, node_id
630 ),
631 )
632 .at(SpanKey::Node {
633 tree: tree_name.to_string(),
634 id: node_id.clone(),
635 }),
636 );
637 }
638
639 for (i, branch) in barrier.branches.iter().enumerate() {
640 if branch.condition == Condition::Default {
641 continue;
642 }
643 let has_prob =
644 branch.effective_probability().is_some() || branch.probability_source.is_some();
645 if !has_prob {
646 diagnostics.push(
647 Diagnostic::error(
648 "V-203",
649 format!(
650 "tree '{}': barrier '{}' branch {} has no probability or probabilitySource",
651 tree_name, node_id, i
652 ),
653 )
654 .at(SpanKey::BranchField {
655 tree: tree_name.to_string(),
656 id: node_id.clone(),
657 branch: i,
658 field: "probability",
659 }),
660 );
661 }
662 }
663 }
664 }
665}
666
667fn check_operation_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
668 for (node_id, node) in &tree.nodes {
669 if let Node::Operation(op) = node {
670 if op.on_failure.is_none() {
671 diagnostics.push(
672 Diagnostic::warning(
673 "W-401",
674 format!(
675 "tree '{}': operation '{}' has no onFailure path",
676 tree_name, node_id
677 ),
678 )
679 .at(SpanKey::Node {
680 tree: tree_name.to_string(),
681 id: node_id.clone(),
682 }),
683 );
684 }
685
686 if !is_rust_ident(&op.handler) {
691 diagnostics.push(
692 Diagnostic::error(
693 "V-301",
694 format!(
695 "tree '{}': operation '{}' handler '{}' is not a valid identifier (must start with a letter or underscore and contain only letters, digits, and underscores)",
696 tree_name, node_id, op.handler
697 ),
698 )
699 .at(SpanKey::Node {
700 tree: tree_name.to_string(),
701 id: node_id.clone(),
702 }),
703 );
704 }
705 }
706 }
707}
708
709fn is_rust_ident(s: &str) -> bool {
710 let mut chars = s.chars();
711 match chars.next() {
712 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
713 _ => return false,
714 }
715 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
716}
717
718fn check_consequence_rules(tree_name: &str, tree: &EventTree, diagnostics: &mut Vec<Diagnostic>) {
719 for (node_id, node) in &tree.nodes {
720 if let Node::Consequence(cons) = node {
721 match cons.consequence_operation {
722 etdl_parser::ast::ConsequenceOperation::Send => {
723 if cons.channel.is_none() || cons.message.is_none() {
724 diagnostics.push(
725 Diagnostic::error(
726 "V-302",
727 format!(
728 "tree '{}': consequence '{}' has operation: send but omits channel or message",
729 tree_name, node_id
730 ),
731 )
732 .at(SpanKey::Node {
733 tree: tree_name.to_string(),
734 id: node_id.clone(),
735 }),
736 );
737 }
738 }
739 etdl_parser::ast::ConsequenceOperation::Terminate => {}
740 }
741 }
742 }
743}
744
745fn validate_fault_trees(doc: &EtlDocument, diagnostics: &mut Vec<Diagnostic>) {
746 let fault_trees = match &doc.fault_trees {
747 Some(fts) => fts,
748 None => return,
749 };
750
751 for (ft_name, ft) in fault_trees {
752 check_fault_tree_structure(doc, ft_name, ft, diagnostics);
753 check_gate_rules(ft_name, ft, diagnostics);
754 check_basic_event_rules(ft_name, ft, diagnostics);
755 }
756}
757
758fn check_fault_tree_structure(
759 doc: &EtlDocument,
760 ft_name: &str,
761 ft: &FaultTree,
762 diagnostics: &mut Vec<Diagnostic>,
763) {
764 let mut known_ids: HashMap<&str, bool> = HashMap::new();
765
766 if let Some(ref gates) = ft.gates {
767 for gate_id in gates.keys() {
768 known_ids.insert(gate_id.as_str(), false);
769 }
770 }
771 for be_id in ft.basic_events.keys() {
772 if known_ids.contains_key(be_id.as_str()) {
773 diagnostics.push(
774 Diagnostic::error(
775 "V-402",
776 format!(
777 "fault tree '{}': gate and basic event share ID '{}'",
778 ft_name, be_id
779 ),
780 )
781 .at(SpanKey::BasicEvent {
782 tree: ft_name.to_string(),
783 id: be_id.clone(),
784 }),
785 );
786 }
787 known_ids.insert(be_id.as_str(), false);
788 }
789
790 for (be_id, be) in &ft.basic_events {
791 if let Some(BasicEventType::House) = be.event_type {
792 if be.probability.is_some() || be.failure_rate.is_some() {
793 diagnostics.push(
794 Diagnostic::warning(
795 "W-406",
796 format!(
797 "fault tree '{}': house event '{}' declares a probability/failureRate; house events are boundary conditions and their value is not a computed leaf probability",
798 ft_name, be_id
799 ),
800 )
801 .at(SpanKey::BasicEvent {
802 tree: ft_name.to_string(),
803 id: be_id.clone(),
804 }),
805 );
806 }
807 }
808 }
809
810 let root_id = ft.top_event.root_cause.as_str();
811 match known_ids.get(root_id) {
812 None => {
813 diagnostics.push(
814 Diagnostic::error(
815 "V-401",
816 format!(
817 "fault tree '{}': topEvent.rootCause '{}' does not resolve to a gate or basic event",
818 ft_name, root_id
819 ),
820 )
821 .at(SpanKey::TopEvent {
822 tree: ft_name.to_string(),
823 field: "root_cause",
824 }),
825 );
826 }
827 Some(_) => {
828 known_ids.insert(root_id, true);
829 }
830 }
831
832 if let Some(ref gates) = ft.gates {
833 for (gate_id, gate) in gates {
834 for (idx, input) in gate.inputs.iter().enumerate() {
835 match known_ids.get(input.as_str()) {
836 None => {
837 diagnostics.push(
838 Diagnostic::error(
839 "V-401",
840 format!(
841 "fault tree '{}': gate '{}' input '{}' does not resolve",
842 ft_name, gate_id, input
843 ),
844 )
845 .at(SpanKey::GateInput {
846 tree: ft_name.to_string(),
847 id: gate_id.clone(),
848 idx,
849 }),
850 );
851 }
852 Some(_) => {
853 known_ids.insert(input.as_str(), true);
854 }
855 }
856 }
857 }
858 }
859
860 check_fault_tree_dag(ft_name, ft, diagnostics);
861
862 let mut unreachable: Vec<&str> = known_ids
864 .iter()
865 .filter(|(id, &is_reachable)| !is_reachable && **id != root_id)
866 .map(|(id, _)| *id)
867 .collect();
868 unreachable.sort_unstable();
869
870 for id in unreachable {
871 let key = if ft.gates.as_ref().is_some_and(|g| g.contains_key(id)) {
872 SpanKey::Gate {
873 tree: ft_name.to_string(),
874 id: id.to_string(),
875 }
876 } else {
877 SpanKey::BasicEvent {
878 tree: ft_name.to_string(),
879 id: id.to_string(),
880 }
881 };
882 diagnostics.push(
883 Diagnostic::error(
884 "V-404",
885 format!(
886 "fault tree '{}': '{}' is not reachable from topEvent.rootCause",
887 ft_name, id
888 ),
889 )
890 .at(key),
891 );
892 }
893
894 check_transfers(doc, ft_name, ft, diagnostics);
895}
896
897fn check_transfers(
898 doc: &EtlDocument,
899 ft_name: &str,
900 ft: &FaultTree,
901 diagnostics: &mut Vec<Diagnostic>,
902) {
903 let transfers = match &ft.transfers {
904 Some(t) => t,
905 None => return,
906 };
907
908 for (transfer_id, transfer) in transfers {
909 let target = transfer.target.trim_start_matches("#");
910 if !target.starts_with("/faultTrees/") {
911 diagnostics.push(
912 Diagnostic::error(
913 "V-506",
914 format!(
915 "fault tree '{}': transfer '{}' target '{}' must be an Internal Reference of the form '#/faultTrees/<id>/...'",
916 ft_name, transfer_id, transfer.target
917 ),
918 )
919 .at(SpanKey::Transfer {
920 tree: ft_name.to_string(),
921 id: transfer_id.clone(),
922 field: "target",
923 }),
924 );
925 } else {
926 let tree_id = target.trim_start_matches("/faultTrees/").split('/').next();
928 let tree_exists = match tree_id {
929 Some(id) => doc
930 .fault_trees
931 .as_ref()
932 .is_some_and(|fts| fts.contains_key(id)),
933 None => false,
934 };
935 if !tree_exists {
936 diagnostics.push(
937 Diagnostic::error(
938 "V-506",
939 format!(
940 "fault tree '{}': transfer '{}' target '{}' references fault tree '{}' which does not exist in this document",
941 ft_name,
942 transfer_id,
943 transfer.target,
944 tree_id.unwrap_or("")
945 ),
946 )
947 .at(SpanKey::Transfer {
948 tree: ft_name.to_string(),
949 id: transfer_id.clone(),
950 field: "target",
951 }),
952 );
953 }
954 }
955 if let Some(label) = &transfer.label {
956 if label.trim().is_empty() {
957 diagnostics.push(
958 Diagnostic::warning(
959 "W-405",
960 format!(
961 "fault tree '{}': transfer '{}' has an empty label",
962 ft_name, transfer_id
963 ),
964 )
965 .at(SpanKey::Transfer {
966 tree: ft_name.to_string(),
967 id: transfer_id.clone(),
968 field: "label",
969 }),
970 );
971 }
972 }
973 }
974}
975
976fn check_fault_tree_dag(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
977 let gates = match &ft.gates {
978 Some(g) => g,
979 None => return,
980 };
981
982 #[derive(Clone, Copy, PartialEq)]
983 enum Color {
984 White,
985 Gray,
986 Black,
987 }
988
989 let mut colors: HashMap<&str, Color> = HashMap::new();
990 for gate_id in gates.keys() {
991 colors.insert(gate_id.as_str(), Color::White);
992 }
993
994 fn dfs_gate<'a>(
995 gate_id: &'a str,
996 gates: &'a BTreeMap<String, Gate>,
997 colors: &mut HashMap<&'a str, Color>,
998 diagnostics: &mut Vec<Diagnostic>,
999 ft_name: &str,
1000 ) {
1001 if let Some(Color::Black) = colors.get(gate_id) {
1002 return;
1003 }
1004 if let Some(Color::Gray) = colors.get(gate_id) {
1005 return;
1006 }
1007
1008 colors.insert(gate_id, Color::Gray);
1009
1010 if let Some(gate) = gates.get(gate_id) {
1011 for input in &gate.inputs {
1012 if gates.contains_key(input.as_str()) {
1013 match colors.get(input.as_str()) {
1014 Some(Color::Gray) => {
1015 diagnostics.push(
1016 Diagnostic::error(
1017 "V-403",
1018 format!(
1019 "fault tree '{}': cycle detected involving gate '{}' -> '{}'",
1020 ft_name, gate_id, input
1021 ),
1022 )
1023 .at(SpanKey::Gate {
1024 tree: ft_name.to_string(),
1025 id: gate_id.to_string(),
1026 }),
1027 );
1028 }
1029 Some(Color::White) => {
1030 dfs_gate(input, gates, colors, diagnostics, ft_name);
1031 }
1032 _ => {}
1033 }
1034 }
1035 }
1036 }
1037
1038 colors.insert(gate_id, Color::Black);
1039 }
1040
1041 let root_id = ft.top_event.root_cause.as_str();
1042 if gates.contains_key(root_id) {
1043 dfs_gate(root_id, gates, &mut colors, diagnostics, ft_name);
1044 }
1045}
1046
1047fn check_gate_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
1048 let gates = match &ft.gates {
1049 Some(g) => g,
1050 None => return,
1051 };
1052
1053 for (gate_id, gate) in gates {
1054 let n = gate.inputs.len();
1055
1056 match gate.gate_type {
1057 GateType::And | GateType::Or => {
1058 if n < 2 {
1059 diagnostics.push(
1060 Diagnostic::error(
1061 "V-501",
1062 format!(
1063 "fault tree '{}': {:?} gate '{}' has {} input(s), minimum 2 required",
1064 ft_name, gate.gate_type, gate_id, n
1065 ),
1066 )
1067 .at(SpanKey::Gate {
1068 tree: ft_name.to_string(),
1069 id: gate_id.clone(),
1070 }),
1071 );
1072 }
1073 }
1074 GateType::Not => {
1075 if n != 1 {
1076 diagnostics.push(
1077 Diagnostic::error(
1078 "V-501",
1079 format!(
1080 "fault tree '{}': NOT gate '{}' has {} input(s), exactly 1 required",
1081 ft_name, gate_id, n
1082 ),
1083 )
1084 .at(SpanKey::Gate {
1085 tree: ft_name.to_string(),
1086 id: gate_id.clone(),
1087 }),
1088 );
1089 }
1090 }
1091 GateType::Xor => {
1092 if n != 2 {
1093 diagnostics.push(
1094 Diagnostic::error(
1095 "V-501",
1096 format!(
1097 "fault tree '{}': XOR gate '{}' has {} input(s), exactly 2 required",
1098 ft_name, gate_id, n
1099 ),
1100 )
1101 .at(SpanKey::Gate {
1102 tree: ft_name.to_string(),
1103 id: gate_id.clone(),
1104 }),
1105 );
1106 }
1107 }
1108 GateType::Voting => {
1109 if n < 2 {
1110 diagnostics.push(
1111 Diagnostic::error(
1112 "V-501",
1113 format!(
1114 "fault tree '{}': VOTING gate '{}' has {} input(s), minimum 2 required",
1115 ft_name, gate_id, n
1116 ),
1117 )
1118 .at(SpanKey::Gate {
1119 tree: ft_name.to_string(),
1120 id: gate_id.clone(),
1121 }),
1122 );
1123 }
1124 if let Some(k) = gate.k {
1125 if k < 1 || k as usize > n {
1126 diagnostics.push(
1127 Diagnostic::error(
1128 "V-502",
1129 format!(
1130 "fault tree '{}': VOTING gate '{}' k={} must satisfy 1 <= k <= n={}",
1131 ft_name, gate_id, k, n
1132 ),
1133 )
1134 .at(SpanKey::GateField {
1135 tree: ft_name.to_string(),
1136 id: gate_id.clone(),
1137 field: "k",
1138 }),
1139 );
1140 }
1141 } else {
1142 diagnostics.push(
1143 Diagnostic::error(
1144 "V-502",
1145 format!(
1146 "fault tree '{}': VOTING gate '{}' missing required 'k' field",
1147 ft_name, gate_id
1148 ),
1149 )
1150 .at(SpanKey::GateField {
1151 tree: ft_name.to_string(),
1152 id: gate_id.clone(),
1153 field: "k",
1154 }),
1155 );
1156 }
1157 }
1158 GateType::Inhibit => {
1159 if n != 2 {
1160 diagnostics.push(
1161 Diagnostic::error(
1162 "V-501",
1163 format!(
1164 "fault tree '{}': INHIBIT gate '{}' has {} input(s), exactly 2 required",
1165 ft_name, gate_id, n
1166 ),
1167 )
1168 .at(SpanKey::Gate {
1169 tree: ft_name.to_string(),
1170 id: gate_id.clone(),
1171 }),
1172 );
1173 }
1174 if gate.inhibit_condition.is_none() {
1175 diagnostics.push(
1176 Diagnostic::error(
1177 "V-505",
1178 format!(
1179 "fault tree '{}': INHIBIT gate '{}' missing required 'inhibitCondition' field",
1180 ft_name, gate_id
1181 ),
1182 )
1183 .at(SpanKey::GateField {
1184 tree: ft_name.to_string(),
1185 id: gate_id.clone(),
1186 field: "inhibit_condition",
1187 }),
1188 );
1189 }
1190 }
1191 GateType::PriorityAnd => {
1192 if n < 2 {
1193 diagnostics.push(
1194 Diagnostic::error(
1195 "V-501",
1196 format!(
1197 "fault tree '{}': PRIORITY_AND gate '{}' has {} input(s), minimum 2 required",
1198 ft_name, gate_id, n
1199 ),
1200 )
1201 .at(SpanKey::Gate {
1202 tree: ft_name.to_string(),
1203 id: gate_id.clone(),
1204 }),
1205 );
1206 }
1207 }
1208 }
1209 }
1210}
1211
1212fn check_basic_event_rules(ft_name: &str, ft: &FaultTree, diagnostics: &mut Vec<Diagnostic>) {
1213 for (be_id, be) in &ft.basic_events {
1214 let has_prob = be.probability.is_some();
1215 let has_rate = be.failure_rate.is_some();
1216 let has_time = be.mission_time.is_some();
1217
1218 if has_prob && has_rate {
1219 diagnostics.push(
1220 Diagnostic::error(
1221 "V-503",
1222 format!(
1223 "fault tree '{}': basic event '{}' supplies both probability and failureRate",
1224 ft_name, be_id
1225 ),
1226 )
1227 .at(SpanKey::BasicEvent {
1228 tree: ft_name.to_string(),
1229 id: be_id.clone(),
1230 }),
1231 );
1232 } else if !has_prob && !has_rate {
1233 diagnostics.push(
1234 Diagnostic::error(
1235 "V-503",
1236 format!(
1237 "fault tree '{}': basic event '{}' supplies neither probability nor failureRate",
1238 ft_name, be_id
1239 ),
1240 )
1241 .at(SpanKey::BasicEvent {
1242 tree: ft_name.to_string(),
1243 id: be_id.clone(),
1244 }),
1245 );
1246 }
1247
1248 if has_rate && !has_time {
1249 diagnostics.push(
1250 Diagnostic::error(
1251 "V-504",
1252 format!(
1253 "fault tree '{}': basic event '{}' has failureRate but no missionTime",
1254 ft_name, be_id
1255 ),
1256 )
1257 .at(SpanKey::BasicEvent {
1258 tree: ft_name.to_string(),
1259 id: be_id.clone(),
1260 }),
1261 );
1262 }
1263 }
1264}
1265
1266pub type FaultTreeProbabilities = BTreeMap<String, f64>;
1267
1268pub fn resolve_probability_links(
1269 doc: &EtlDocument,
1270 fault_tree_probs: &FaultTreeProbabilities,
1271 diagnostics: &mut Vec<Diagnostic>,
1272) -> BTreeMap<String, f64> {
1273 let mut branch_probs: BTreeMap<String, f64> = BTreeMap::new();
1274
1275 for (tree_name, tree) in &doc.event_trees {
1276 for (node_id, node) in &tree.nodes {
1277 match node {
1278 Node::Barrier(barrier) => {
1279 for (i, branch) in barrier.branches.iter().enumerate() {
1280 let key = format!("{}.branch.{}", node_id, i);
1281
1282 if let Some(ref ps) = branch.probability_source {
1283 let ft_id = extract_fault_tree_id(&ps.pointer);
1284 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
1285 if let Some(cached) = branch.effective_probability() {
1286 if (cached - prob).abs() > 0.001 {
1287 diagnostics.push(
1288 Diagnostic::warning(
1289 "W-402",
1290 format!(
1291 "branch '{}[{}]' cached probability {} drifted from fault tree computed {}",
1292 node_id, i, cached, prob
1293 ),
1294 )
1295 .at(SpanKey::BranchField {
1296 tree: tree_name.clone(),
1297 id: node_id.clone(),
1298 branch: i,
1299 field: "probability",
1300 }),
1301 );
1302 }
1303 }
1304 branch_probs.insert(key, prob);
1305 } else {
1306 diagnostics.push(
1307 Diagnostic::error(
1308 "E-105",
1309 format!(
1310 "branch '{}[{}]' probabilitySource references unknown fault tree",
1311 node_id, i
1312 ),
1313 )
1314 .at(SpanKey::BranchField {
1315 tree: tree_name.clone(),
1316 id: node_id.clone(),
1317 branch: i,
1318 field: "probability_source",
1319 }),
1320 );
1321 }
1322 } else if let Some(prob) = branch.effective_probability() {
1323 branch_probs.insert(key, prob);
1324 }
1325 }
1326 }
1327 Node::Operation(op) => {
1328 if let Some(ref ps) = op.on_failure_probability_source {
1329 let ft_id = extract_fault_tree_id(&ps.pointer);
1330 if let Some(&prob) = fault_tree_probs.get(&ft_id) {
1331 branch_probs.insert(format!("{}.onFailure", node_id), prob);
1332 }
1333 }
1334 }
1335 _ => {}
1336 }
1337 }
1338 }
1339
1340 branch_probs
1341}
1342
1343fn extract_fault_tree_id(pointer: &str) -> String {
1344 let parts: Vec<&str> = pointer
1345 .trim_start_matches("#/faultTrees/")
1346 .split('/')
1347 .collect();
1348 parts[0].to_string()
1349}
1350
1351pub fn validate_probability_sums(
1352 doc: &EtlDocument,
1353 resolved_probs: &BTreeMap<String, f64>,
1354 diagnostics: &mut Vec<Diagnostic>,
1355) {
1356 for (tree_name, tree) in &doc.event_trees {
1357 for (node_id, node) in &tree.nodes {
1358 if let Node::Barrier(barrier) = node {
1359 let mut sum: f64 = 0.0;
1360 let mut all_declared = true;
1361
1362 for (i, b) in barrier.branches.iter().enumerate() {
1363 let prob = if let Some(_ps) = &b.probability_source {
1364 resolved_probs
1365 .get(&format!("{}.branch.{}", node_id, i))
1366 .copied()
1367 } else {
1368 b.effective_probability()
1369 };
1370
1371 match prob {
1372 Some(p) => {
1373 if !(0.0..=1.0).contains(&p) {
1375 diagnostics.push(
1376 Diagnostic::error(
1377 "V-203",
1378 format!(
1379 "tree '{}': barrier '{}' branch '{}' probability {} is outside [0,1]",
1380 tree_name, node_id, b.outcome, p
1381 ),
1382 )
1383 .at(SpanKey::BranchField {
1384 tree: tree_name.clone(),
1385 id: node_id.clone(),
1386 branch: i,
1387 field: "probability",
1388 }),
1389 );
1390 }
1391 sum += p;
1392 }
1393 None => {
1394 all_declared = false;
1395 }
1396 }
1397 }
1398
1399 if !barrier.branches.is_empty() && all_declared && (sum - 1.0).abs() > 0.0001 {
1400 diagnostics.push(
1401 Diagnostic::error(
1402 "V-203",
1403 format!(
1404 "tree '{}': barrier '{}' branch probabilities sum to {:.4} (must be 1.0 within ±0.0001)",
1405 tree_name, node_id, sum
1406 ),
1407 )
1408 .at(SpanKey::Node {
1409 tree: tree_name.clone(),
1410 id: node_id.clone(),
1411 }),
1412 );
1413 }
1414 }
1415 }
1416 }
1417}