Skip to main content

scud/attractor/
validator.rs

1//! Graph validation with lint rules from spec Section 7.2.
2
3use super::graph::PipelineGraph;
4
5/// Severity of a validation issue.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Severity {
8    Error,
9    Warning,
10}
11
12/// A single validation issue.
13#[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
21/// Validate a pipeline graph and return all issues found.
22pub 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
40/// Check that validation passed (no errors).
41pub 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    // All edge targets exist as nodes (this is guaranteed by petgraph construction,
99    // but we check for edges referencing non-existent node IDs in retry_target)
100    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            // Basic syntax check: must contain = or !=
167            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    // Already covered in check_edge_targets, but we can add warning-level checks here
216}
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}