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