Skip to main content

atman_runtime/
validate.rs

1use std::collections::{HashMap, HashSet};
2
3use atman_dsl::ast::{Arg, Expr, FlowDecl, Node, Stmt, WatchEvent};
4
5use crate::tool::ToolRegistry;
6
7#[derive(Debug, thiserror::Error)]
8pub enum ValidationError {
9    #[error("undefined variable `{0}`")]
10    UndefinedVar(String),
11
12    #[error("undefined tool `{0}`")]
13    UndefinedTool(String),
14
15    #[error(
16        "watch on `{target}` uses event `{event}`, but bind is a {target_kind} node — expected one of {expected}"
17    )]
18    WatchEventMismatch {
19        target: String,
20        event: String,
21        target_kind: String,
22        expected: String,
23    },
24}
25
26pub fn validate(flow: &FlowDecl, tools: &ToolRegistry) -> Result<(), Vec<ValidationError>> {
27    let mut errors = Vec::new();
28    let mut scope: HashSet<String> = flow.params.iter().map(|(id, _)| id.name.clone()).collect();
29    let mut kinds: HashMap<String, &'static str> = HashMap::new();
30    walk_stmts(&flow.body, &mut scope, &mut kinds, tools, &mut errors);
31    if errors.is_empty() {
32        Ok(())
33    } else {
34        Err(errors)
35    }
36}
37
38fn infer_node_kind(value: &Expr) -> Option<&'static str> {
39    match value {
40        Expr::Node(Node::Llm { .. }) => Some("llm"),
41        Expr::Node(Node::ToolCall { path, .. }) => {
42            let _ = path;
43            Some("tool_call")
44        }
45        Expr::Node(Node::Fanout { .. }) => Some("fanout"),
46        Expr::Node(Node::UserConfirm { .. }) => Some("user_confirm"),
47        Expr::Node(Node::Subflow { .. }) => Some("subflow"),
48        Expr::Node(Node::FixUntilTestPasses { .. }) => Some("fix_until"),
49        Expr::Node(Node::Message { .. }) => Some("message"),
50        _ => None,
51    }
52}
53
54fn watch_event_expected_kinds(event: &WatchEvent) -> &'static [&'static str] {
55    match event {
56        WatchEvent::Token { .. } => &["llm"],
57        WatchEvent::TokensConsumed { .. } => &["llm"],
58        WatchEvent::Elapsed { .. } => &["llm", "tool_call", "subflow", "fix_until"],
59    }
60}
61
62fn walk_stmts(
63    stmts: &[Stmt],
64    scope: &mut HashSet<String>,
65    kinds: &mut HashMap<String, &'static str>,
66    tools: &ToolRegistry,
67    errors: &mut Vec<ValidationError>,
68) {
69    for stmt in stmts {
70        match stmt {
71            Stmt::Bind { name, value } => {
72                walk_expr(value, scope, tools, errors);
73                let bound = name.bound_names();
74                if let Some(k) = infer_node_kind(value)
75                    && let Some(single) = name.as_single_ident()
76                {
77                    kinds.insert(single.name.clone(), k);
78                }
79                for n in bound {
80                    scope.insert(n);
81                }
82            }
83            Stmt::When { cond, body } => {
84                walk_expr(cond, scope, tools, errors);
85                walk_stmts(body, scope, kinds, tools, errors);
86            }
87            Stmt::Return { value } => walk_expr(value, scope, tools, errors),
88            Stmt::Expr(e) => walk_expr(e, scope, tools, errors),
89            Stmt::Watch(w) => {
90                if !scope.contains(&w.target.name) {
91                    errors.push(ValidationError::UndefinedVar(w.target.name.clone()));
92                    continue;
93                }
94                let Some(target_kind) = kinds.get(&w.target.name).copied() else {
95                    continue;
96                };
97                for on in &w.on_blocks {
98                    let expected = watch_event_expected_kinds(&on.event);
99                    if !expected.contains(&target_kind) {
100                        errors.push(ValidationError::WatchEventMismatch {
101                            target: w.target.name.clone(),
102                            event: watch_event_label(&on.event).into(),
103                            target_kind: target_kind.into(),
104                            expected: expected.join(", "),
105                        });
106                    }
107                }
108            }
109        }
110    }
111}
112
113fn watch_event_label(event: &WatchEvent) -> &'static str {
114    match event {
115        WatchEvent::Token { .. } => "token",
116        WatchEvent::TokensConsumed { .. } => "tokens_consumed",
117        WatchEvent::Elapsed { .. } => "elapsed",
118    }
119}
120
121fn walk_expr(
122    expr: &Expr,
123    scope: &HashSet<String>,
124    tools: &ToolRegistry,
125    errors: &mut Vec<ValidationError>,
126) {
127    match expr {
128        Expr::Literal(_) | Expr::FileRef(_) => {}
129        Expr::Ident(id) => {
130            if !scope.contains(&id.name) {
131                errors.push(ValidationError::UndefinedVar(id.name.clone()));
132            }
133        }
134        Expr::Member { base, .. } => walk_expr(base, scope, tools, errors),
135        Expr::Binary { left, right, .. } => {
136            walk_expr(left, scope, tools, errors);
137            walk_expr(right, scope, tools, errors);
138        }
139        Expr::Unary { operand, .. } => walk_expr(operand, scope, tools, errors),
140        Expr::List(items) => {
141            for item in items {
142                walk_expr(item, scope, tools, errors);
143            }
144        }
145        Expr::Struct(fields) => {
146            for (_, v) in fields {
147                walk_expr(v, scope, tools, errors);
148            }
149        }
150        Expr::Node(node) => walk_node(node, scope, tools, errors),
151        Expr::Call { args, .. } => {
152            for a in args {
153                walk_expr(a, scope, tools, errors);
154            }
155        }
156        Expr::Pipe { lhs, rhs } => {
157            walk_expr(lhs, scope, tools, errors);
158            walk_expr(rhs, scope, tools, errors);
159        }
160    }
161}
162
163fn walk_node(
164    node: &Node,
165    scope: &HashSet<String>,
166    tools: &ToolRegistry,
167    errors: &mut Vec<ValidationError>,
168) {
169    match node {
170        Node::ToolCall { path, args } => {
171            let name = path
172                .iter()
173                .map(|i| i.name.as_str())
174                .collect::<Vec<_>>()
175                .join(".");
176            if !tools.has(&name) {
177                errors.push(ValidationError::UndefinedTool(name));
178            }
179            for arg in args {
180                match arg {
181                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
182                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
183                }
184            }
185        }
186        Node::Llm { kwargs } => {
187            for (_, v) in kwargs {
188                walk_expr(v, scope, tools, errors);
189            }
190        }
191        Node::Fanout { items, .. } => {
192            for item in items {
193                walk_expr(item, scope, tools, errors);
194            }
195        }
196        Node::UserConfirm { msg } => walk_expr(msg, scope, tools, errors),
197        Node::Subflow { args, .. } => {
198            for arg in args {
199                match arg {
200                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
201                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
202                }
203            }
204        }
205        Node::FixUntilTestPasses { kwargs } => {
206            for (_, v) in kwargs {
207                walk_expr(v, scope, tools, errors);
208            }
209        }
210        Node::Message { args, .. } => {
211            for arg in args {
212                match arg {
213                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
214                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
215                }
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::tools;
225    use atman_dsl::parse::parse_file;
226
227    fn registry_with_fs() -> ToolRegistry {
228        let mut reg = ToolRegistry::new();
229        tools::register_tier_zero(&mut reg);
230        reg
231    }
232
233    #[test]
234    fn valid_flow_using_declared_var_and_registered_tool() {
235        let src = r#"flow t(p: path) -> string {
236    body = fs.read(p)
237    return body
238}
239"#;
240        let file = parse_file(src).unwrap();
241        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
242    }
243
244    #[test]
245    fn undefined_var_is_reported() {
246        let src = r#"flow t() -> Int {
247    return missing
248}
249"#;
250        let file = parse_file(src).unwrap();
251        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
252        assert!(
253            errs.iter()
254                .any(|e| matches!(e, ValidationError::UndefinedVar(name) if name == "missing"))
255        );
256    }
257
258    #[test]
259    fn undefined_tool_is_reported() {
260        let src = r#"flow t(p: path) -> Int {
261    return fs.nope(p)
262}
263"#;
264        let file = parse_file(src).unwrap();
265        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
266        assert!(
267            errs.iter()
268                .any(|e| matches!(e, ValidationError::UndefinedTool(name) if name == "fs.nope"))
269        );
270    }
271
272    #[test]
273    fn errors_accumulate_not_fail_fast() {
274        let src = r#"flow t() -> Int {
275    x = nope1
276    y = nope2.tool()
277    return x
278}
279"#;
280        let file = parse_file(src).unwrap();
281        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
282        assert!(errs.len() >= 2);
283    }
284
285    #[test]
286    fn watch_on_llm_bind_with_token_event_is_ok() {
287        let src = r#"flow r() -> string {
288    x = llm { model: "m", prompt: "hi" }
289    watch x { on token(match: "bad") { abort("no") } }
290    return x
291}
292"#;
293        let file = parse_file(src).unwrap();
294        validate(&file.flows[0], &registry_with_fs()).expect("token on llm is fine");
295    }
296
297    #[test]
298    fn watch_token_on_non_llm_bind_is_rejected() {
299        let src = r#"flow r(p: path) -> string {
300    body = fs.read(p)
301    watch body { on token(match: "bad") { warn() } }
302    return body
303}
304"#;
305        let file = parse_file(src).unwrap();
306        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
307        let mismatch = errs
308            .iter()
309            .find(|e| matches!(e, ValidationError::WatchEventMismatch { .. }))
310            .expect("expected WatchEventMismatch");
311        let msg = mismatch.to_string();
312        assert!(msg.contains("body"), "msg: {msg}");
313        assert!(msg.contains("token"), "msg: {msg}");
314        assert!(msg.contains("llm"), "msg: {msg}");
315    }
316
317    #[test]
318    fn bind_introduces_variable_for_later_stmts() {
319        let src = r#"flow t() -> Int {
320    x = 1
321    return x
322}
323"#;
324        let file = parse_file(src).unwrap();
325        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
326    }
327}