Skip to main content

atman_runtime/
flow_lint.rs

1use std::collections::HashSet;
2
3use atman_dsl::ast::{Arg, Expr, File, FlowDecl, Node, Stmt};
4
5const MANY_POSITIONAL_THRESHOLD: usize = 4;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct LintHit {
9    pub flow: String,
10    pub rule: LintRule,
11    pub message: String,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LintRule {
16    UnusedFlowParam,
17    ManyPositional,
18}
19
20impl LintRule {
21    pub fn slug(&self) -> &'static str {
22        match self {
23            LintRule::UnusedFlowParam => "unused-flow-param",
24            LintRule::ManyPositional => "many-positional",
25        }
26    }
27}
28
29pub fn lint_file(file: &File) -> Vec<LintHit> {
30    let mut hits = Vec::new();
31    for flow in &file.flows {
32        lint_flow(flow, &mut hits);
33    }
34    hits
35}
36
37fn lint_flow(flow: &FlowDecl, hits: &mut Vec<LintHit>) {
38    let mut refs = HashSet::new();
39    collect_ident_refs_stmts(&flow.body, &mut refs);
40    for p in &flow.params {
41        if !refs.contains(&p.name.name) {
42            hits.push(LintHit {
43                flow: flow.name.name.clone(),
44                rule: LintRule::UnusedFlowParam,
45                message: format!(
46                    "parameter `{}` is declared but never referenced",
47                    p.name.name
48                ),
49            });
50        }
51    }
52    walk_stmts_for_nodes(&flow.body, &flow.name.name, hits);
53}
54
55fn collect_ident_refs_stmts(stmts: &[Stmt], refs: &mut HashSet<String>) {
56    for stmt in stmts {
57        match stmt {
58            Stmt::Bind { value, .. } => collect_ident_refs_expr(value, refs),
59            Stmt::When { cond, body } => {
60                collect_ident_refs_expr(cond, refs);
61                collect_ident_refs_stmts(body, refs);
62            }
63            Stmt::Return { value } => collect_ident_refs_expr(value, refs),
64            Stmt::Expr(e) => collect_ident_refs_expr(e, refs),
65            Stmt::Watch(w) => {
66                refs.insert(w.target.name.clone());
67            }
68            Stmt::Loop { body } => collect_ident_refs_stmts(body, refs),
69            Stmt::Break => {}
70            Stmt::Continue => {}
71        }
72    }
73}
74
75fn collect_ident_refs_expr(expr: &Expr, refs: &mut HashSet<String>) {
76    match expr {
77        Expr::Literal(_) | Expr::FileRef(_) => {}
78        Expr::Ident(id) => {
79            refs.insert(id.name.clone());
80        }
81        Expr::Member { base, .. } => collect_ident_refs_expr(base, refs),
82        Expr::Binary { left, right, .. } => {
83            collect_ident_refs_expr(left, refs);
84            collect_ident_refs_expr(right, refs);
85        }
86        Expr::Unary { operand, .. } => collect_ident_refs_expr(operand, refs),
87        Expr::List(items) => {
88            for it in items {
89                collect_ident_refs_expr(it, refs);
90            }
91        }
92        Expr::Struct(fields) => {
93            for (_, v) in fields {
94                collect_ident_refs_expr(v, refs);
95            }
96        }
97        Expr::Node(node) => collect_ident_refs_node(node, refs),
98        Expr::Call { args, .. } => {
99            for a in args {
100                collect_ident_refs_expr(a, refs);
101            }
102        }
103        Expr::Pipe { lhs, rhs } => {
104            collect_ident_refs_expr(lhs, refs);
105            collect_ident_refs_expr(rhs, refs);
106        }
107        Expr::Annotated { expr, .. } => collect_ident_refs_expr(expr, refs),
108        Expr::Lambda { params, body } => {
109            for p in params {
110                refs.insert(p.name.clone());
111            }
112            collect_ident_refs_expr(body, refs);
113        }
114    }
115}
116
117fn collect_ident_refs_node(node: &Node, refs: &mut HashSet<String>) {
118    match node {
119        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
120            for a in args {
121                match a {
122                    Arg::Positional(e) => collect_ident_refs_expr(e, refs),
123                    Arg::Named { value, .. } => collect_ident_refs_expr(value, refs),
124                }
125            }
126        }
127        Node::FixUntilTestPasses { kwargs } => {
128            for (_, v) in kwargs {
129                collect_ident_refs_expr(v, refs);
130            }
131        }
132        Node::DynamicFanout { source, lambda, .. } => {
133            collect_ident_refs_expr(source, refs);
134            collect_ident_refs_expr(lambda, refs);
135        }
136        Node::Fanout { items, .. } => {
137            for it in items {
138                collect_ident_refs_expr(it, refs);
139            }
140        }
141        Node::UserConfirm { msg } => collect_ident_refs_expr(msg, refs),
142    }
143}
144
145fn walk_stmts_for_nodes(stmts: &[Stmt], flow_name: &str, hits: &mut Vec<LintHit>) {
146    for stmt in stmts {
147        match stmt {
148            Stmt::Bind { value, .. } | Stmt::Return { value } | Stmt::Expr(value) => {
149                walk_expr_for_nodes(value, flow_name, hits);
150            }
151            Stmt::When { cond, body } => {
152                walk_expr_for_nodes(cond, flow_name, hits);
153                walk_stmts_for_nodes(body, flow_name, hits);
154            }
155            Stmt::Watch(_) => {}
156            Stmt::Loop { body } => {
157                walk_stmts_for_nodes(body, flow_name, hits);
158            }
159            Stmt::Break => {}
160            Stmt::Continue => {}
161        }
162    }
163}
164
165fn walk_expr_for_nodes(expr: &Expr, flow_name: &str, hits: &mut Vec<LintHit>) {
166    match expr {
167        Expr::Literal(_) | Expr::FileRef(_) | Expr::Ident(_) => {}
168        Expr::Member { base, .. } => walk_expr_for_nodes(base, flow_name, hits),
169        Expr::Binary { left, right, .. } => {
170            walk_expr_for_nodes(left, flow_name, hits);
171            walk_expr_for_nodes(right, flow_name, hits);
172        }
173        Expr::Unary { operand, .. } => walk_expr_for_nodes(operand, flow_name, hits),
174        Expr::List(items) => {
175            for it in items {
176                walk_expr_for_nodes(it, flow_name, hits);
177            }
178        }
179        Expr::Struct(fields) => {
180            for (_, v) in fields {
181                walk_expr_for_nodes(v, flow_name, hits);
182            }
183        }
184        Expr::Call { args, .. } => {
185            for a in args {
186                walk_expr_for_nodes(a, flow_name, hits);
187            }
188        }
189        Expr::Pipe { lhs, rhs } => {
190            walk_expr_for_nodes(lhs, flow_name, hits);
191            walk_expr_for_nodes(rhs, flow_name, hits);
192        }
193        Expr::Node(node) => {
194            check_node(node, flow_name, hits);
195            for e in child_exprs(node) {
196                walk_expr_for_nodes(e, flow_name, hits);
197            }
198        }
199        Expr::Annotated { expr, .. } => walk_expr_for_nodes(expr, flow_name, hits),
200        Expr::Lambda { body, .. } => walk_expr_for_nodes(body, flow_name, hits),
201    }
202}
203
204fn check_node(node: &Node, flow_name: &str, hits: &mut Vec<LintHit>) {
205    if let Node::ToolCall { path, args } = node {
206        let positional = args
207            .iter()
208            .filter(|a| matches!(a, Arg::Positional(_)))
209            .count();
210        let named = args
211            .iter()
212            .filter(|a| matches!(a, Arg::Named { .. }))
213            .count();
214        if positional >= MANY_POSITIONAL_THRESHOLD && named == 0 {
215            let name = path
216                .iter()
217                .map(|i| i.name.as_str())
218                .collect::<Vec<_>>()
219                .join(".");
220            hits.push(LintHit {
221                flow: flow_name.to_string(),
222                rule: LintRule::ManyPositional,
223                message: format!(
224                    "{name} takes {positional} positional args with no names — prefer named args for readability"
225                ),
226            });
227        }
228    }
229}
230
231fn child_exprs(node: &Node) -> Vec<&Expr> {
232    let mut out: Vec<&Expr> = Vec::new();
233    match node {
234        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
235            for a in args {
236                match a {
237                    Arg::Positional(e) => out.push(e),
238                    Arg::Named { value, .. } => out.push(value),
239                }
240            }
241        }
242        Node::FixUntilTestPasses { kwargs } => {
243            for (_, v) in kwargs {
244                out.push(v);
245            }
246        }
247        Node::DynamicFanout { source, lambda, .. } => {
248            out.push(source);
249            out.push(lambda);
250        }
251        Node::Fanout { items, .. } => {
252            for i in items {
253                out.push(i);
254            }
255        }
256        Node::UserConfirm { msg } => out.push(msg),
257    }
258    out
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use atman_dsl::parse::parse_file;
265
266    fn lint(src: &str) -> Vec<LintHit> {
267        let file = parse_file(src).unwrap_or_else(|e| panic!("parse: {e}"));
268        lint_file(&file)
269    }
270
271    #[test]
272    fn llm_without_fallback_is_intentional_and_clean() {
273        let src = r#"flow t() -> string {
274    return llm.call(model: "mock", prompt: "hi")
275}
276"#;
277        assert!(lint(src).is_empty());
278    }
279
280    #[test]
281    fn unused_flow_param_fires() {
282        let src = r#"flow t(x: int, y: int) -> int {
283    return x
284}
285"#;
286        let hits = lint(src);
287        assert_eq!(hits.len(), 1);
288        assert_eq!(hits[0].rule, LintRule::UnusedFlowParam);
289        assert!(hits[0].message.contains("`y`"), "hit={:?}", hits[0]);
290    }
291
292    #[test]
293    fn used_params_are_clean() {
294        let src = r#"flow t(x: int, y: int) -> int {
295    z = x
296    return z + y
297}
298"#;
299        assert!(lint(src).is_empty());
300    }
301
302    #[test]
303    fn many_positional_fires_at_threshold() {
304        let src = r#"flow t() -> string {
305    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
306}
307"#;
308        let hits = lint(src);
309        assert_eq!(hits.len(), 1);
310        assert_eq!(hits[0].rule, LintRule::ManyPositional);
311    }
312
313    #[test]
314    fn many_positional_with_any_named_arg_is_clean() {
315        let src = r#"flow t() -> string {
316    return stdlib.compose_email_preview("s", "b", to: ["a"])
317}
318"#;
319        assert!(lint(src).is_empty());
320    }
321
322    #[test]
323    fn three_positional_below_threshold_is_clean() {
324        let src = r#"flow t() -> string {
325    return stdlib.compose_email_preview("s", "b", ["a"])
326}
327"#;
328        assert!(lint(src).is_empty());
329    }
330
331    #[test]
332    fn multiple_hits_across_flows_reported_together() {
333        let src = r#"flow a() -> string {
334    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
335}
336
337flow b(unused: int) -> int {
338    return 1
339}
340"#;
341        let hits = lint(src);
342        assert_eq!(hits.len(), 2);
343        assert!(
344            hits.iter()
345                .any(|h| h.flow == "a" && h.rule == LintRule::ManyPositional)
346        );
347        assert!(
348            hits.iter()
349                .any(|h| h.flow == "b" && h.rule == LintRule::UnusedFlowParam)
350        );
351    }
352
353    #[test]
354    fn watch_target_counts_as_reference() {
355        let src = r#"flow t() -> string {
356    x = llm.call(model: "m", prompt: "p")
357    watch x {
358        on token(match: "err") { }
359    }
360    return x
361}
362"#;
363        let hits = lint(src);
364        assert!(hits.is_empty(), "unexpected hits: {hits:?}");
365    }
366}