Skip to main content

etdl_compiler/
validate.rs

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    /// Structured locator used to resolve source positions in the WASM layer.
19    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    /// Attach a structured source locator so the caller can resolve the span.
62    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}