atman-runtime 1.8.0

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use std::collections::HashSet;

use atman_dsl::ast::{Arg, Expr, File, FlowDecl, Node, Stmt};

const MANY_POSITIONAL_THRESHOLD: usize = 4;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintHit {
    pub flow: String,
    pub rule: LintRule,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LintRule {
    UnusedFlowParam,
    ManyPositional,
}

impl LintRule {
    pub fn slug(&self) -> &'static str {
        match self {
            LintRule::UnusedFlowParam => "unused-flow-param",
            LintRule::ManyPositional => "many-positional",
        }
    }
}

pub fn lint_file(file: &File) -> Vec<LintHit> {
    let mut hits = Vec::new();
    for flow in &file.flows {
        lint_flow(flow, &mut hits);
    }
    hits
}

fn lint_flow(flow: &FlowDecl, hits: &mut Vec<LintHit>) {
    let mut refs = HashSet::new();
    collect_ident_refs_stmts(&flow.body, &mut refs);
    for p in &flow.params {
        if !refs.contains(&p.name.name) {
            hits.push(LintHit {
                flow: flow.name.name.clone(),
                rule: LintRule::UnusedFlowParam,
                message: format!(
                    "parameter `{}` is declared but never referenced",
                    p.name.name
                ),
            });
        }
    }
    walk_stmts_for_nodes(&flow.body, &flow.name.name, hits);
}

fn collect_ident_refs_stmts(stmts: &[Stmt], refs: &mut HashSet<String>) {
    for stmt in stmts {
        match stmt {
            Stmt::Bind { value, .. } => collect_ident_refs_expr(value, refs),
            Stmt::When { cond, body } => {
                collect_ident_refs_expr(cond, refs);
                collect_ident_refs_stmts(body, refs);
            }
            Stmt::Return { value } => collect_ident_refs_expr(value, refs),
            Stmt::Expr(e) => collect_ident_refs_expr(e, refs),
            Stmt::Watch(w) => {
                refs.insert(w.target.name.clone());
            }
            Stmt::Loop { body } => collect_ident_refs_stmts(body, refs),
            Stmt::Break => {}
            Stmt::Continue => {}
        }
    }
}

fn collect_ident_refs_expr(expr: &Expr, refs: &mut HashSet<String>) {
    match expr {
        Expr::Literal(_) | Expr::FileRef(_) => {}
        Expr::Ident(id) => {
            refs.insert(id.name.clone());
        }
        Expr::Member { base, .. } => collect_ident_refs_expr(base, refs),
        Expr::Binary { left, right, .. } => {
            collect_ident_refs_expr(left, refs);
            collect_ident_refs_expr(right, refs);
        }
        Expr::Unary { operand, .. } => collect_ident_refs_expr(operand, refs),
        Expr::List(items) => {
            for it in items {
                collect_ident_refs_expr(it, refs);
            }
        }
        Expr::Struct(fields) => {
            for (_, v) in fields {
                collect_ident_refs_expr(v, refs);
            }
        }
        Expr::Node(node) => collect_ident_refs_node(node, refs),
        Expr::Call { args, .. } => {
            for a in args {
                collect_ident_refs_expr(a, refs);
            }
        }
        Expr::Pipe { lhs, rhs } => {
            collect_ident_refs_expr(lhs, refs);
            collect_ident_refs_expr(rhs, refs);
        }
        Expr::Annotated { expr, .. } => collect_ident_refs_expr(expr, refs),
        Expr::Lambda { params, body } => {
            for p in params {
                refs.insert(p.name.clone());
            }
            collect_ident_refs_expr(body, refs);
        }
    }
}

fn collect_ident_refs_node(node: &Node, refs: &mut HashSet<String>) {
    match node {
        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
            for a in args {
                match a {
                    Arg::Positional(e) => collect_ident_refs_expr(e, refs),
                    Arg::Named { value, .. } => collect_ident_refs_expr(value, refs),
                }
            }
        }
        Node::FixUntilTestPasses { kwargs } => {
            for (_, v) in kwargs {
                collect_ident_refs_expr(v, refs);
            }
        }
        Node::DynamicFanout { source, lambda, .. } => {
            collect_ident_refs_expr(source, refs);
            collect_ident_refs_expr(lambda, refs);
        }
        Node::Fanout { items, .. } => {
            for it in items {
                collect_ident_refs_expr(it, refs);
            }
        }
        Node::UserConfirm { msg } => collect_ident_refs_expr(msg, refs),
    }
}

fn walk_stmts_for_nodes(stmts: &[Stmt], flow_name: &str, hits: &mut Vec<LintHit>) {
    for stmt in stmts {
        match stmt {
            Stmt::Bind { value, .. } | Stmt::Return { value } | Stmt::Expr(value) => {
                walk_expr_for_nodes(value, flow_name, hits);
            }
            Stmt::When { cond, body } => {
                walk_expr_for_nodes(cond, flow_name, hits);
                walk_stmts_for_nodes(body, flow_name, hits);
            }
            Stmt::Watch(_) => {}
            Stmt::Loop { body } => {
                walk_stmts_for_nodes(body, flow_name, hits);
            }
            Stmt::Break => {}
            Stmt::Continue => {}
        }
    }
}

fn walk_expr_for_nodes(expr: &Expr, flow_name: &str, hits: &mut Vec<LintHit>) {
    match expr {
        Expr::Literal(_) | Expr::FileRef(_) | Expr::Ident(_) => {}
        Expr::Member { base, .. } => walk_expr_for_nodes(base, flow_name, hits),
        Expr::Binary { left, right, .. } => {
            walk_expr_for_nodes(left, flow_name, hits);
            walk_expr_for_nodes(right, flow_name, hits);
        }
        Expr::Unary { operand, .. } => walk_expr_for_nodes(operand, flow_name, hits),
        Expr::List(items) => {
            for it in items {
                walk_expr_for_nodes(it, flow_name, hits);
            }
        }
        Expr::Struct(fields) => {
            for (_, v) in fields {
                walk_expr_for_nodes(v, flow_name, hits);
            }
        }
        Expr::Call { args, .. } => {
            for a in args {
                walk_expr_for_nodes(a, flow_name, hits);
            }
        }
        Expr::Pipe { lhs, rhs } => {
            walk_expr_for_nodes(lhs, flow_name, hits);
            walk_expr_for_nodes(rhs, flow_name, hits);
        }
        Expr::Node(node) => {
            check_node(node, flow_name, hits);
            for e in child_exprs(node) {
                walk_expr_for_nodes(e, flow_name, hits);
            }
        }
        Expr::Annotated { expr, .. } => walk_expr_for_nodes(expr, flow_name, hits),
        Expr::Lambda { body, .. } => walk_expr_for_nodes(body, flow_name, hits),
    }
}

fn check_node(node: &Node, flow_name: &str, hits: &mut Vec<LintHit>) {
    if let Node::ToolCall { path, args } = node {
        let positional = args
            .iter()
            .filter(|a| matches!(a, Arg::Positional(_)))
            .count();
        let named = args
            .iter()
            .filter(|a| matches!(a, Arg::Named { .. }))
            .count();
        if positional >= MANY_POSITIONAL_THRESHOLD && named == 0 {
            let name = path
                .iter()
                .map(|i| i.name.as_str())
                .collect::<Vec<_>>()
                .join(".");
            hits.push(LintHit {
                flow: flow_name.to_string(),
                rule: LintRule::ManyPositional,
                message: format!(
                    "{name} takes {positional} positional args with no names — prefer named args for readability"
                ),
            });
        }
    }
}

fn child_exprs(node: &Node) -> Vec<&Expr> {
    let mut out: Vec<&Expr> = Vec::new();
    match node {
        Node::ToolCall { args, .. } | Node::Subflow { args, .. } | Node::Message { args, .. } => {
            for a in args {
                match a {
                    Arg::Positional(e) => out.push(e),
                    Arg::Named { value, .. } => out.push(value),
                }
            }
        }
        Node::FixUntilTestPasses { kwargs } => {
            for (_, v) in kwargs {
                out.push(v);
            }
        }
        Node::DynamicFanout { source, lambda, .. } => {
            out.push(source);
            out.push(lambda);
        }
        Node::Fanout { items, .. } => {
            for i in items {
                out.push(i);
            }
        }
        Node::UserConfirm { msg } => out.push(msg),
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use atman_dsl::parse::parse_file;

    fn lint(src: &str) -> Vec<LintHit> {
        let file = parse_file(src).unwrap_or_else(|e| panic!("parse: {e}"));
        lint_file(&file)
    }

    #[test]
    fn llm_without_fallback_is_intentional_and_clean() {
        let src = r#"flow t() -> string {
    return llm.call(model: "mock", prompt: "hi")
}
"#;
        assert!(lint(src).is_empty());
    }

    #[test]
    fn unused_flow_param_fires() {
        let src = r#"flow t(x: int, y: int) -> int {
    return x
}
"#;
        let hits = lint(src);
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].rule, LintRule::UnusedFlowParam);
        assert!(hits[0].message.contains("`y`"), "hit={:?}", hits[0]);
    }

    #[test]
    fn used_params_are_clean() {
        let src = r#"flow t(x: int, y: int) -> int {
    z = x
    return z + y
}
"#;
        assert!(lint(src).is_empty());
    }

    #[test]
    fn many_positional_fires_at_threshold() {
        let src = r#"flow t() -> string {
    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
}
"#;
        let hits = lint(src);
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].rule, LintRule::ManyPositional);
    }

    #[test]
    fn many_positional_with_any_named_arg_is_clean() {
        let src = r#"flow t() -> string {
    return stdlib.compose_email_preview("s", "b", to: ["a"])
}
"#;
        assert!(lint(src).is_empty());
    }

    #[test]
    fn three_positional_below_threshold_is_clean() {
        let src = r#"flow t() -> string {
    return stdlib.compose_email_preview("s", "b", ["a"])
}
"#;
        assert!(lint(src).is_empty());
    }

    #[test]
    fn multiple_hits_across_flows_reported_together() {
        let src = r#"flow a() -> string {
    return stdlib.compose_email_preview("s", "b", ["a"], "extra")
}

flow b(unused: int) -> int {
    return 1
}
"#;
        let hits = lint(src);
        assert_eq!(hits.len(), 2);
        assert!(
            hits.iter()
                .any(|h| h.flow == "a" && h.rule == LintRule::ManyPositional)
        );
        assert!(
            hits.iter()
                .any(|h| h.flow == "b" && h.rule == LintRule::UnusedFlowParam)
        );
    }

    #[test]
    fn watch_target_counts_as_reference() {
        let src = r#"flow t() -> string {
    x = llm.call(model: "m", prompt: "p")
    watch x {
        on token(match: "err") { }
    }
    return x
}
"#;
        let hits = lint(src);
        assert!(hits.is_empty(), "unexpected hits: {hits:?}");
    }
}