Skip to main content

harn_vm/stdlib/template/
lint.rs

1//! AST surface that `harn-lint` consumes to enforce `.harn.prompt`
2//! drift-prevention rules (#1669).
3//!
4//! The template parser and AST are otherwise internal — exposing a
5//! shallow read-only view through this module keeps the lint crate
6//! free of template-engine internals while still giving rules enough
7//! structure to walk conditionals, sections, and includes.
8
9use super::ast::{BinOp, Expr, Node, PathSeg};
10use super::parser::parse as parse_template;
11
12/// Parse a template source string into a flat list of lintable
13/// constructs (conditionals + sections). Returns `Err` when the
14/// template doesn't parse — callers should surface the underlying
15/// `validate_template_syntax` error to the user before linting.
16pub fn parse(src: &str) -> Result<Vec<LintConstruct>, String> {
17    let nodes = parse_template(src).map_err(|error| error.message())?;
18    let mut out = Vec::new();
19    walk_nodes(&nodes, &mut out);
20    Ok(out)
21}
22
23/// One lintable construct, materialized in source order so rules can
24/// reason about counts (e.g. branch-explosion) and individual call
25/// sites (e.g. provider-identity comparisons).
26#[derive(Debug, Clone)]
27pub enum LintConstruct {
28    /// An `{{ if .. }}` / `{{ elif }}` chain. One entry per condition
29    /// in the chain (the trailing `{{ else }}` is implicit and not
30    /// listed). Conditions are flattened across `elif` to make
31    /// branch-count rules straightforward.
32    IfChain { branches: Vec<IfBranch> },
33    /// A `{{ section "..." }}` block. Sections are themselves
34    /// capability-adaptive but never look identity-driven; rules use
35    /// this to count capability-aware partials.
36    Section {
37        name: String,
38        line: usize,
39        col: usize,
40    },
41}
42
43#[derive(Debug, Clone)]
44pub struct IfBranch {
45    pub line: usize,
46    pub col: usize,
47    pub condition: ConditionShape,
48}
49
50/// Coarse classification of an `{{ if expr }}` condition. The lint
51/// rules don't need to evaluate or fully reconstruct expressions —
52/// just enough structure to detect the two failure patterns called
53/// out in #1669:
54///
55/// - Identity comparisons (`llm.provider == "..."`).
56/// - Capability-flag branches (`llm.capabilities.<flag>`), which the
57///   variant-explosion rule counts.
58///
59/// Conditions outside these shapes resolve to `Other` and don't
60/// participate in either rule.
61#[derive(Debug, Clone)]
62pub enum ConditionShape {
63    /// `llm.provider == "..."` / `llm.model == "..."` /
64    /// `llm.family == "..."` (or `!=`).
65    ProviderIdentity(IdentityField),
66    /// Any path-based condition mentioning `llm.capabilities.<flag>`
67    /// (including negation and use as a comparison operand). The
68    /// variant-explosion rule counts every branch with this shape.
69    /// Source position lives on the surrounding [`IfBranch`].
70    CapabilityFlag {
71        flag: String,
72    },
73    Other,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum IdentityField {
78    Provider,
79    Model,
80    Family,
81}
82
83impl IdentityField {
84    pub fn as_str(self) -> &'static str {
85        match self {
86            IdentityField::Provider => "provider",
87            IdentityField::Model => "model",
88            IdentityField::Family => "family",
89        }
90    }
91}
92
93fn walk_nodes(nodes: &[Node], out: &mut Vec<LintConstruct>) {
94    for node in nodes {
95        walk_node(node, out);
96    }
97}
98
99fn walk_node(node: &Node, out: &mut Vec<LintConstruct>) {
100    match node {
101        Node::Text(_) | Node::Expr { .. } | Node::LegacyBareInterp { .. } => {}
102        Node::If {
103            branches,
104            else_branch,
105            line: _,
106            col: _,
107        } => {
108            let mut summary = Vec::with_capacity(branches.len());
109            for branch in branches {
110                summary.push(IfBranch {
111                    line: branch.line,
112                    col: branch.col,
113                    condition: classify_condition(&branch.cond),
114                });
115                walk_nodes(&branch.body, out);
116            }
117            out.push(LintConstruct::IfChain { branches: summary });
118            if let Some(else_body) = else_branch {
119                walk_nodes(else_body, out);
120            }
121        }
122        Node::For { body, empty, .. } => {
123            walk_nodes(body, out);
124            if let Some(empty) = empty {
125                walk_nodes(empty, out);
126            }
127        }
128        Node::Include { .. } => {
129            // Include resolution happens at render time. Linting only
130            // walks the calling template; the included partial gets
131            // linted independently when the linter encounters it.
132        }
133        Node::Section {
134            name,
135            body,
136            line,
137            col,
138            ..
139        } => {
140            out.push(LintConstruct::Section {
141                name: name.clone(),
142                line: *line,
143                col: *col,
144            });
145            walk_nodes(body, out);
146        }
147    }
148}
149
150/// Classify the top-level shape of an `{{ if expr }}` condition.
151fn classify_condition(expr: &Expr) -> ConditionShape {
152    if let Some(identity) = match_identity_compare(expr) {
153        return ConditionShape::ProviderIdentity(identity);
154    }
155    if let Some(capability) = match_capability_path(expr) {
156        return capability;
157    }
158    ConditionShape::Other
159}
160
161/// Match `llm.<provider|model|family> == "..."` or `!= "..."`,
162/// returning the LHS identity field that was compared.
163fn match_identity_compare(expr: &Expr) -> Option<IdentityField> {
164    let Expr::Binary(op, lhs, rhs) = expr else {
165        return None;
166    };
167    if !matches!(op, BinOp::Eq | BinOp::Neq) {
168        return None;
169    }
170    let path = match (lhs.as_ref(), rhs.as_ref()) {
171        (Expr::Path(p), Expr::Str(_)) | (Expr::Str(_), Expr::Path(p)) => p,
172        _ => return None,
173    };
174    if !path_starts_with_llm(path) {
175        return None;
176    }
177    match path.get(1) {
178        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "provider" => {
179            Some(IdentityField::Provider)
180        }
181        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "model" => {
182            Some(IdentityField::Model)
183        }
184        Some(PathSeg::Field(name) | PathSeg::Key(name)) if name == "family" => {
185            Some(IdentityField::Family)
186        }
187        _ => None,
188    }
189}
190
191/// Match `llm.capabilities.<flag>` (possibly negated by `!`) or
192/// `llm.capabilities.<flag> == <literal>`, returning the flag name.
193fn match_capability_path(expr: &Expr) -> Option<ConditionShape> {
194    fn find_capability_path(expr: &Expr) -> Option<String> {
195        match expr {
196            Expr::Path(path) => capability_flag_from_path(path),
197            Expr::Unary(_, inner) => find_capability_path(inner),
198            Expr::Binary(_, lhs, rhs) => {
199                find_capability_path(lhs).or_else(|| find_capability_path(rhs))
200            }
201            Expr::Filter(inner, _, _) => find_capability_path(inner),
202            _ => None,
203        }
204    }
205    let flag = find_capability_path(expr)?;
206    Some(ConditionShape::CapabilityFlag { flag })
207}
208
209fn capability_flag_from_path(path: &[PathSeg]) -> Option<String> {
210    if !path_starts_with_llm(path) {
211        return None;
212    }
213    let Some(PathSeg::Field(name) | PathSeg::Key(name)) = path.get(1) else {
214        return None;
215    };
216    if name != "capabilities" {
217        return None;
218    }
219    let Some(PathSeg::Field(flag) | PathSeg::Key(flag)) = path.get(2) else {
220        return None;
221    };
222    Some(flag.clone())
223}
224
225fn path_starts_with_llm(path: &[PathSeg]) -> bool {
226    matches!(
227        path.first(),
228        Some(PathSeg::Field(name)) if name == "llm",
229    )
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn parse_ok(src: &str) -> Vec<LintConstruct> {
237        parse(src).expect("template should parse")
238    }
239
240    fn first_if(constructs: &[LintConstruct]) -> &[IfBranch] {
241        match constructs
242            .iter()
243            .find(|c| matches!(c, LintConstruct::IfChain { .. }))
244            .expect("if chain present")
245        {
246            LintConstruct::IfChain { branches } => branches.as_slice(),
247            _ => unreachable!(),
248        }
249    }
250
251    #[test]
252    fn provider_identity_eq_detected() {
253        let constructs = parse_ok("{{ if llm.provider == \"anthropic\" }}x{{ else }}y{{ end }}");
254        let branches = first_if(&constructs);
255        assert_eq!(branches.len(), 1);
256        assert!(matches!(
257            branches[0].condition,
258            ConditionShape::ProviderIdentity(IdentityField::Provider)
259        ));
260    }
261
262    #[test]
263    fn model_identity_neq_detected() {
264        let constructs = parse_ok("{{ if llm.model != \"gpt-5\" }}x{{ end }}");
265        let branches = first_if(&constructs);
266        assert!(matches!(
267            branches[0].condition,
268            ConditionShape::ProviderIdentity(IdentityField::Model)
269        ));
270    }
271
272    #[test]
273    fn capability_flag_detected_in_negation_and_filter() {
274        let constructs = parse_ok(
275            "{{ if !llm.capabilities.native_tools }}x{{ end }}\
276             {{ if llm.capabilities.prefers_xml_scaffolding | default: false }}y{{ end }}",
277        );
278        let if_chains: Vec<_> = constructs
279            .iter()
280            .filter_map(|c| match c {
281                LintConstruct::IfChain { branches } => Some(branches.clone()),
282                _ => None,
283            })
284            .collect();
285        assert_eq!(if_chains.len(), 2);
286        assert!(matches!(
287            if_chains[0][0].condition,
288            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
289        ));
290        assert!(matches!(
291            if_chains[1][0].condition,
292            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "prefers_xml_scaffolding"
293        ));
294    }
295
296    #[test]
297    fn elif_chain_lifts_per_branch_condition() {
298        let constructs = parse_ok(
299            "{{ if llm.provider == \"openai\" }}a\
300             {{ elif llm.capabilities.native_tools }}b\
301             {{ else }}c{{ end }}",
302        );
303        let branches = first_if(&constructs);
304        assert_eq!(branches.len(), 2);
305        assert!(matches!(
306            branches[0].condition,
307            ConditionShape::ProviderIdentity(IdentityField::Provider)
308        ));
309        assert!(matches!(
310            branches[1].condition,
311            ConditionShape::CapabilityFlag { ref flag, .. } if flag == "native_tools"
312        ));
313    }
314
315    #[test]
316    fn unrelated_condition_falls_through_to_other() {
317        let constructs = parse_ok("{{ if score > 0.5 }}a{{ end }}");
318        let branches = first_if(&constructs);
319        assert!(matches!(branches[0].condition, ConditionShape::Other));
320    }
321
322    #[test]
323    fn sections_listed_in_source_order() {
324        let constructs = parse_ok(
325            "{{ section \"task\" }}t{{ endsection }}\
326             {{ section \"output_format\" }}o{{ endsection }}",
327        );
328        let names: Vec<_> = constructs
329            .iter()
330            .filter_map(|c| match c {
331                LintConstruct::Section { name, .. } => Some(name.clone()),
332                _ => None,
333            })
334            .collect();
335        assert_eq!(names, vec!["task", "output_format"]);
336    }
337}