Skip to main content

etdl_compiler/
validate.rs

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