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}