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