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