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], ®istry_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], ®istry_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], ®istry_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], ®istry_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], ®istry_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], ®istry_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], ®istry_with_fs()).expect("valid flow");
350 }
351}