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