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