Skip to main content

busbar_sf_agentscript/graph/
validation.rs

1//! Validation and analysis of reference graphs.
2
3use super::edges::RefEdge;
4use super::error::ValidationError;
5use super::nodes::RefNode;
6use super::RefGraph;
7use petgraph::algo::{is_cyclic_directed, tarjan_scc};
8use petgraph::graph::NodeIndex;
9use petgraph::visit::EdgeRef;
10use petgraph::Direction;
11use std::collections::HashSet;
12
13/// Result of validating a reference graph.
14#[derive(Debug, Default)]
15pub struct ValidationResult {
16    /// All validation errors found
17    pub errors: Vec<ValidationError>,
18    /// All validation warnings found
19    pub warnings: Vec<ValidationError>,
20}
21
22impl ValidationResult {
23    /// Check if validation passed (no errors).
24    pub fn is_ok(&self) -> bool {
25        self.errors.is_empty()
26    }
27
28    /// Check if there are any issues (errors or warnings).
29    pub fn has_issues(&self) -> bool {
30        !self.errors.is_empty() || !self.warnings.is_empty()
31    }
32
33    /// Get all issues (errors and warnings combined).
34    pub fn all_issues(&self) -> impl Iterator<Item = &ValidationError> {
35        self.errors.iter().chain(self.warnings.iter())
36    }
37}
38
39impl RefGraph {
40    /// Perform full validation of the reference graph.
41    ///
42    /// Returns errors for issues that would cause runtime failures,
43    /// and warnings for issues that may indicate problems.
44    pub fn validate(&self) -> ValidationResult {
45        let mut result = ValidationResult::default();
46
47        // Report unresolved references found during graph build
48        result.errors.extend(self.unresolved_references.clone());
49
50        // Check for cycles
51        result.errors.extend(self.find_cycles());
52
53        // Check for unreachable topics
54        result.warnings.extend(self.find_unreachable_topics());
55
56        // Check for unused definitions
57        result.warnings.extend(self.find_unused_actions());
58        result.warnings.extend(self.find_unused_variables());
59
60        result
61    }
62
63    /// Find cycles in topic transitions.
64    ///
65    /// Topic transitions should form a DAG. Cycles indicate infinite loops.
66    pub fn find_cycles(&self) -> Vec<ValidationError> {
67        if !is_cyclic_directed(&self.graph) {
68            return vec![];
69        }
70
71        // Find strongly connected components to identify cycles
72        let sccs = tarjan_scc(&self.graph);
73        let mut errors = Vec::new();
74
75        for scc in sccs {
76            // A SCC with more than one node indicates a cycle
77            if scc.len() > 1 {
78                let path: Vec<String> = scc
79                    .iter()
80                    .filter_map(|&idx| {
81                        if let Some(RefNode::Topic { name, .. }) = self.graph.node_weight(idx) {
82                            Some(name.clone())
83                        } else {
84                            None
85                        }
86                    })
87                    .collect();
88
89                if !path.is_empty() {
90                    errors.push(ValidationError::CycleDetected { path });
91                }
92            }
93        }
94
95        errors
96    }
97
98    /// Find topics that are unreachable from start_agent.
99    pub fn find_unreachable_topics(&self) -> Vec<ValidationError> {
100        let start_idx = match self.start_agent {
101            Some(idx) => idx,
102            None => return vec![], // No start_agent to check from
103        };
104
105        // Find all topics reachable from start_agent
106        let reachable = self.find_reachable_from(start_idx);
107
108        // Check each topic
109        self.topics
110            .iter()
111            .filter_map(|(name, &idx)| {
112                if !reachable.contains(&idx) {
113                    if let Some(RefNode::Topic { span, .. }) = self.graph.node_weight(idx) {
114                        Some(ValidationError::UnreachableTopic {
115                            name: name.clone(),
116                            span: *span,
117                        })
118                    } else {
119                        None
120                    }
121                } else {
122                    None
123                }
124            })
125            .collect()
126    }
127
128    /// Find action definitions that are never invoked.
129    pub fn find_unused_actions(&self) -> Vec<ValidationError> {
130        self.action_defs
131            .iter()
132            .filter_map(|((topic, name), &idx)| {
133                // Check if any edge points to this action
134                let has_incoming = self
135                    .graph
136                    .edges_directed(idx, Direction::Incoming)
137                    .any(|e| matches!(e.weight(), RefEdge::Invokes));
138
139                if !has_incoming {
140                    if let Some(RefNode::ActionDef { span, .. }) = self.graph.node_weight(idx) {
141                        Some(ValidationError::UnusedActionDef {
142                            name: name.clone(),
143                            topic: topic.clone(),
144                            span: *span,
145                        })
146                    } else {
147                        None
148                    }
149                } else {
150                    None
151                }
152            })
153            .collect()
154    }
155
156    /// Find variables that are never read.
157    pub fn find_unused_variables(&self) -> Vec<ValidationError> {
158        self.variables
159            .iter()
160            .filter_map(|(name, &idx)| {
161                // Check if any edge reads from this variable
162                let has_readers = self
163                    .graph
164                    .edges_directed(idx, Direction::Incoming)
165                    .any(|e| matches!(e.weight(), RefEdge::Reads));
166
167                if !has_readers {
168                    if let Some(RefNode::Variable { span, .. }) = self.graph.node_weight(idx) {
169                        Some(ValidationError::UnusedVariable {
170                            name: name.clone(),
171                            span: *span,
172                        })
173                    } else {
174                        None
175                    }
176                } else {
177                    None
178                }
179            })
180            .collect()
181    }
182
183    /// Find all nodes reachable from a starting node.
184    fn find_reachable_from(&self, start: NodeIndex) -> HashSet<NodeIndex> {
185        let mut reachable = HashSet::new();
186        let mut stack = vec![start];
187
188        while let Some(idx) = stack.pop() {
189            if reachable.insert(idx) {
190                // Add all outgoing neighbors
191                for edge in self.graph.edges_directed(idx, Direction::Outgoing) {
192                    stack.push(edge.target());
193                }
194            }
195        }
196
197        reachable
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn parse_and_build(source: &str) -> RefGraph {
206        let ast = crate::parse(source).expect("Failed to parse");
207        RefGraph::from_ast(&ast).expect("Failed to build graph")
208    }
209
210    #[test]
211    fn test_no_cycles() {
212        let source = r#"config:
213   agent_name: "Test"
214
215start_agent topic_selector:
216   description: "Route to topics"
217   reasoning:
218      instructions: "Select the best topic"
219      actions:
220         go_help: @utils.transition to @topic.help
221            description: "Go to help topic"
222
223topic help:
224   description: "Help topic"
225   reasoning:
226      instructions: "Provide help"
227"#;
228        let graph = parse_and_build(source);
229        let result = graph.validate();
230        assert!(result.errors.is_empty());
231    }
232
233    #[test]
234    fn test_cycle_detected_between_two_topics() {
235        // topic_a transitions to topic_b and topic_b transitions back to topic_a,
236        // forming a cycle that should be detected.
237        let source = r#"config:
238   agent_name: "Test"
239
240start_agent selector:
241   description: "Route"
242   reasoning:
243      instructions: "Select"
244      actions:
245         go_a: @utils.transition to @topic.topic_a
246            description: "Go to A"
247
248topic topic_a:
249   description: "Topic A"
250   reasoning:
251      instructions: "In A"
252      actions:
253         go_b: @utils.transition to @topic.topic_b
254            description: "Go to B"
255
256topic topic_b:
257   description: "Topic B"
258   reasoning:
259      instructions: "In B"
260      actions:
261         go_a: @utils.transition to @topic.topic_a
262            description: "Back to A"
263"#;
264        let graph = parse_and_build(source);
265        let cycles = graph.find_cycles();
266        assert!(!cycles.is_empty(), "Expected a cycle between topic_a and topic_b");
267        let cycle_names: Vec<_> = cycles
268            .iter()
269            .flat_map(|e| {
270                if let ValidationError::CycleDetected { path } = e {
271                    path.clone()
272                } else {
273                    vec![]
274                }
275            })
276            .collect();
277        assert!(
278            cycle_names.contains(&"topic_a".to_string())
279                || cycle_names.contains(&"topic_b".to_string()),
280            "Cycle should involve topic_a and/or topic_b, got: {:?}",
281            cycle_names
282        );
283    }
284
285    #[test]
286    fn test_unreachable_topic_detected() {
287        // topic_orphan is never the target of any transition, so it is unreachable
288        // from start_agent and should be reported as a warning.
289        let source = r#"config:
290   agent_name: "Test"
291
292start_agent selector:
293   description: "Route"
294   reasoning:
295      instructions: "Select"
296      actions:
297         go_help: @utils.transition to @topic.help
298            description: "Go to help"
299
300topic help:
301   description: "Help topic"
302   reasoning:
303      instructions: "Provide help"
304
305topic orphan:
306   description: "This topic is never reached by any transition"
307   reasoning:
308      instructions: "Orphan"
309"#;
310        let graph = parse_and_build(source);
311        let unreachable = graph.find_unreachable_topics();
312        assert!(!unreachable.is_empty(), "Expected 'orphan' to be detected as unreachable");
313        let unreachable_names: Vec<_> = unreachable
314            .iter()
315            .filter_map(|e| {
316                if let ValidationError::UnreachableTopic { name, .. } = e {
317                    Some(name.clone())
318                } else {
319                    None
320                }
321            })
322            .collect();
323        assert!(
324            unreachable_names.contains(&"orphan".to_string()),
325            "Expected 'orphan' in unreachable topics, got: {:?}",
326            unreachable_names
327        );
328        // 'help' IS reachable so it should not appear
329        assert!(!unreachable_names.contains(&"help".to_string()), "'help' should be reachable");
330    }
331
332    #[test]
333    fn test_unused_action_def_detected() {
334        // get_data is defined in the actions block but no reasoning action invokes it,
335        // so it should be reported as an unused action definition.
336        let source = r#"config:
337   agent_name: "Test"
338
339topic main:
340   description: "Main topic"
341
342   actions:
343      get_data:
344         description: "Retrieves data from backend"
345         inputs:
346            record_id: string
347               description: "Record identifier"
348         outputs:
349            result: string
350               description: "Query result"
351         target: "flow://GetData"
352
353   reasoning:
354      instructions: "Help the user with their request"
355"#;
356        let graph = parse_and_build(source);
357        let unused = graph.find_unused_actions();
358        assert!(!unused.is_empty(), "Expected 'get_data' to be detected as unused");
359        let unused_names: Vec<_> = unused
360            .iter()
361            .filter_map(|e| {
362                if let ValidationError::UnusedActionDef { name, topic, .. } = e {
363                    Some((topic.clone(), name.clone()))
364                } else {
365                    None
366                }
367            })
368            .collect();
369        assert!(
370            unused_names.contains(&("main".to_string(), "get_data".to_string())),
371            "Expected ('main', 'get_data') in unused actions, got: {:?}",
372            unused_names
373        );
374    }
375
376    #[test]
377    fn test_unused_variable_detected() {
378        // customer_name is declared in the variables block but is never read by
379        // any reasoning action, so it should be reported as an unused variable.
380        let source = r#"config:
381   agent_name: "Test"
382
383variables:
384   customer_name: mutable string = ""
385      description: "The customer's name — declared but never read"
386
387topic main:
388   description: "Main topic"
389   reasoning:
390      instructions: "Help the user"
391"#;
392        let graph = parse_and_build(source);
393        let unused = graph.find_unused_variables();
394        assert!(!unused.is_empty(), "Expected 'customer_name' to be detected as unused");
395        let unused_names: Vec<_> = unused
396            .iter()
397            .filter_map(|e| {
398                if let ValidationError::UnusedVariable { name, .. } = e {
399                    Some(name.clone())
400                } else {
401                    None
402                }
403            })
404            .collect();
405        assert!(
406            unused_names.contains(&"customer_name".to_string()),
407            "Expected 'customer_name' in unused variables, got: {:?}",
408            unused_names
409        );
410    }
411
412    #[test]
413    fn test_unresolved_topic_reference_detected() {
414        // The start_agent transitions to @topic.nonexistent which is never defined,
415        // so the reference should surface as an error during validation.
416        let source = r#"config:
417   agent_name: "Test"
418
419start_agent selector:
420   description: "Route"
421   reasoning:
422      instructions: "Select"
423      actions:
424         go_missing: @utils.transition to @topic.nonexistent
425            description: "Go to a topic that does not exist"
426
427topic real_topic:
428   description: "The only real topic"
429   reasoning:
430      instructions: "Real"
431"#;
432        let graph = parse_and_build(source);
433        let result = graph.validate();
434        // Unresolved references are collected as errors
435        let unresolved: Vec<_> = result
436            .errors
437            .iter()
438            .filter(|e| matches!(e, ValidationError::UnresolvedReference { .. }))
439            .collect();
440        assert!(
441            !unresolved.is_empty(),
442            "Expected an unresolved reference error for @topic.nonexistent"
443        );
444    }
445
446    #[test]
447    fn test_validate_returns_ok_for_fully_connected_graph() {
448        // All topics reachable, all action defs invoked, no cycles — validate() should
449        // return no errors and no warnings.
450        let source = r#"config:
451   agent_name: "Test"
452
453start_agent selector:
454   description: "Route to main"
455   reasoning:
456      instructions: "Select"
457      actions:
458         go_main: @utils.transition to @topic.main
459            description: "Enter main"
460
461topic main:
462   description: "Main topic"
463
464   actions:
465      lookup:
466         description: "Look up a record"
467         inputs:
468            id: string
469               description: "Record ID"
470         outputs:
471            name: string
472               description: "Record name"
473         target: "flow://Lookup"
474
475   reasoning:
476      instructions: "Help"
477      actions:
478         do_lookup: @actions.lookup
479            description: "Perform the lookup"
480"#;
481        let graph = parse_and_build(source);
482        let result = graph.validate();
483        assert!(result.errors.is_empty(), "Expected no errors, got: {:?}", result.errors);
484        // The action is invoked, so no unused-action warnings expected
485        let unused_action_warns: Vec<_> = result
486            .warnings
487            .iter()
488            .filter(|w| matches!(w, ValidationError::UnusedActionDef { .. }))
489            .collect();
490        assert!(
491            unused_action_warns.is_empty(),
492            "Expected no unused-action warnings, got: {:?}",
493            unused_action_warns
494        );
495    }
496
497    #[test]
498    fn test_three_node_cycle_detected() {
499        // topic_a → topic_b → topic_c → topic_a forms a three-node cycle.
500        // The graph contains a cycle involving all three topics and
501        // find_cycles() should surface it.
502        let source = r#"config:
503   agent_name: "Test"
504
505start_agent selector:
506   description: "Route"
507   reasoning:
508      instructions: "Select"
509      actions:
510         go_a: @utils.transition to @topic.topic_a
511            description: "Go to A"
512
513topic topic_a:
514   description: "Topic A"
515   reasoning:
516      instructions: "In A"
517      actions:
518         go_b: @utils.transition to @topic.topic_b
519            description: "Go to B"
520
521topic topic_b:
522   description: "Topic B"
523   reasoning:
524      instructions: "In B"
525      actions:
526         go_c: @utils.transition to @topic.topic_c
527            description: "Go to C"
528
529topic topic_c:
530   description: "Topic C"
531   reasoning:
532      instructions: "In C"
533      actions:
534         back_to_a: @utils.transition to @topic.topic_a
535            description: "Back to A"
536"#;
537        let graph = parse_and_build(source);
538        let cycles = graph.find_cycles();
539        assert!(!cycles.is_empty(), "Expected a cycle among topic_a, topic_b, topic_c");
540        let cycle_names: Vec<_> = cycles
541            .iter()
542            .flat_map(|e| {
543                if let ValidationError::CycleDetected { path } = e {
544                    path.clone()
545                } else {
546                    vec![]
547                }
548            })
549            .collect();
550        // At least one of the three topics should appear in the reported cycle path
551        assert!(
552            cycle_names
553                .iter()
554                .any(|n| { n == "topic_a" || n == "topic_b" || n == "topic_c" }),
555            "Cycle should involve topic_a/b/c, got: {:?}",
556            cycle_names
557        );
558    }
559
560    #[test]
561    fn test_invalid_property_access_on_non_object_variable() {
562        // Accessing a property on a number variable should produce an
563        // InvalidPropertyAccess error.
564        let source = r#"config:
565   agent_name: "Test"
566
567variables:
568   count: mutable number = 0
569      description: "A counter"
570
571start_agent selector:
572   description: "Route"
573   reasoning:
574      instructions:->
575         | Value: {!@variables.count.value}
576      actions:
577         go_main: @utils.transition to @topic.main
578            description: "Go to main"
579
580topic main:
581   description: "Main"
582   reasoning:
583      instructions: "Help"
584      actions:
585         stay: @utils.transition to @topic.main
586            description: "Stay"
587"#;
588        let graph = parse_and_build(source);
589        let result = graph.validate();
590        let invalid_access: Vec<_> = result
591            .errors
592            .iter()
593            .filter(|e| matches!(e, ValidationError::InvalidPropertyAccess { .. }))
594            .collect();
595        assert!(
596            !invalid_access.is_empty(),
597            "Expected InvalidPropertyAccess for @variables.count.value on number type"
598        );
599    }
600
601    #[test]
602    fn test_valid_property_access_on_object_variable() {
603        // Accessing a property on an object variable should NOT produce errors.
604        let source = r#"config:
605   agent_name: "Test"
606
607variables:
608   stats: mutable object = {}
609      description: "Stats"
610
611start_agent selector:
612   description: "Route"
613   reasoning:
614      instructions:->
615         | Total: {!@variables.stats.total}
616      actions:
617         go_main: @utils.transition to @topic.main
618            description: "Go to main"
619
620topic main:
621   description: "Main"
622   reasoning:
623      instructions: "Help"
624      actions:
625         stay: @utils.transition to @topic.main
626            description: "Stay"
627"#;
628        let graph = parse_and_build(source);
629        let result = graph.validate();
630        let invalid_access: Vec<_> = result
631            .errors
632            .iter()
633            .filter(|e| matches!(e, ValidationError::InvalidPropertyAccess { .. }))
634            .collect();
635        assert!(
636            invalid_access.is_empty(),
637            "Expected no InvalidPropertyAccess for @variables.stats.total on object type, got: {:?}",
638            invalid_access
639        );
640    }
641
642    #[test]
643    fn test_unresolved_variable_reference_detected() {
644        // A reasoning action binds @variables.nonexistent_var which is never declared
645        // in the variables block.  The unresolved reference should surface as an error.
646        let source = r#"config:
647   agent_name: "Test"
648
649variables:
650   real_var: mutable string = ""
651
652start_agent selector:
653   description: "Route"
654   reasoning:
655      instructions: "Select"
656      actions:
657         go_main: @utils.transition to @topic.main
658            description: "Go to main"
659
660topic main:
661   description: "Main"
662   reasoning:
663      instructions: "Help"
664      actions:
665         do_thing: @actions.do_thing
666            description: "Do a thing"
667            with id=@variables.nonexistent_var
668"#;
669        let graph = parse_and_build(source);
670        let result = graph.validate();
671        let unresolved: Vec<_> = result
672            .errors
673            .iter()
674            .filter(|e| matches!(e, ValidationError::UnresolvedReference { .. }))
675            .collect();
676        assert!(
677            !unresolved.is_empty(),
678            "Expected an unresolved reference error for @variables.nonexistent_var"
679        );
680    }
681}