1use super::graph::PipelineGraph;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Severity {
8 Error,
9 Warning,
10}
11
12#[derive(Debug, Clone)]
14pub struct ValidationIssue {
15 pub severity: Severity,
16 pub rule: String,
17 pub message: String,
18 pub node_id: Option<String>,
19}
20
21pub fn validate(graph: &PipelineGraph) -> Vec<ValidationIssue> {
23 let mut issues = Vec::new();
24
25 check_start_node(graph, &mut issues);
26 check_terminal_node(graph, &mut issues);
27 check_reachability(graph, &mut issues);
28 check_edge_targets(graph, &mut issues);
29 check_start_no_incoming(graph, &mut issues);
30 check_exit_no_outgoing(graph, &mut issues);
31 check_condition_syntax(graph, &mut issues);
32 check_type_known(graph, &mut issues);
33 check_retry_target_exists(graph, &mut issues);
34 check_goal_gate_has_retry(graph, &mut issues);
35 check_prompt_on_llm_nodes(graph, &mut issues);
36
37 issues
38}
39
40pub fn is_valid(issues: &[ValidationIssue]) -> bool {
42 !issues.iter().any(|i| i.severity == Severity::Error)
43}
44
45fn check_start_node(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
46 let has_start = graph
47 .graph
48 .node_indices()
49 .any(|idx| graph.graph[idx].handler_type == "start");
50 if !has_start {
51 issues.push(ValidationIssue {
52 severity: Severity::Error,
53 rule: "start_node".into(),
54 message: "Graph must have exactly one start node (shape=Mdiamond)".into(),
55 node_id: None,
56 });
57 }
58}
59
60fn check_terminal_node(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
61 let has_exit = graph
62 .graph
63 .node_indices()
64 .any(|idx| graph.graph[idx].handler_type == "exit");
65 if !has_exit {
66 issues.push(ValidationIssue {
67 severity: Severity::Error,
68 rule: "terminal_node".into(),
69 message: "Graph must have at least one exit node (shape=Msquare)".into(),
70 node_id: None,
71 });
72 }
73}
74
75fn check_reachability(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
76 use petgraph::visit::Bfs;
77
78 let mut bfs = Bfs::new(&graph.graph, graph.start_node);
79 let mut reachable = std::collections::HashSet::new();
80 while let Some(node) = bfs.next(&graph.graph) {
81 reachable.insert(node);
82 }
83
84 for idx in graph.graph.node_indices() {
85 if !reachable.contains(&idx) {
86 let node = &graph.graph[idx];
87 issues.push(ValidationIssue {
88 severity: Severity::Error,
89 rule: "reachability".into(),
90 message: format!("Node '{}' is not reachable from start", node.id),
91 node_id: Some(node.id.clone()),
92 });
93 }
94 }
95}
96
97fn check_edge_targets(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
98 for idx in graph.graph.node_indices() {
101 let node = &graph.graph[idx];
102 if let Some(ref target) = node.retry_target {
103 if !graph.node_index.contains_key(target) {
104 issues.push(ValidationIssue {
105 severity: Severity::Error,
106 rule: "edge_target_exists".into(),
107 message: format!(
108 "Node '{}' has retry_target '{}' which does not exist",
109 node.id, target
110 ),
111 node_id: Some(node.id.clone()),
112 });
113 }
114 }
115 if let Some(ref target) = node.fallback_retry_target {
116 if !graph.node_index.contains_key(target) {
117 issues.push(ValidationIssue {
118 severity: Severity::Error,
119 rule: "edge_target_exists".into(),
120 message: format!(
121 "Node '{}' has fallback_retry_target '{}' which does not exist",
122 node.id, target
123 ),
124 node_id: Some(node.id.clone()),
125 });
126 }
127 }
128 }
129}
130
131fn check_start_no_incoming(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
132 let incoming = graph
133 .graph
134 .edges_directed(graph.start_node, petgraph::Direction::Incoming)
135 .count();
136 if incoming > 0 {
137 issues.push(ValidationIssue {
138 severity: Severity::Error,
139 rule: "start_no_incoming".into(),
140 message: "Start node must not have incoming edges".into(),
141 node_id: Some(graph.graph[graph.start_node].id.clone()),
142 });
143 }
144}
145
146fn check_exit_no_outgoing(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
147 let outgoing = graph
148 .graph
149 .edges_directed(graph.exit_node, petgraph::Direction::Outgoing)
150 .count();
151 if outgoing > 0 {
152 issues.push(ValidationIssue {
153 severity: Severity::Error,
154 rule: "exit_no_outgoing".into(),
155 message: "Exit node must not have outgoing edges".into(),
156 node_id: Some(graph.graph[graph.exit_node].id.clone()),
157 });
158 }
159}
160
161fn check_condition_syntax(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
162 use petgraph::visit::EdgeRef;
163 for edge_ref in graph.graph.edge_references() {
164 let edge = edge_ref.weight();
165 if !edge.condition.is_empty() {
166 let cond = edge.condition.trim();
168 if !cond.contains('=') && !cond.contains("!=") {
169 let from = &graph.graph[edge_ref.source()].id;
170 let to = &graph.graph[edge_ref.target()].id;
171 issues.push(ValidationIssue {
172 severity: Severity::Error,
173 rule: "condition_syntax".into(),
174 message: format!(
175 "Edge {} -> {} has invalid condition: '{}'",
176 from, to, cond
177 ),
178 node_id: Some(from.clone()),
179 });
180 }
181 }
182 }
183}
184
185fn check_type_known(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
186 let known_types = [
187 "start",
188 "exit",
189 "codergen",
190 "conditional",
191 "wait.human",
192 "parallel",
193 "parallel.fan_in",
194 "tool",
195 "stack.manager_loop",
196 ];
197
198 for idx in graph.graph.node_indices() {
199 let node = &graph.graph[idx];
200 if !known_types.contains(&node.handler_type.as_str()) {
201 issues.push(ValidationIssue {
202 severity: Severity::Warning,
203 rule: "type_known".into(),
204 message: format!(
205 "Node '{}' has unknown handler type '{}'",
206 node.id, node.handler_type
207 ),
208 node_id: Some(node.id.clone()),
209 });
210 }
211 }
212}
213
214fn check_retry_target_exists(_graph: &PipelineGraph, _issues: &mut Vec<ValidationIssue>) {
215 }
217
218fn check_goal_gate_has_retry(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
219 for idx in graph.graph.node_indices() {
220 let node = &graph.graph[idx];
221 if node.goal_gate && node.retry_target.is_none() {
222 issues.push(ValidationIssue {
223 severity: Severity::Warning,
224 rule: "goal_gate_has_retry".into(),
225 message: format!(
226 "Node '{}' has goal_gate=true but no retry_target",
227 node.id
228 ),
229 node_id: Some(node.id.clone()),
230 });
231 }
232 }
233}
234
235fn check_prompt_on_llm_nodes(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
236 for idx in graph.graph.node_indices() {
237 let node = &graph.graph[idx];
238 if node.handler_type == "codergen" && node.prompt.is_empty() {
239 issues.push(ValidationIssue {
240 severity: Severity::Warning,
241 rule: "prompt_on_llm_nodes".into(),
242 message: format!(
243 "LLM node '{}' has no prompt attribute",
244 node.id
245 ),
246 node_id: Some(node.id.clone()),
247 });
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::attractor::dot_parser::parse_dot;
256 use crate::attractor::graph::PipelineGraph;
257
258 #[test]
259 fn test_valid_pipeline() {
260 let input = r#"
261 digraph test {
262 start [shape=Mdiamond]
263 task [shape=box, prompt="Do something"]
264 finish [shape=Msquare]
265 start -> task -> finish
266 }
267 "#;
268 let dot = parse_dot(input).unwrap();
269 let graph = PipelineGraph::from_dot(&dot).unwrap();
270 let issues = validate(&graph);
271
272 let errors: Vec<_> = issues.iter().filter(|i| i.severity == Severity::Error).collect();
273 assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
274 }
275
276 #[test]
277 fn test_unreachable_node() {
278 let input = r#"
279 digraph test {
280 start [shape=Mdiamond]
281 task [shape=box, prompt="Do it"]
282 orphan [shape=box, prompt="Never reached"]
283 finish [shape=Msquare]
284 start -> task -> finish
285 }
286 "#;
287 let dot = parse_dot(input).unwrap();
288 let graph = PipelineGraph::from_dot(&dot).unwrap();
289 let issues = validate(&graph);
290
291 let reachability_errors: Vec<_> = issues
292 .iter()
293 .filter(|i| i.rule == "reachability")
294 .collect();
295 assert_eq!(reachability_errors.len(), 1);
296 assert!(reachability_errors[0].message.contains("orphan"));
297 }
298
299 #[test]
300 fn test_missing_prompt_warning() {
301 let input = r#"
302 digraph test {
303 start [shape=Mdiamond]
304 task [shape=box]
305 finish [shape=Msquare]
306 start -> task -> finish
307 }
308 "#;
309 let dot = parse_dot(input).unwrap();
310 let graph = PipelineGraph::from_dot(&dot).unwrap();
311 let issues = validate(&graph);
312
313 let warnings: Vec<_> = issues
314 .iter()
315 .filter(|i| i.rule == "prompt_on_llm_nodes")
316 .collect();
317 assert_eq!(warnings.len(), 1);
318 }
319
320 #[test]
321 fn test_goal_gate_without_retry_warning() {
322 let input = r#"
323 digraph test {
324 start [shape=Mdiamond]
325 task [shape=box, prompt="Do it"]
326 gate [shape=Msquare, goal_gate=true]
327 start -> task -> gate
328 }
329 "#;
330 let dot = parse_dot(input).unwrap();
331 let graph = PipelineGraph::from_dot(&dot).unwrap();
332 let issues = validate(&graph);
333
334 let warnings: Vec<_> = issues
335 .iter()
336 .filter(|i| i.rule == "goal_gate_has_retry")
337 .collect();
338 assert_eq!(warnings.len(), 1);
339 }
340}