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!("Edge {} -> {} has invalid condition: '{}'", from, to, cond),
175                    node_id: Some(from.clone()),
176                });
177            }
178        }
179    }
180}
181
182fn check_type_known(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
183    let known_types = [
184        "start",
185        "exit",
186        "codergen",
187        "conditional",
188        "wait.human",
189        "parallel",
190        "parallel.fan_in",
191        "tool",
192        "stack.manager_loop",
193    ];
194
195    for idx in graph.graph.node_indices() {
196        let node = &graph.graph[idx];
197        if !known_types.contains(&node.handler_type.as_str()) {
198            issues.push(ValidationIssue {
199                severity: Severity::Warning,
200                rule: "type_known".into(),
201                message: format!(
202                    "Node '{}' has unknown handler type '{}'",
203                    node.id, node.handler_type
204                ),
205                node_id: Some(node.id.clone()),
206            });
207        }
208    }
209}
210
211fn check_retry_target_exists(_graph: &PipelineGraph, _issues: &mut Vec<ValidationIssue>) {
212    // Already covered in check_edge_targets, but we can add warning-level checks here
213}
214
215fn check_goal_gate_has_retry(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
216    for idx in graph.graph.node_indices() {
217        let node = &graph.graph[idx];
218        if node.goal_gate && node.retry_target.is_none() {
219            issues.push(ValidationIssue {
220                severity: Severity::Warning,
221                rule: "goal_gate_has_retry".into(),
222                message: format!("Node '{}' has goal_gate=true but no retry_target", node.id),
223                node_id: Some(node.id.clone()),
224            });
225        }
226    }
227}
228
229fn check_prompt_on_llm_nodes(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
230    for idx in graph.graph.node_indices() {
231        let node = &graph.graph[idx];
232        if node.handler_type == "codergen" && node.prompt.is_empty() {
233            issues.push(ValidationIssue {
234                severity: Severity::Warning,
235                rule: "prompt_on_llm_nodes".into(),
236                message: format!("LLM node '{}' has no prompt attribute", node.id),
237                node_id: Some(node.id.clone()),
238            });
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::attractor::dot_parser::parse_dot;
247    use crate::attractor::graph::PipelineGraph;
248
249    #[test]
250    fn test_valid_pipeline() {
251        let input = r#"
252        digraph test {
253            start [shape=Mdiamond]
254            task [shape=box, prompt="Do something"]
255            finish [shape=Msquare]
256            start -> task -> finish
257        }
258        "#;
259        let dot = parse_dot(input).unwrap();
260        let graph = PipelineGraph::from_dot(&dot).unwrap();
261        let issues = validate(&graph);
262
263        let errors: Vec<_> = issues
264            .iter()
265            .filter(|i| i.severity == Severity::Error)
266            .collect();
267        assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
268    }
269
270    #[test]
271    fn test_unreachable_node() {
272        let input = r#"
273        digraph test {
274            start [shape=Mdiamond]
275            task [shape=box, prompt="Do it"]
276            orphan [shape=box, prompt="Never reached"]
277            finish [shape=Msquare]
278            start -> task -> finish
279        }
280        "#;
281        let dot = parse_dot(input).unwrap();
282        let graph = PipelineGraph::from_dot(&dot).unwrap();
283        let issues = validate(&graph);
284
285        let reachability_errors: Vec<_> =
286            issues.iter().filter(|i| i.rule == "reachability").collect();
287        assert_eq!(reachability_errors.len(), 1);
288        assert!(reachability_errors[0].message.contains("orphan"));
289    }
290
291    #[test]
292    fn test_missing_prompt_warning() {
293        let input = r#"
294        digraph test {
295            start [shape=Mdiamond]
296            task [shape=box]
297            finish [shape=Msquare]
298            start -> task -> finish
299        }
300        "#;
301        let dot = parse_dot(input).unwrap();
302        let graph = PipelineGraph::from_dot(&dot).unwrap();
303        let issues = validate(&graph);
304
305        let warnings: Vec<_> = issues
306            .iter()
307            .filter(|i| i.rule == "prompt_on_llm_nodes")
308            .collect();
309        assert_eq!(warnings.len(), 1);
310    }
311
312    #[test]
313    fn test_goal_gate_without_retry_warning() {
314        let input = r#"
315        digraph test {
316            start [shape=Mdiamond]
317            task [shape=box, prompt="Do it"]
318            gate [shape=Msquare, goal_gate=true]
319            start -> task -> gate
320        }
321        "#;
322        let dot = parse_dot(input).unwrap();
323        let graph = PipelineGraph::from_dot(&dot).unwrap();
324        let issues = validate(&graph);
325
326        let warnings: Vec<_> = issues
327            .iter()
328            .filter(|i| i.rule == "goal_gate_has_retry")
329            .collect();
330        assert_eq!(warnings.len(), 1);
331    }
332}