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