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("invocation user_message must reference a declared string parameter")]
16 InvalidInvocationUserMessage,
17
18 #[error(
19 "watch on `{target}` uses event `{event}`, but bind is a {target_kind} node — expected one of {expected}"
20 )]
21 WatchEventMismatch {
22 target: String,
23 event: String,
24 target_kind: String,
25 expected: String,
26 },
27}
28
29pub fn validate(flow: &FlowDecl, tools: &ToolRegistry) -> Result<(), Vec<ValidationError>> {
30 let mut errors = Vec::new();
31 validate_invocation_contract(flow, &mut errors);
32 let mut scope: HashSet<String> = flow.params.iter().map(|p| p.name.name.clone()).collect();
33 for name in BUILTIN_VARS {
34 scope.insert(name.to_string());
35 }
36 let mut kinds: HashMap<String, &'static str> = HashMap::new();
37 walk_stmts(&flow.body, &mut scope, &mut kinds, tools, &mut errors);
38 if errors.is_empty() {
39 Ok(())
40 } else {
41 Err(errors)
42 }
43}
44
45fn validate_invocation_contract(flow: &FlowDecl, errors: &mut Vec<ValidationError>) {
46 let Some((_, value)) = flow.contract.as_ref().and_then(|contract| {
47 contract
48 .blocks
49 .iter()
50 .find(|block| block.name.name == "invocation")
51 .and_then(|block| {
52 block
53 .kwargs
54 .iter()
55 .find(|(name, _)| name.name == "user_message")
56 })
57 }) else {
58 return;
59 };
60 let atman_dsl::ast::Expr::Ident(parameter_name) = value else {
61 errors.push(ValidationError::InvalidInvocationUserMessage);
62 return;
63 };
64 let Some(parameter) = flow
65 .params
66 .iter()
67 .find(|parameter| parameter.name.name == parameter_name.name)
68 else {
69 errors.push(ValidationError::InvalidInvocationUserMessage);
70 return;
71 };
72 let is_string = matches!(
73 ¶meter.ty,
74 atman_dsl::ast::TypeExpr::Named(name) if name.name == "string"
75 );
76 let has_supported_default = parameter.default.as_ref().is_none_or(|default| {
77 matches!(
78 default,
79 atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Str(_))
80 )
81 });
82 if !is_string || !has_supported_default {
83 errors.push(ValidationError::InvalidInvocationUserMessage);
84 }
85}
86
87const BUILTIN_VARS: &[&str] = &[
88 "session",
89 "fs",
90 "bash",
91 "term",
92 "task",
93 "web",
94 "hunk",
95 "git",
96 "test",
97 "memory",
98 "plan",
99 "form",
100 "help",
101 "preview",
102 "session_tool",
103 "sleep",
104 "watch",
105 "watcher",
106];
107
108fn infer_node_kind(value: &Expr) -> Option<&'static str> {
109 match value {
110 Expr::Node(Node::ToolCall { path, .. })
111 if path.len() == 2 && path[0].name == "llm" && path[1].name == "call" =>
112 {
113 Some("llm")
114 }
115 Expr::Node(Node::ToolCall { path, .. }) => {
116 let _ = path;
117 Some("tool_call")
118 }
119 Expr::Node(Node::Fanout { .. }) => Some("fanout"),
120 Expr::Node(Node::UserConfirm { .. }) => Some("user_confirm"),
121 Expr::Node(Node::Subflow { .. }) => Some("subflow"),
122 Expr::Node(Node::FixUntilTestPasses { .. }) => Some("fix_until"),
123 Expr::Node(Node::Message { .. }) => Some("message"),
124 _ => None,
125 }
126}
127
128fn watch_event_expected_kinds(event: &WatchEvent) -> &'static [&'static str] {
129 match event {
130 WatchEvent::Token { .. } => &["llm"],
131 WatchEvent::TokensConsumed { .. } => &["llm"],
132 WatchEvent::Elapsed { .. } => &["llm", "tool_call", "subflow", "fix_until"],
133 }
134}
135
136fn walk_stmts(
137 stmts: &[Stmt],
138 scope: &mut HashSet<String>,
139 kinds: &mut HashMap<String, &'static str>,
140 tools: &ToolRegistry,
141 errors: &mut Vec<ValidationError>,
142) {
143 for stmt in stmts {
144 match stmt {
145 Stmt::Bind { name, value } => {
146 walk_expr(value, scope, tools, errors);
147 let bound = name.bound_names();
148 if let Some(k) = infer_node_kind(value)
149 && let Some(single) = name.as_single_ident()
150 {
151 kinds.insert(single.name.clone(), k);
152 }
153 for n in bound {
154 scope.insert(n);
155 }
156 }
157 Stmt::When { cond, body } => {
158 walk_expr(cond, scope, tools, errors);
159 walk_stmts(body, scope, kinds, tools, errors);
160 }
161 Stmt::Return { value } => walk_expr(value, scope, tools, errors),
162 Stmt::Expr(e) => walk_expr(e, scope, tools, errors),
163 Stmt::Watch(w) => {
164 if !scope.contains(&w.target.name) {
165 errors.push(ValidationError::UndefinedVar(w.target.name.clone()));
166 continue;
167 }
168 let Some(target_kind) = kinds.get(&w.target.name).copied() else {
169 continue;
170 };
171 for on in &w.on_blocks {
172 let expected = watch_event_expected_kinds(&on.event);
173 if !expected.contains(&target_kind) {
174 errors.push(ValidationError::WatchEventMismatch {
175 target: w.target.name.clone(),
176 event: watch_event_label(&on.event).into(),
177 target_kind: target_kind.into(),
178 expected: expected.join(", "),
179 });
180 }
181 }
182 }
183 Stmt::Loop { body } => {
184 walk_stmts(body, scope, kinds, tools, errors);
185 }
186 Stmt::Break => {}
187 Stmt::Continue => {}
188 }
189 }
190}
191
192fn watch_event_label(event: &WatchEvent) -> &'static str {
193 match event {
194 WatchEvent::Token { .. } => "token",
195 WatchEvent::TokensConsumed { .. } => "tokens_consumed",
196 WatchEvent::Elapsed { .. } => "elapsed",
197 }
198}
199
200fn walk_expr(
201 expr: &Expr,
202 scope: &HashSet<String>,
203 tools: &ToolRegistry,
204 errors: &mut Vec<ValidationError>,
205) {
206 match expr {
207 Expr::Literal(_) | Expr::FileRef(_) => {}
208 Expr::Ident(id) => {
209 if !scope.contains(&id.name) {
210 errors.push(ValidationError::UndefinedVar(id.name.clone()));
211 }
212 }
213 Expr::Member { base, .. } => walk_expr(base, scope, tools, errors),
214 Expr::Binary { left, right, .. } => {
215 walk_expr(left, scope, tools, errors);
216 walk_expr(right, scope, tools, errors);
217 }
218 Expr::Unary { operand, .. } => walk_expr(operand, scope, tools, errors),
219 Expr::List(items) => {
220 for item in items {
221 walk_expr(item, scope, tools, errors);
222 }
223 }
224 Expr::Struct(fields) => {
225 for (_, v) in fields {
226 walk_expr(v, scope, tools, errors);
227 }
228 }
229 Expr::Node(node) => walk_node(node, scope, tools, errors),
230 Expr::Call { args, .. } => {
231 for a in args {
232 walk_expr(a, scope, tools, errors);
233 }
234 }
235 Expr::Pipe { lhs, rhs } => {
236 walk_expr(lhs, scope, tools, errors);
237 walk_expr(rhs, scope, tools, errors);
238 }
239 Expr::Lambda { params, body } => {
240 let mut child_scope = scope.clone();
241 for p in params {
242 child_scope.insert(p.name.clone());
243 }
244 walk_expr(body, &child_scope, tools, errors);
245 }
246 Expr::Annotated { expr, .. } => {
247 match expr.as_ref() {
250 Expr::Ident(id) if crate::eval::is_type_name(&id.name) => {}
251 Expr::List(inner) if inner.len() == 1 => {
252 if let Expr::Ident(id) = &inner[0] {
253 if crate::eval::is_type_name(&id.name) {
254 return;
255 }
256 }
257 walk_expr(expr, scope, tools, errors);
258 }
259 _ => walk_expr(expr, scope, tools, errors),
260 }
261 }
262 }
263}
264
265fn walk_node(
266 node: &Node,
267 scope: &HashSet<String>,
268 tools: &ToolRegistry,
269 errors: &mut Vec<ValidationError>,
270) {
271 match node {
272 Node::ToolCall { path, args } => {
273 let name = path
274 .iter()
275 .map(|i| i.name.as_str())
276 .collect::<Vec<_>>()
277 .join(".");
278 let is_intrinsic = crate::eval::is_evaluator_intrinsic(&name);
280 if !is_intrinsic && !tools.has(&name) {
281 errors.push(ValidationError::UndefinedTool(name));
282 }
283 for arg in args {
284 match arg {
285 Arg::Positional(e) => walk_expr(e, scope, tools, errors),
286 Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
287 }
288 }
289 }
290 Node::DynamicFanout { source, lambda, .. } => {
291 walk_expr(source, scope, tools, errors);
292 walk_expr(lambda, scope, tools, errors);
293 }
294 Node::Fanout { items, .. } => {
295 for item in items {
296 walk_expr(item, scope, tools, errors);
297 }
298 }
299 Node::UserConfirm { msg } => walk_expr(msg, scope, tools, errors),
300 Node::Subflow { args, .. } => {
301 for arg in args {
302 match arg {
303 Arg::Positional(e) => walk_expr(e, scope, tools, errors),
304 Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
305 }
306 }
307 }
308 Node::FixUntilTestPasses { kwargs } => {
309 for (_, v) in kwargs {
310 walk_expr(v, scope, tools, errors);
311 }
312 }
313 Node::Message { args, .. } => {
314 for arg in args {
315 match arg {
316 Arg::Positional(e) => walk_expr(e, scope, tools, errors),
317 Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
318 }
319 }
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::tools;
328 use atman_dsl::parse::parse_file;
329
330 fn registry_with_fs() -> ToolRegistry {
331 let reg = ToolRegistry::new();
332 tools::register_tier_zero(®);
333 reg
334 }
335
336 #[test]
337 fn valid_flow_using_declared_var_and_registered_tool() {
338 let src = r#"flow t(p: path) -> string {
339 body = fs.read(p)
340 return body
341}
342"#;
343 let file = parse_file(src).unwrap();
344 validate(&file.flows[0], ®istry_with_fs()).expect("valid flow");
345 }
346
347 #[test]
348 fn invocation_user_message_requires_a_declared_string_parameter() {
349 for source in [
350 r#"flow t(count: int) -> int {
351 contract { invocation { user_message: count } }
352 return count
353}"#,
354 r#"flow t(prompt: string) -> string {
355 contract { invocation { user_message: "prompt" } }
356 return prompt
357}"#,
358 r#"flow t(prefix: string, prompt: string = prefix) -> string {
359 contract { invocation { user_message: prompt } }
360 return prompt
361}"#,
362 ] {
363 let file = parse_file(source).unwrap();
364 let errors = validate(&file.flows[0], ®istry_with_fs()).unwrap_err();
365 assert!(
366 errors
367 .iter()
368 .any(|error| matches!(error, ValidationError::InvalidInvocationUserMessage))
369 );
370 }
371 }
372
373 #[test]
374 fn undefined_var_is_reported() {
375 let src = r#"flow t() -> Int {
376 return missing
377}
378"#;
379 let file = parse_file(src).unwrap();
380 let errs = validate(&file.flows[0], ®istry_with_fs()).unwrap_err();
381 assert!(
382 errs.iter()
383 .any(|e| matches!(e, ValidationError::UndefinedVar(name) if name == "missing"))
384 );
385 }
386
387 #[test]
388 fn undefined_tool_is_reported() {
389 let src = r#"flow t(p: path) -> Int {
390 return fs.nope(p)
391}
392"#;
393 let file = parse_file(src).unwrap();
394 let errs = validate(&file.flows[0], ®istry_with_fs()).unwrap_err();
395 assert!(
396 errs.iter()
397 .any(|e| matches!(e, ValidationError::UndefinedTool(name) if name == "fs.nope"))
398 );
399 }
400
401 #[test]
402 fn errors_accumulate_not_fail_fast() {
403 let src = r#"flow t() -> Int {
404 x = nope1
405 y = nope2.tool()
406 return x
407}
408"#;
409 let file = parse_file(src).unwrap();
410 let errs = validate(&file.flows[0], ®istry_with_fs()).unwrap_err();
411 assert!(errs.len() >= 2);
412 }
413
414 #[test]
415 fn watch_on_llm_bind_with_token_event_is_ok() {
416 let src = r#"flow r() -> string {
417 x = llm.call(model: "m", prompt: "hi")
418 watch x { on token(match: "bad") { abort("no") } }
419 return x
420}
421"#;
422 let file = parse_file(src).unwrap();
423 validate(&file.flows[0], ®istry_with_fs()).expect("token on llm is fine");
424 }
425
426 #[test]
427 fn watch_token_on_non_llm_bind_is_rejected() {
428 let src = r#"flow r(p: path) -> string {
429 body = fs.read(p)
430 watch body { on token(match: "bad") { warn() } }
431 return body
432}
433"#;
434 let file = parse_file(src).unwrap();
435 let errs = validate(&file.flows[0], ®istry_with_fs()).unwrap_err();
436 let mismatch = errs
437 .iter()
438 .find(|e| matches!(e, ValidationError::WatchEventMismatch { .. }))
439 .expect("expected WatchEventMismatch");
440 let msg = mismatch.to_string();
441 assert!(msg.contains("body"), "msg: {msg}");
442 assert!(msg.contains("token"), "msg: {msg}");
443 assert!(msg.contains("llm"), "msg: {msg}");
444 }
445
446 #[test]
447 fn bind_introduces_variable_for_later_stmts() {
448 let src = r#"flow t() -> Int {
449 x = 1
450 return x
451}
452"#;
453 let file = parse_file(src).unwrap();
454 validate(&file.flows[0], ®istry_with_fs()).expect("valid flow");
455 }
456
457 #[test]
458 fn invocation_env_is_a_valid_intrinsic_without_a_registered_tool() {
459 let file = parse_file(
460 r#"flow t() -> string {
461 return env("effort")
462}"#,
463 )
464 .unwrap();
465
466 validate(&file.flows[0], &ToolRegistry::new()).expect("env is evaluator-owned");
467 }
468}