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 (param, _) in &flow.params {
41        if !refs.contains(&param.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                    param.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        }
69    }
70}
71
72fn collect_ident_refs_expr(expr: &Expr, refs: &mut HashSet<String>) {
73    match expr {
74        Expr::Literal(_) | Expr::FileRef(_) => {}
75        Expr::Ident(id) => {
76            refs.insert(id.name.clone());
77        }
78        Expr::Member { base, .. } => collect_ident_refs_expr(base, refs),
79        Expr::Binary { left, right, .. } => {
80            collect_ident_refs_expr(left, refs);
81            collect_ident_refs_expr(right, refs);
82        }
83        Expr::Unary { operand, .. } => collect_ident_refs_expr(operand, refs),
84        Expr::List(items) => {
85            for it in items {
86                collect_ident_refs_expr(it, refs);
87            }
88        }
89        Expr::Struct(fields) => {
90            for (_, v) in fields {
91                collect_ident_refs_expr(v, refs);
92            }
93        }
94        Expr::Node(node) => collect_ident_refs_node(node, refs),
95        Expr::Call { args, .. } => {
96            for a in args {
97                collect_ident_refs_expr(a, refs);
98            }
99        }
100        Expr::Pipe { lhs, rhs } => {
101            collect_ident_refs_expr(lhs, refs);
102            collect_ident_refs_expr(rhs, refs);
103        }
104    }
105}
106
107fn collect_ident_refs_node(node: &Node, refs: &mut HashSet<String>) {
108    match node {
109        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
110            for a in args {
111                match a {
112                    Arg::Positional(e) => collect_ident_refs_expr(e, refs),
113                    Arg::Named { value, .. } => collect_ident_refs_expr(value, refs),
114                }
115            }
116        }
117        Node::Llm { kwargs } | Node::FixUntilTestPasses { kwargs } => {
118            for (_, v) in kwargs {
119                collect_ident_refs_expr(v, refs);
120            }
121        }
122        Node::Fanout { items, .. } => {
123            for it in items {
124                collect_ident_refs_expr(it, refs);
125            }
126        }
127        Node::UserConfirm { msg } => collect_ident_refs_expr(msg, refs),
128    }
129}
130
131fn walk_stmts_for_nodes(stmts: &[Stmt], flow_name: &str, hits: &mut Vec<LintHit>) {
132    for stmt in stmts {
133        match stmt {
134            Stmt::Bind { value, .. } | Stmt::Return { value } | Stmt::Expr(value) => {
135                walk_expr_for_nodes(value, flow_name, hits);
136            }
137            Stmt::When { cond, body } => {
138                walk_expr_for_nodes(cond, flow_name, hits);
139                walk_stmts_for_nodes(body, flow_name, hits);
140            }
141            Stmt::Watch(_) => {}
142        }
143    }
144}
145
146fn walk_expr_for_nodes(expr: &Expr, flow_name: &str, hits: &mut Vec<LintHit>) {
147    match expr {
148        Expr::Literal(_) | Expr::FileRef(_) | Expr::Ident(_) => {}
149        Expr::Member { base, .. } => walk_expr_for_nodes(base, flow_name, hits),
150        Expr::Binary { left, right, .. } => {
151            walk_expr_for_nodes(left, flow_name, hits);
152            walk_expr_for_nodes(right, flow_name, hits);
153        }
154        Expr::Unary { operand, .. } => walk_expr_for_nodes(operand, flow_name, hits),
155        Expr::List(items) => {
156            for it in items {
157                walk_expr_for_nodes(it, flow_name, hits);
158            }
159        }
160        Expr::Struct(fields) => {
161            for (_, v) in fields {
162                walk_expr_for_nodes(v, flow_name, hits);
163            }
164        }
165        Expr::Call { args, .. } => {
166            for a in args {
167                walk_expr_for_nodes(a, flow_name, hits);
168            }
169        }
170        Expr::Pipe { lhs, rhs } => {
171            walk_expr_for_nodes(lhs, flow_name, hits);
172            walk_expr_for_nodes(rhs, flow_name, hits);
173        }
174        Expr::Node(node) => {
175            check_node(node, flow_name, hits);
176            for e in child_exprs(node) {
177                walk_expr_for_nodes(e, flow_name, hits);
178            }
179        }
180    }
181}
182
183fn check_node(node: &Node, flow_name: &str, hits: &mut Vec<LintHit>) {
184    if let Node::ToolCall { path, args } = node {
185        let positional = args
186            .iter()
187            .filter(|a| matches!(a, Arg::Positional(_)))
188            .count();
189        let named = args
190            .iter()
191            .filter(|a| matches!(a, Arg::Named { .. }))
192            .count();
193        if positional >= MANY_POSITIONAL_THRESHOLD && named == 0 {
194            let name = path
195                .iter()
196                .map(|i| i.name.as_str())
197                .collect::<Vec<_>>()
198                .join(".");
199            hits.push(LintHit {
200                flow: flow_name.to_string(),
201                rule: LintRule::ManyPositional,
202                message: format!(
203                    "{name} takes {positional} positional args with no names — prefer named args for readability"
204                ),
205            });
206        }
207    }
208}
209
210fn child_exprs(node: &Node) -> Vec<&Expr> {
211    let mut out: Vec<&Expr> = Vec::new();
212    match node {
213        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
214            for a in args {
215                match a {
216                    Arg::Positional(e) => out.push(e),
217                    Arg::Named { value, .. } => out.push(value),
218                }
219            }
220        }
221        Node::Llm { kwargs } | Node::FixUntilTestPasses { kwargs } => {
222            for (_, v) in kwargs {
223                out.push(v);
224            }
225        }
226        Node::Fanout { items, .. } => {
227            for i in items {
228                out.push(i);
229            }
230        }
231        Node::UserConfirm { msg } => out.push(msg),
232    }
233    out
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use atman_dsl::parse::parse_file;
240
241    fn lint(src: &str) -> Vec<LintHit> {
242        let file = parse_file(src).unwrap_or_else(|e| panic!("parse: {e}"));
243        lint_file(&file)
244    }
245
246    #[test]
247    fn llm_without_fallback_is_intentional_and_clean() {
248        let src = r#"flow t() -> string {
249    return llm { model: "mock", prompt: "hi" }
250}
251"#;
252        assert!(lint(src).is_empty());
253    }
254
255    #[test]
256    fn unused_flow_param_fires() {
257        let src = r#"flow t(x: int, y: int) -> int {
258    return x
259}
260"#;
261        let hits = lint(src);
262        assert_eq!(hits.len(), 1);
263        assert_eq!(hits[0].rule, LintRule::UnusedFlowParam);
264        assert!(hits[0].message.contains("`y`"), "hit={:?}", hits[0]);
265    }
266
267    #[test]
268    fn used_params_are_clean() {
269        let src = r#"flow t(x: int, y: int) -> int {
270    z = x
271    return z + y
272}
273"#;
274        assert!(lint(src).is_empty());
275    }
276
277    #[test]
278    fn many_positional_fires_at_threshold() {
279        let src = r#"flow t() -> string {
280    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
281}
282"#;
283        let hits = lint(src);
284        assert_eq!(hits.len(), 1);
285        assert_eq!(hits[0].rule, LintRule::ManyPositional);
286    }
287
288    #[test]
289    fn many_positional_with_any_named_arg_is_clean() {
290        let src = r#"flow t() -> string {
291    return stdlib.compose_email_preview("s", "b", to: ["a"])
292}
293"#;
294        assert!(lint(src).is_empty());
295    }
296
297    #[test]
298    fn three_positional_below_threshold_is_clean() {
299        let src = r#"flow t() -> string {
300    return stdlib.compose_email_preview("s", "b", ["a"])
301}
302"#;
303        assert!(lint(src).is_empty());
304    }
305
306    #[test]
307    fn multiple_hits_across_flows_reported_together() {
308        let src = r#"flow a() -> string {
309    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
310}
311
312flow b(unused: int) -> int {
313    return 1
314}
315"#;
316        let hits = lint(src);
317        assert_eq!(hits.len(), 2);
318        assert!(
319            hits.iter()
320                .any(|h| h.flow == "a" && h.rule == LintRule::ManyPositional)
321        );
322        assert!(
323            hits.iter()
324                .any(|h| h.flow == "b" && h.rule == LintRule::UnusedFlowParam)
325        );
326    }
327
328    #[test]
329    fn watch_target_counts_as_reference() {
330        let src = r#"flow t() -> string {
331    x = llm { model: "m", prompt: "p" }
332    watch x {
333        on token(match: "err") { }
334    }
335    return x
336}
337"#;
338        let hits = lint(src);
339        assert!(hits.is_empty(), "unexpected hits: {hits:?}");
340    }
341}