busbar_sf_agentscript/graph/
validation.rs1use 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#[derive(Debug, Default)]
15pub struct ValidationResult {
16 pub errors: Vec<ValidationError>,
18 pub warnings: Vec<ValidationError>,
20}
21
22impl ValidationResult {
23 pub fn is_ok(&self) -> bool {
25 self.errors.is_empty()
26 }
27
28 pub fn has_issues(&self) -> bool {
30 !self.errors.is_empty() || !self.warnings.is_empty()
31 }
32
33 pub fn all_issues(&self) -> impl Iterator<Item = &ValidationError> {
35 self.errors.iter().chain(self.warnings.iter())
36 }
37}
38
39impl RefGraph {
40 pub fn validate(&self) -> ValidationResult {
45 let mut result = ValidationResult::default();
46
47 result.errors.extend(self.unresolved_references.clone());
49
50 result.errors.extend(self.find_cycles());
52
53 result.warnings.extend(self.find_unreachable_topics());
55
56 result.warnings.extend(self.find_unused_actions());
58 result.warnings.extend(self.find_unused_variables());
59
60 result
61 }
62
63 pub fn find_cycles(&self) -> Vec<ValidationError> {
67 if !is_cyclic_directed(&self.graph) {
68 return vec![];
69 }
70
71 let sccs = tarjan_scc(&self.graph);
73 let mut errors = Vec::new();
74
75 for scc in sccs {
76 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 pub fn find_unreachable_topics(&self) -> Vec<ValidationError> {
100 let start_idx = match self.start_agent {
101 Some(idx) => idx,
102 None => return vec![], };
104
105 let reachable = self.find_reachable_from(start_idx);
107
108 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 pub fn find_unused_actions(&self) -> Vec<ValidationError> {
130 self.action_defs
131 .iter()
132 .filter_map(|((topic, name), &idx)| {
133 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 pub fn find_unused_variables(&self) -> Vec<ValidationError> {
158 self.variables
159 .iter()
160 .filter_map(|(name, &idx)| {
161 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 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 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 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 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 assert!(!unreachable_names.contains(&"help".to_string()), "'help' should be reachable");
330 }
331
332 #[test]
333 fn test_unused_action_def_detected() {
334 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 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 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 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 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 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 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 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_unresolved_variable_reference_detected() {
562 let source = r#"config:
565 agent_name: "Test"
566
567variables:
568 real_var: mutable string = ""
569
570start_agent selector:
571 description: "Route"
572 reasoning:
573 instructions: "Select"
574 actions:
575 go_main: @utils.transition to @topic.main
576 description: "Go to main"
577
578topic main:
579 description: "Main"
580 reasoning:
581 instructions: "Help"
582 actions:
583 do_thing: @actions.do_thing
584 description: "Do a thing"
585 with id=@variables.nonexistent_var
586"#;
587 let graph = parse_and_build(source);
588 let result = graph.validate();
589 let unresolved: Vec<_> = result
590 .errors
591 .iter()
592 .filter(|e| matches!(e, ValidationError::UnresolvedReference { .. }))
593 .collect();
594 assert!(
595 !unresolved.is_empty(),
596 "Expected an unresolved reference error for @variables.nonexistent_var"
597 );
598 }
599}