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