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(|p| p.name.name.clone()).collect();
29    for name in BUILTIN_VARS {
30        scope.insert(name.to_string());
31    }
32    let mut kinds: HashMap<String, &'static str> = HashMap::new();
33    walk_stmts(&flow.body, &mut scope, &mut kinds, tools, &mut errors);
34    if errors.is_empty() {
35        Ok(())
36    } else {
37        Err(errors)
38    }
39}
40
41const BUILTIN_VARS: &[&str] = &[
42    "session",
43    "fs",
44    "bash",
45    "term",
46    "task",
47    "web",
48    "hunk",
49    "git",
50    "test",
51    "memory",
52    "plan",
53    "form",
54    "help",
55    "preview",
56    "session_tool",
57    "sleep",
58    "watch",
59    "watcher",
60];
61
62fn infer_node_kind(value: &Expr) -> Option<&'static str> {
63    match value {
64        Expr::Node(Node::ToolCall { path, .. })
65            if path.len() == 2 && path[0].name == "llm" && path[1].name == "call" =>
66        {
67            Some("llm")
68        }
69        Expr::Node(Node::ToolCall { path, .. }) => {
70            let _ = path;
71            Some("tool_call")
72        }
73        Expr::Node(Node::Fanout { .. }) => Some("fanout"),
74        Expr::Node(Node::UserConfirm { .. }) => Some("user_confirm"),
75        Expr::Node(Node::Subflow { .. }) => Some("subflow"),
76        Expr::Node(Node::FixUntilTestPasses { .. }) => Some("fix_until"),
77        Expr::Node(Node::Message { .. }) => Some("message"),
78        _ => None,
79    }
80}
81
82fn watch_event_expected_kinds(event: &WatchEvent) -> &'static [&'static str] {
83    match event {
84        WatchEvent::Token { .. } => &["llm"],
85        WatchEvent::TokensConsumed { .. } => &["llm"],
86        WatchEvent::Elapsed { .. } => &["llm", "tool_call", "subflow", "fix_until"],
87    }
88}
89
90fn walk_stmts(
91    stmts: &[Stmt],
92    scope: &mut HashSet<String>,
93    kinds: &mut HashMap<String, &'static str>,
94    tools: &ToolRegistry,
95    errors: &mut Vec<ValidationError>,
96) {
97    for stmt in stmts {
98        match stmt {
99            Stmt::Bind { name, value } => {
100                walk_expr(value, scope, tools, errors);
101                let bound = name.bound_names();
102                if let Some(k) = infer_node_kind(value)
103                    && let Some(single) = name.as_single_ident()
104                {
105                    kinds.insert(single.name.clone(), k);
106                }
107                for n in bound {
108                    scope.insert(n);
109                }
110            }
111            Stmt::When { cond, body } => {
112                walk_expr(cond, scope, tools, errors);
113                walk_stmts(body, scope, kinds, tools, errors);
114            }
115            Stmt::Return { value } => walk_expr(value, scope, tools, errors),
116            Stmt::Expr(e) => walk_expr(e, scope, tools, errors),
117            Stmt::Watch(w) => {
118                if !scope.contains(&w.target.name) {
119                    errors.push(ValidationError::UndefinedVar(w.target.name.clone()));
120                    continue;
121                }
122                let Some(target_kind) = kinds.get(&w.target.name).copied() else {
123                    continue;
124                };
125                for on in &w.on_blocks {
126                    let expected = watch_event_expected_kinds(&on.event);
127                    if !expected.contains(&target_kind) {
128                        errors.push(ValidationError::WatchEventMismatch {
129                            target: w.target.name.clone(),
130                            event: watch_event_label(&on.event).into(),
131                            target_kind: target_kind.into(),
132                            expected: expected.join(", "),
133                        });
134                    }
135                }
136            }
137            Stmt::Loop { body } => {
138                walk_stmts(body, scope, kinds, tools, errors);
139            }
140            Stmt::Break => {}
141            Stmt::Continue => {}
142        }
143    }
144}
145
146fn watch_event_label(event: &WatchEvent) -> &'static str {
147    match event {
148        WatchEvent::Token { .. } => "token",
149        WatchEvent::TokensConsumed { .. } => "tokens_consumed",
150        WatchEvent::Elapsed { .. } => "elapsed",
151    }
152}
153
154fn walk_expr(
155    expr: &Expr,
156    scope: &HashSet<String>,
157    tools: &ToolRegistry,
158    errors: &mut Vec<ValidationError>,
159) {
160    match expr {
161        Expr::Literal(_) | Expr::FileRef(_) => {}
162        Expr::Ident(id) => {
163            if !scope.contains(&id.name) {
164                errors.push(ValidationError::UndefinedVar(id.name.clone()));
165            }
166        }
167        Expr::Member { base, .. } => walk_expr(base, scope, tools, errors),
168        Expr::Binary { left, right, .. } => {
169            walk_expr(left, scope, tools, errors);
170            walk_expr(right, scope, tools, errors);
171        }
172        Expr::Unary { operand, .. } => walk_expr(operand, scope, tools, errors),
173        Expr::List(items) => {
174            for item in items {
175                walk_expr(item, scope, tools, errors);
176            }
177        }
178        Expr::Struct(fields) => {
179            for (_, v) in fields {
180                walk_expr(v, scope, tools, errors);
181            }
182        }
183        Expr::Node(node) => walk_node(node, scope, tools, errors),
184        Expr::Call { args, .. } => {
185            for a in args {
186                walk_expr(a, scope, tools, errors);
187            }
188        }
189        Expr::Pipe { lhs, rhs } => {
190            walk_expr(lhs, scope, tools, errors);
191            walk_expr(rhs, scope, tools, errors);
192        }
193        Expr::Lambda { params, body } => {
194            let mut child_scope = scope.clone();
195            for p in params {
196                child_scope.insert(p.name.clone());
197            }
198            walk_expr(body, &child_scope, tools, errors);
199        }
200        Expr::Annotated { expr, .. } => {
201            // Type names and type list expressions in annotation position
202            // are not variable references
203            match expr.as_ref() {
204                Expr::Ident(id) if crate::eval::is_type_name(&id.name) => {}
205                Expr::List(inner) if inner.len() == 1 => {
206                    if let Expr::Ident(id) = &inner[0] {
207                        if crate::eval::is_type_name(&id.name) {
208                            return;
209                        }
210                    }
211                    walk_expr(expr, scope, tools, errors);
212                }
213                _ => walk_expr(expr, scope, tools, errors),
214            }
215        }
216    }
217}
218
219fn walk_node(
220    node: &Node,
221    scope: &HashSet<String>,
222    tools: &ToolRegistry,
223    errors: &mut Vec<ValidationError>,
224) {
225    match node {
226        Node::ToolCall { path, args } => {
227            let name = path
228                .iter()
229                .map(|i| i.name.as_str())
230                .collect::<Vec<_>>()
231                .join(".");
232            // list.* combinators are intercepted at eval time, not registered as tools
233            let is_combinator = name.starts_with("list.");
234            if !is_combinator && !tools.has(&name) {
235                errors.push(ValidationError::UndefinedTool(name));
236            }
237            for arg in args {
238                match arg {
239                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
240                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
241                }
242            }
243        }
244        Node::DynamicFanout { source, lambda, .. } => {
245            walk_expr(source, scope, tools, errors);
246            walk_expr(lambda, scope, tools, errors);
247        }
248        Node::Fanout { items, .. } => {
249            for item in items {
250                walk_expr(item, scope, tools, errors);
251            }
252        }
253        Node::UserConfirm { msg } => walk_expr(msg, scope, tools, errors),
254        Node::Subflow { args, .. } => {
255            for arg in args {
256                match arg {
257                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
258                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
259                }
260            }
261        }
262        Node::FixUntilTestPasses { kwargs } => {
263            for (_, v) in kwargs {
264                walk_expr(v, scope, tools, errors);
265            }
266        }
267        Node::Message { args, .. } => {
268            for arg in args {
269                match arg {
270                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
271                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
272                }
273            }
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::tools;
282    use atman_dsl::parse::parse_file;
283
284    fn registry_with_fs() -> ToolRegistry {
285        let reg = ToolRegistry::new();
286        tools::register_tier_zero(&reg);
287        reg
288    }
289
290    #[test]
291    fn valid_flow_using_declared_var_and_registered_tool() {
292        let src = r#"flow t(p: path) -> string {
293    body = fs.read(p)
294    return body
295}
296"#;
297        let file = parse_file(src).unwrap();
298        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
299    }
300
301    #[test]
302    fn undefined_var_is_reported() {
303        let src = r#"flow t() -> Int {
304    return missing
305}
306"#;
307        let file = parse_file(src).unwrap();
308        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
309        assert!(
310            errs.iter()
311                .any(|e| matches!(e, ValidationError::UndefinedVar(name) if name == "missing"))
312        );
313    }
314
315    #[test]
316    fn undefined_tool_is_reported() {
317        let src = r#"flow t(p: path) -> Int {
318    return fs.nope(p)
319}
320"#;
321        let file = parse_file(src).unwrap();
322        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
323        assert!(
324            errs.iter()
325                .any(|e| matches!(e, ValidationError::UndefinedTool(name) if name == "fs.nope"))
326        );
327    }
328
329    #[test]
330    fn errors_accumulate_not_fail_fast() {
331        let src = r#"flow t() -> Int {
332    x = nope1
333    y = nope2.tool()
334    return x
335}
336"#;
337        let file = parse_file(src).unwrap();
338        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
339        assert!(errs.len() >= 2);
340    }
341
342    #[test]
343    fn watch_on_llm_bind_with_token_event_is_ok() {
344        let src = r#"flow r() -> string {
345    x = llm.call(model: "m", prompt: "hi")
346    watch x { on token(match: "bad") { abort("no") } }
347    return x
348}
349"#;
350        let file = parse_file(src).unwrap();
351        validate(&file.flows[0], &registry_with_fs()).expect("token on llm is fine");
352    }
353
354    #[test]
355    fn watch_token_on_non_llm_bind_is_rejected() {
356        let src = r#"flow r(p: path) -> string {
357    body = fs.read(p)
358    watch body { on token(match: "bad") { warn() } }
359    return body
360}
361"#;
362        let file = parse_file(src).unwrap();
363        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
364        let mismatch = errs
365            .iter()
366            .find(|e| matches!(e, ValidationError::WatchEventMismatch { .. }))
367            .expect("expected WatchEventMismatch");
368        let msg = mismatch.to_string();
369        assert!(msg.contains("body"), "msg: {msg}");
370        assert!(msg.contains("token"), "msg: {msg}");
371        assert!(msg.contains("llm"), "msg: {msg}");
372    }
373
374    #[test]
375    fn bind_introduces_variable_for_later_stmts() {
376        let src = r#"flow t() -> Int {
377    x = 1
378    return x
379}
380"#;
381        let file = parse_file(src).unwrap();
382        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
383    }
384}