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(¶m.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}