Skip to main content

aver/analysis/
shape.rs

1//! Per-fn shape recognition — typed facts over resolved HIR.
2//!
3//! Stage 1 of issue #232 (0.23 "Shape"). This module is the
4//! *recognition-only* layer: walks `ResolvedFnDef` bodies, builds
5//! `Facts` per fn, emits multi-label archetype classifications,
6//! computes call-graph SCCs. **No `ModuleShape` vector / Kind /
7//! Layer / renderer here** — those live one tier up in
8//! `aver::diagnostics::shape` (presentation) or
9//! `aver::codegen::proof_lower` (meaning), and the future Stage 6
10//! patterns / relations (`WrapperOverRecursion`, `ResultPipeline`,
11//! …) land here next to the primitives they're built from.
12
13use std::collections::{HashMap, HashSet};
14
15use crate::ir::FnId;
16use crate::ir::hir::{
17    ResolvedCallee, ResolvedCtor, ResolvedExpr, ResolvedFnDef, ResolvedMatchArm, ResolvedPattern,
18    ResolvedStmt, ResolvedStrPart,
19};
20use crate::types::Type;
21
22// ─── Per-fn AST facts ────────────────────────────────────────────────────────
23
24#[derive(Debug, Default)]
25pub struct Facts {
26    pub calls_to: HashSet<FnId>,
27    pub tail_calls: HashSet<FnId>,
28    pub builtin_calls: Vec<String>,
29    pub ctor_constructs: Vec<ResolvedCtor>,
30    pub ctor_match_patterns: usize,
31    pub other_match_patterns: usize,
32    pub has_match: bool,
33    pub has_error_prop: bool,
34    pub record_creates: usize,
35    pub last_stmt_is_match: bool,
36    pub only_stmt_is_literal: bool,
37    pub body_stmt_count: usize,
38    pub has_interp_str: bool,
39    pub string_builtin_calls: usize,
40    pub has_match_with_err_arm: bool,
41}
42
43fn walk_expr(e: &ResolvedExpr, facts: &mut Facts) {
44    match e {
45        ResolvedExpr::Literal(_) | ResolvedExpr::Ident(_) | ResolvedExpr::Resolved { .. } => {}
46        ResolvedExpr::Attr(inner, _) => walk_expr(&inner.node, facts),
47        ResolvedExpr::Call(callee, args) => {
48            match callee {
49                ResolvedCallee::Fn(id) => {
50                    facts.calls_to.insert(*id);
51                }
52                ResolvedCallee::Builtin(name) => {
53                    if name.starts_with("String.") {
54                        facts.string_builtin_calls += 1;
55                    }
56                    facts.builtin_calls.push(name.clone());
57                }
58                ResolvedCallee::Intrinsic(_) => {}
59                ResolvedCallee::LocalSlot { .. } => {}
60                ResolvedCallee::Unresolved { callee } => walk_expr(&callee.node, facts),
61            }
62            for a in args {
63                walk_expr(&a.node, facts);
64            }
65        }
66        ResolvedExpr::BinOp(_, a, b) => {
67            walk_expr(&a.node, facts);
68            walk_expr(&b.node, facts);
69        }
70        ResolvedExpr::Neg(inner) => walk_expr(&inner.node, facts),
71        ResolvedExpr::Match { subject, arms } => {
72            facts.has_match = true;
73            walk_expr(&subject.node, facts);
74            for arm in arms {
75                count_arm_pattern(&arm.pattern, facts);
76                walk_match_arm(arm, facts);
77            }
78        }
79        ResolvedExpr::Ctor(c, args) => {
80            facts.ctor_constructs.push(c.clone());
81            for a in args {
82                walk_expr(&a.node, facts);
83            }
84        }
85        ResolvedExpr::ErrorProp(inner) => {
86            facts.has_error_prop = true;
87            walk_expr(&inner.node, facts);
88        }
89        ResolvedExpr::InterpolatedStr(parts) => {
90            facts.has_interp_str = true;
91            for p in parts {
92                if let ResolvedStrPart::Parsed(e) = p {
93                    walk_expr(&e.node, facts);
94                }
95            }
96        }
97        ResolvedExpr::List(xs) | ResolvedExpr::Tuple(xs) => {
98            for x in xs {
99                walk_expr(&x.node, facts);
100            }
101        }
102        ResolvedExpr::MapLiteral(pairs) => {
103            for (k, v) in pairs {
104                walk_expr(&k.node, facts);
105                walk_expr(&v.node, facts);
106            }
107        }
108        ResolvedExpr::RecordCreate { fields, .. } => {
109            facts.record_creates += 1;
110            for (_, v) in fields {
111                walk_expr(&v.node, facts);
112            }
113        }
114        ResolvedExpr::RecordUpdate { base, updates, .. } => {
115            walk_expr(&base.node, facts);
116            for (_, v) in updates {
117                walk_expr(&v.node, facts);
118            }
119        }
120        ResolvedExpr::TailCall { target, args } => {
121            facts.tail_calls.insert(*target);
122            facts.calls_to.insert(*target);
123            for a in args {
124                walk_expr(&a.node, facts);
125            }
126        }
127        ResolvedExpr::IndependentProduct(xs, _) => {
128            for x in xs {
129                walk_expr(&x.node, facts);
130            }
131        }
132    }
133}
134
135fn count_arm_pattern(p: &ResolvedPattern, facts: &mut Facts) {
136    match p {
137        ResolvedPattern::Ctor(ctor, _) => {
138            facts.ctor_match_patterns += 1;
139            if matches!(
140                ctor,
141                ResolvedCtor::Builtin(crate::ir::hir::BuiltinCtor::ResultErr)
142            ) {
143                facts.has_match_with_err_arm = true;
144            }
145        }
146        _ => facts.other_match_patterns += 1,
147    }
148}
149
150fn walk_match_arm(arm: &ResolvedMatchArm, facts: &mut Facts) {
151    walk_expr(&arm.body.node, facts);
152}
153
154pub fn extract_facts(fd: &ResolvedFnDef) -> Facts {
155    let mut facts = Facts::default();
156    let stmts = fd.body.stmts();
157    facts.body_stmt_count = stmts.len();
158    for stmt in stmts {
159        match stmt {
160            ResolvedStmt::Binding { value, .. } => walk_expr(&value.node, &mut facts),
161            ResolvedStmt::Expr(value) => walk_expr(&value.node, &mut facts),
162        }
163    }
164    if let Some(last) = stmts.last() {
165        let expr = match last {
166            ResolvedStmt::Binding { value, .. } => &value.node,
167            ResolvedStmt::Expr(value) => &value.node,
168        };
169        facts.last_stmt_is_match = matches!(expr, ResolvedExpr::Match { .. });
170    }
171    facts.only_stmt_is_literal = stmts.len() == 1 && {
172        let expr = match &stmts[0] {
173            ResolvedStmt::Binding { value, .. } => &value.node,
174            ResolvedStmt::Expr(value) => &value.node,
175        };
176        matches!(
177            expr,
178            ResolvedExpr::Literal(_)
179                | ResolvedExpr::List(_)
180                | ResolvedExpr::Tuple(_)
181                | ResolvedExpr::MapLiteral(_)
182                | ResolvedExpr::RecordCreate { .. }
183                | ResolvedExpr::Ctor(_, _)
184        )
185    };
186    facts
187}
188
189// ─── Per-fn classification ───────────────────────────────────────────────────
190
191pub fn classify(fd: &ResolvedFnDef, facts: &Facts, scc: &HashSet<FnId>) -> Vec<Archetype> {
192    let mut labels = Vec::new();
193
194    let self_call = facts.calls_to.contains(&fd.fn_id) || facts.tail_calls.contains(&fd.fn_id);
195    if self_call {
196        labels.push(Archetype::StructuralRecursion);
197    }
198    if scc.contains(&fd.fn_id) {
199        labels.push(Archetype::SccMutual);
200    }
201
202    if facts.last_stmt_is_match {
203        if facts.ctor_match_patterns >= facts.other_match_patterns && facts.ctor_match_patterns > 0
204        {
205            labels.push(Archetype::MatchDispatcher);
206        } else {
207            labels.push(Archetype::MatchOnValue);
208        }
209    }
210
211    if !fd.effects.is_empty() {
212        let total_calls = facts.calls_to.len() + facts.builtin_calls.len();
213        if total_calls >= 2 {
214            labels.push(Archetype::Orchestration);
215        } else {
216            labels.push(Archetype::EffectfulLeaf);
217        }
218    }
219
220    let is_result_ret = type_is_result(&fd.return_type);
221    if is_result_ret && facts.has_error_prop {
222        labels.push(Archetype::PipelineResult);
223    } else if is_result_ret && facts.has_match_with_err_arm {
224        labels.push(Archetype::ManualResultAdapter);
225    }
226
227    let is_string_ret = matches!(&fd.return_type, Type::Named { name, .. } if name == "String");
228    if is_string_ret
229        && fd.effects.is_empty()
230        && (facts.has_interp_str || facts.string_builtin_calls >= 1)
231        && (facts.body_stmt_count >= 2 || facts.has_interp_str)
232    {
233        labels.push(Archetype::RendererFormatter);
234    }
235
236    if facts.body_stmt_count == 1
237        && (!facts.ctor_constructs.is_empty() || facts.record_creates > 0)
238        && !facts.has_match
239    {
240        labels.push(Archetype::ConstructorWrapper);
241    }
242
243    if fd.params.is_empty()
244        && facts.calls_to.is_empty()
245        && facts.builtin_calls.is_empty()
246        && fd.effects.is_empty()
247        && facts.only_stmt_is_literal
248    {
249        labels.push(Archetype::DataAsFunction);
250    }
251
252    if facts.body_stmt_count == 1
253        && !facts.has_match
254        && !self_call
255        && fd.effects.is_empty()
256        && (!facts.builtin_calls.is_empty() || !facts.calls_to.is_empty())
257    {
258        labels.push(Archetype::TrivialHelper);
259    }
260
261    if facts.body_stmt_count == 1
262        && !facts.has_match
263        && !self_call
264        && fd.effects.is_empty()
265        && facts.builtin_calls.is_empty()
266        && facts.calls_to.is_empty()
267        && facts.ctor_constructs.is_empty()
268        && !fd.params.is_empty()
269    {
270        labels.push(Archetype::PureExpression);
271    }
272
273    if facts.body_stmt_count >= 2
274        && !facts.last_stmt_is_match
275        && fd.effects.is_empty()
276        && (!facts.builtin_calls.is_empty() || !facts.calls_to.is_empty())
277        && !self_call
278    {
279        labels.push(Archetype::LetPipeline);
280    }
281
282    labels
283}
284
285pub fn type_is_result(t: &Type) -> bool {
286    matches!(t, Type::Named { name, .. } if name == "Result")
287}
288
289/// Per-fn archetype label. Multi-label per fn — `classify` returns
290/// a `Vec<Archetype>`; `primary_label` picks one by the precedence
291/// declared in [`Archetype::all`]. Stringified only at presentation
292/// boundaries (renderer, JSON, LSP hover).
293///
294/// Stage 3 of #232 ("Shape") replaced the prior `&'static str`-keyed
295/// representation with this typed enum. The string forms are still
296/// the public JSON / text contract; `as_str` / `parse` translate
297/// at the edges.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
299pub enum Archetype {
300    SccMutual,
301    StructuralRecursion,
302    MatchDispatcher,
303    PipelineResult,
304    ManualResultAdapter,
305    RendererFormatter,
306    MatchOnValue,
307    Orchestration,
308    EffectfulLeaf,
309    LetPipeline,
310    ConstructorWrapper,
311    DataAsFunction,
312    TrivialHelper,
313    PureExpression,
314    /// Fallback for fn bodies that don't match any classifier rule.
315    /// Surfaces as `"unclassified"` in JSON / text output.
316    Unclassified,
317}
318
319impl Archetype {
320    /// Precedence-ordered list. `primary_label` walks this in order;
321    /// the first archetype that fires on a fn wins. Renderers also
322    /// use this for histogram tie-breaking.
323    pub fn all() -> &'static [Archetype] {
324        &[
325            Archetype::SccMutual,
326            Archetype::StructuralRecursion,
327            Archetype::MatchDispatcher,
328            Archetype::PipelineResult,
329            Archetype::ManualResultAdapter,
330            Archetype::RendererFormatter,
331            Archetype::MatchOnValue,
332            Archetype::Orchestration,
333            Archetype::EffectfulLeaf,
334            Archetype::LetPipeline,
335            Archetype::ConstructorWrapper,
336            Archetype::DataAsFunction,
337            Archetype::TrivialHelper,
338            Archetype::PureExpression,
339        ]
340    }
341
342    /// Canonical kebab-case label. Used by renderers, JSON, hover.
343    pub fn as_str(&self) -> &'static str {
344        match self {
345            Archetype::SccMutual => "scc-mutual",
346            Archetype::StructuralRecursion => "structural-recursion",
347            Archetype::MatchDispatcher => "match-dispatcher",
348            Archetype::PipelineResult => "pipeline-result",
349            Archetype::ManualResultAdapter => "manual-result-adapter",
350            Archetype::RendererFormatter => "renderer-formatter",
351            Archetype::MatchOnValue => "match-on-value",
352            Archetype::Orchestration => "orchestration",
353            Archetype::EffectfulLeaf => "effectful-leaf",
354            Archetype::LetPipeline => "let-pipeline",
355            Archetype::ConstructorWrapper => "constructor-wrapper",
356            Archetype::DataAsFunction => "data-as-function",
357            Archetype::TrivialHelper => "trivial-helper",
358            Archetype::PureExpression => "pure-expression",
359            Archetype::Unclassified => "unclassified",
360        }
361    }
362
363    /// Parse a string back into the typed form. Used by callers that
364    /// receive the public JSON shape and want to operate on the typed
365    /// enum (e.g. the research test reading per-folder histograms).
366    pub fn parse(s: &str) -> Option<Archetype> {
367        Some(match s {
368            "scc-mutual" => Archetype::SccMutual,
369            "structural-recursion" => Archetype::StructuralRecursion,
370            "match-dispatcher" => Archetype::MatchDispatcher,
371            "pipeline-result" => Archetype::PipelineResult,
372            "manual-result-adapter" => Archetype::ManualResultAdapter,
373            "renderer-formatter" => Archetype::RendererFormatter,
374            "match-on-value" => Archetype::MatchOnValue,
375            "orchestration" => Archetype::Orchestration,
376            "effectful-leaf" => Archetype::EffectfulLeaf,
377            "let-pipeline" => Archetype::LetPipeline,
378            "constructor-wrapper" => Archetype::ConstructorWrapper,
379            "data-as-function" => Archetype::DataAsFunction,
380            "trivial-helper" => Archetype::TrivialHelper,
381            "pure-expression" => Archetype::PureExpression,
382            "unclassified" => Archetype::Unclassified,
383            _ => return None,
384        })
385    }
386}
387
388pub fn primary_label(labels: &[Archetype]) -> Archetype {
389    for &want in Archetype::all() {
390        if labels.contains(&want) {
391            return want;
392        }
393    }
394    Archetype::Unclassified
395}
396
397// ─── Call-graph SCC (multi-node strongly connected components) ───────────────
398
399pub fn compute_sccs(fns: &[&ResolvedFnDef], facts_by_id: &HashMap<FnId, &Facts>) -> HashSet<FnId> {
400    let mut graph: HashMap<FnId, Vec<FnId>> = HashMap::new();
401    let fn_ids: HashSet<FnId> = fns.iter().map(|f| f.fn_id).collect();
402    for fd in fns {
403        if let Some(facts) = facts_by_id.get(&fd.fn_id) {
404            let edges: Vec<FnId> = facts
405                .calls_to
406                .iter()
407                .copied()
408                .filter(|c| fn_ids.contains(c) && *c != fd.fn_id)
409                .collect();
410            graph.insert(fd.fn_id, edges);
411        }
412    }
413    // Mutual-recursion set: members of a multi-element SCC. Self-edges were
414    // excluded when building `graph` above, so a self-recursive fn is a
415    // 1-element component and is intentionally NOT reported here. The Tarjan
416    // core is shared with `call_graph` via `crate::scc`.
417    let nodes: Vec<FnId> = graph.keys().copied().collect();
418    crate::scc::mutually_recursive(&nodes, &graph)
419}
420
421// ─── Program-level shape (stage 4 of #232) ───────────────────────────────────
422
423/// Per-fn recognition output: precedence-picked primary archetype +
424/// the full multi-label set the classifier fired on.
425///
426/// Facts (the AST walker output) intentionally don't escape here —
427/// they're cheap to recompute if a future consumer wants them, and
428/// keeping the `ProgramShape` value small means downstream passes
429/// (proof_lower, future inliner / monomorphizer) can clone it freely.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct FnRecognition {
432    pub primary: Archetype,
433    pub labels: Vec<Archetype>,
434}
435
436/// Whole-program shape facts produced by [`analyze_program`].
437///
438/// Stage 4 of issue #232 (0.23 "Shape"). This is the *recognition*
439/// substrate every downstream consumer reads from — `aver shape` CLI,
440/// proof_lower's strategy router, future inliner / monomorphizer.
441/// Stage 6 will grow `patterns: Vec<ModulePattern>` and
442/// `relations: Vec<FnRelation>` next to `per_fn` for the higher-arity
443/// recognitions (`WrapperOverRecursion`, `RefinementSmartConstructor`,
444/// `ResultPipelineChain`, …).
445///
446/// Computed once per compilation per the peer-review note ("compute
447/// ProgramShape once per compilation / per HIR snapshot — don't
448/// persistent-cache per FnId yet"). Threaded as a read-only borrow
449/// from the call site; the analysis tier never mutates it.
450#[derive(Debug, Clone, Default)]
451pub struct ProgramShape {
452    /// Per-fn recognition keyed by stable `FnId`.
453    pub per_fn: std::collections::HashMap<FnId, FnRecognition>,
454    /// Multi-node SCC participants in the local call graph. Kept here
455    /// so consumers don't have to recompute Tarjan to ask
456    /// "is this fn part of a mutual-recursion group?".
457    pub sccs: HashSet<FnId>,
458    /// Whole-module typed patterns (stage 6 of #232). Populated by
459    /// [`analyze_program_with_modules`]; [`analyze_program`] leaves
460    /// this empty since it only sees the resolved-fn snapshot, not
461    /// the source items needed to detect module-level shapes like
462    /// `RefinementSmartConstructor`.
463    pub patterns: Vec<ModulePattern>,
464    /// Source-level sum type names that are eligible as induction
465    /// targets: directly self-referential in at least one variant
466    /// and not indirectly recursive through nested generics the
467    /// per-variant emit can't case-split (e.g. `Some(List<Self>)`).
468    /// Mirrors `proof_lower::detect_induction_target`'s inline
469    /// scan so the detector can read this set instead of
470    /// re-walking type defs.
471    pub inductable_sum_types: HashSet<String>,
472}
473
474impl ProgramShape {
475    /// Recognition for one fn by id. Returns `None` for fns that
476    /// weren't included in the call to [`analyze_program`] (e.g. an
477    /// out-of-tree id or a stale lookup against a refreshed view).
478    pub fn for_fn(&self, fn_id: FnId) -> Option<&FnRecognition> {
479        self.per_fn.get(&fn_id)
480    }
481}
482
483/// Build a [`ProgramShape`] over the post-resolver fn snapshot in one
484/// pass. Caller picks which fns participate (typically every
485/// `ResolvedTopLevel::FnDef` of the entry module, sometimes plus
486/// dep-module fns when a cross-module analysis needs the broader
487/// view).
488///
489/// Two-pass internally: facts first (needed to build the call graph
490/// for SCC detection), then classify with the facts + SCC ready.
491/// `O(N)` over fn bodies, `O(N+E)` Tarjan over the call graph; the
492/// per-compilation cache budget the peer-review pinned.
493pub fn analyze_program(resolved_fns: &[&ResolvedFnDef]) -> ProgramShape {
494    let mut facts_by_id: std::collections::HashMap<FnId, Facts> =
495        std::collections::HashMap::with_capacity(resolved_fns.len());
496    for fd in resolved_fns {
497        facts_by_id.insert(fd.fn_id, extract_facts(fd));
498    }
499    let facts_refs: std::collections::HashMap<FnId, &Facts> =
500        facts_by_id.iter().map(|(k, v)| (*k, v)).collect();
501    let sccs = compute_sccs(resolved_fns, &facts_refs);
502
503    let mut per_fn = std::collections::HashMap::with_capacity(resolved_fns.len());
504    for fd in resolved_fns {
505        let facts = &facts_by_id[&fd.fn_id];
506        let labels = classify(fd, facts, &sccs);
507        let primary = primary_label(&labels);
508        per_fn.insert(fd.fn_id, FnRecognition { primary, labels });
509    }
510
511    ProgramShape {
512        per_fn,
513        sccs,
514        patterns: Vec::new(),
515        inductable_sum_types: HashSet::new(),
516    }
517}
518
519/// Same as [`analyze_program`] but also detects module-level patterns
520/// (`ModulePattern::RefinementSmartConstructor`, …) by walking the
521/// source `items` and dep modules. Callers that have both the
522/// resolved-fn snapshot and the source items should prefer this.
523pub fn analyze_program_with_modules(
524    resolved_fns: &[&ResolvedFnDef],
525    entry_items: &[crate::ast::TopLevel],
526    dep_modules: &[crate::codegen::ModuleInfo],
527) -> ProgramShape {
528    let mut shape = analyze_program(resolved_fns);
529    shape.patterns = detect_module_patterns(entry_items, dep_modules);
530    shape.inductable_sum_types = collect_inductable_sum_types(entry_items, dep_modules);
531    shape
532}
533
534// ─── Module-level typed patterns (stage 6 of #232) ───────────────────────────
535
536/// A `ModulePattern` is a *recognized structural fact* about a whole
537/// module's surface — the level above per-fn archetypes — carrying the
538/// **typed payload** downstream consumers need to act on it.
539///
540/// The first variant is `RefinementSmartConstructor`, the canonical
541/// `refinement-via-opaque` shape (single-field record + validating
542/// smart constructor) the proof export already recognizes via
543/// [`crate::codegen::common::refinement_info_for`]. Stage 6 lifts the
544/// recognition into the analysis tier so other consumers (`aver shape`
545/// LSP, future inliner, monomorphizer) don't each re-walk the AST to
546/// ask the same question.
547///
548/// Peer-review note from issue #232: "kind == SmartConstructor is too
549/// compressed to be source of truth for proof routing — proof needs
550/// typed payload". This enum carries that payload (carrier field +
551/// type, constructor fn name, predicate expression).
552///
553/// Stage 6a (this commit) only **detects** the pattern; the proof
554/// export still walks via the legacy `refinement_info_for` API.
555/// Stage 6b refactors that fn into a thin adapter over
556/// `ProgramShape::patterns`. Stage 6c+ adds the next pattern
557/// (`WrapperOverRecursion`, `ResultPipelineChain`, …).
558#[derive(Debug, Clone)]
559pub enum ModulePattern {
560    /// `refinement-via-opaque` shape: a single-field
561    /// `record T { <carrier_field>: <carrier_type> }` paired with a
562    /// validating smart constructor
563    ///   `fn <constructor_fn>(<param_name>: <carrier_type>) -> Result<T, _>`
564    ///   `    match <predicate>`
565    ///   `        true  -> Result.Ok(T(<carrier_field> = <param_name>))`
566    ///   `        false -> Result.Err("...")`
567    RefinementSmartConstructor {
568        /// Where this pattern lives: `None` = entry items,
569        /// `Some(prefix)` = dep module with that prefix. Lets the
570        /// scope-aware adapter (`refinement_info_for_in_scope`) pick
571        /// the predicate from the right module when two modules
572        /// declare a refined record with the same bare name
573        /// (e.g. `A.Natural` vs `B.Natural`).
574        scope: Option<String>,
575        /// Source-level type name (`"Natural"`, `"Positive"`, …).
576        /// FnId / TypeId migration deferred — name keys match what
577        /// the current `refinement_info_for` adapter uses.
578        type_name: String,
579        /// Carrier-field name (e.g. `"value"`). Lean projects through
580        /// `.val` on a `Subtype`; this is the field that gets renamed
581        /// in the lifted view.
582        carrier_field: String,
583        /// Carrier type annotation as written in the record field
584        /// (`"Int"`, `"Float"`, …). Backends emit it as the subset's
585        /// underlying type.
586        carrier_type: String,
587        /// Source-level name of the smart constructor (`"fromInt"`).
588        constructor_fn: String,
589        /// Parameter name on the smart constructor signature
590        /// (`"n"` in `fromInt(n: Int) -> Result<Natural, _>`). Used
591        /// when substituting the law's quantified variable into the
592        /// predicate.
593        param_name: String,
594        /// Cloned bool predicate the smart constructor branches on —
595        /// the body's `match <predicate>` subject. Owned so
596        /// `ProgramShape` doesn't borrow source items.
597        predicate: crate::ast::Spanned<crate::ast::Expr>,
598    },
599    /// `wrapper-over-recursion` shape: a non-recursive `wrapper_fn` whose
600    /// body's only recursive call is to a self-recursive `inner_fn`
601    /// living in the same scope, with `inner_fn` taking the wrapper's
602    /// parameters as a prefix (literally, as `Ident` args) plus at
603    /// least one additional argument (typically an accumulator initial
604    /// value). `fib(n) -> fibTR(n, 0, 1)` is the canonical example;
605    /// `aver fmt` / proof export use this to route the wrapper through
606    /// the inner's induction certificate.
607    ///
608    /// Conservative detection rules (stage 6c):
609    /// - wrapper is itself non-recursive (no self-call)
610    /// - exactly one inner call to a self-recursive same-scope fn
611    /// - every wrapper parameter appears literally (`Ident`) somewhere
612    ///   in the inner's argument list
613    /// - inner's arity is strictly greater than the wrapper's arity
614    ///
615    /// These rules keep false positives near zero on the shipped
616    /// corpus; mutual recursion across fns isn't claimed yet
617    /// (`inner_fn` must self-recurse, not participate in a larger SCC).
618    WrapperOverRecursion {
619        /// Scope of the wrapper (`None` = entry, `Some(prefix)` = dep
620        /// module). `inner_scope` is always equal to `wrapper_scope`
621        /// in stage 6c — cross-module wrappers aren't claimed.
622        wrapper_scope: Option<String>,
623        /// Source-level wrapper fn name (the outer, non-recursive one).
624        wrapper_fn: String,
625        /// Scope of the recursive inner fn. Mirrors `wrapper_scope`
626        /// while stage 6c keeps this same-scope-only.
627        inner_scope: Option<String>,
628        /// Source-level inner fn name (the recursive one).
629        inner_fn: String,
630    },
631    /// `?`-propagating Result pipeline: a fn whose body is a sequence
632    /// of `let x = step()?` bindings followed by a tail expression
633    /// (typically `Result.Ok(final)`). Canonical example:
634    /// `examples/core/result_pipeline.av::validateAndCombine` — six
635    /// `?` steps that short-circuit on the first Err.
636    ///
637    /// Detection rules (stage 6d):
638    /// - fn return type starts with `Result<`
639    /// - body has at least two `Stmt::Binding` whose value is
640    ///   `Expr::ErrorProp(...)` (the `?` operator)
641    /// - the tail stmt is an expression, not a binding
642    ///
643    /// `step_count` is the number of `?` bindings; downstream
644    /// consumers can use it to size the staged result type or to
645    /// pick between inlined and trampoline lowerings. No proof-export
646    /// consumer yet — this is substrate-only.
647    ResultPipelineChain {
648        scope: Option<String>,
649        fn_name: String,
650        step_count: usize,
651        /// Source names of the step fns called via `?` in body
652        /// order. Captured here because the post-pipeline AST
653        /// desugars `?` into nested `match` arms — downstream
654        /// consumers that need the original step list (e.g. the
655        /// proof_lower `ResultPipelineChain` strategy) read from
656        /// this field instead of re-walking.
657        step_fns: Vec<String>,
658    },
659    /// Non-recursive pure renderer: a fn whose return type is `String`,
660    /// effects list is empty, and body contains an `InterpolatedStr`
661    /// or a `String`-typed `+` concatenation. Canonical examples are
662    /// `examples/data/rle.av::showRun` (single interpolation) and the
663    /// `show*` family in `examples/data/fibonacci.av`.
664    ///
665    /// Detection rules (stage 6e):
666    /// - return type is exactly `String`
667    /// - effects list is empty
668    /// - fn does not call itself anywhere in its body
669    /// - body contains at least one `Expr::InterpolatedStr` or
670    ///   `Expr::BinOp(Add, ..)` reachable through nesting
671    ///
672    /// Recursive structural renderers (`showRuns`, `showListIntInner`)
673    /// are intentionally excluded — they belong to a future
674    /// `StructuralRenderer` pattern paired with structural induction.
675    RendererFormatter {
676        scope: Option<String>,
677        fn_name: String,
678    },
679    /// Self-recursive structural fold over a `List<T>` parameter:
680    /// fn body is a single `match <param>` with at minimum
681    /// `[] -> ...` and `[head, ..tail] -> ...` arms, and the fn
682    /// calls itself somewhere in its body (typically passing
683    /// `tail` to recur). `nthOrZero(xs, index)` from
684    /// `examples/data/fibonacci.av` is the canonical example.
685    ///
686    /// Detection rules (stage 6f):
687    /// - body is a single `Stmt::Expr(Match)`
688    /// - subject is `Ident(p)` where `p` is one of the fn's params
689    /// - arms include both `Pattern::EmptyList` and `Pattern::Cons`
690    /// - fn is self-recursive
691    ///
692    /// Aver's stdlib has no `List.map/fold`, so this hand-rolled
693    /// structural fold shows up across the corpus. Recognizing it
694    /// unlocks two future moves: list-induction proof obligation
695    /// emission, and a deforestation rewrite that fuses the fold
696    /// with its consumer.
697    MatchDispatcherFold {
698        scope: Option<String>,
699        fn_name: String,
700        list_param: String,
701    },
702}
703
704/// Walk entry items + dep modules and collect the names of sum types
705/// that pass the proof-export induction eligibility check: directly
706/// self-referential in at least one variant, and not indirectly
707/// recursive through nested generics that the per-variant emit
708/// would have to give up on. Mirrors the inline scan that
709/// `proof_lower::detect_induction_target` used to perform so the
710/// detector can read from `ProgramShape.inductable_sum_types` and
711/// avoid re-walking the AST.
712pub fn collect_inductable_sum_types(
713    entry_items: &[crate::ast::TopLevel],
714    dep_modules: &[crate::codegen::ModuleInfo],
715) -> HashSet<String> {
716    use crate::ast::{TopLevel, TypeDef};
717    let mut out = HashSet::new();
718    let mut consider = |td: &TypeDef| {
719        if let TypeDef::Sum { name, variants, .. } = td
720            && crate::codegen::common::is_recursive_sum(name, variants)
721            && !indirect_rec_variants(variants, name)
722        {
723            out.insert(name.clone());
724        }
725    };
726    for item in entry_items {
727        if let TopLevel::TypeDef(td) = item {
728            consider(td);
729        }
730    }
731    for m in dep_modules {
732        for td in &m.type_defs {
733            consider(td);
734        }
735    }
736    out
737}
738
739/// Mirror of `proof_lower::has_indirect_rec_variants`: a variant
740/// field that contains `type_name` nested past one `<` is rejected
741/// because the per-variant induction case-split can't decompose it.
742fn indirect_rec_variants(variants: &[crate::ast::TypeVariant], type_name: &str) -> bool {
743    for variant in variants {
744        for field in &variant.fields {
745            let f = field.trim();
746            if f == type_name {
747                continue;
748            }
749            let opens = f.matches('<').count();
750            if opens > 1 && f.contains(type_name) {
751                return true;
752            }
753        }
754    }
755    false
756}
757
758/// Walk entry items + dep modules and emit every typed
759/// `ModulePattern` we can recognize. Used by `analyze_program_with_modules`
760/// to populate `ProgramShape.patterns`.
761///
762/// Mirrors the recognition rules in
763/// `codegen::common::refinement_info_for_walk` so downstream consumers
764/// see the same set of refinement records; Stage 6b will retire the
765/// legacy fn and route through this output.
766pub fn detect_module_patterns(
767    entry_items: &[crate::ast::TopLevel],
768    dep_modules: &[crate::codegen::ModuleInfo],
769) -> Vec<ModulePattern> {
770    use crate::ast::{Expr, Stmt, TopLevel, TypeDef};
771
772    let mut out = Vec::new();
773
774    // Per-scope candidate records. `scope = None` = entry items,
775    // `scope = Some(prefix)` = dep module. The smart-constructor
776    // walk searches inside the same scope as the record — that's the
777    // `refinement-via-opaque` invariant (constructor lives next to
778    // the carrier field, since `exposes opaque` hides the field from
779    // other modules).
780    struct CandidateRecord<'a> {
781        scope: Option<String>,
782        type_name: &'a str,
783        carrier_field: &'a str,
784        carrier_type: &'a str,
785        fns: Vec<&'a crate::ast::FnDef>,
786    }
787
788    let entry_fns: Vec<&crate::ast::FnDef> = entry_items
789        .iter()
790        .filter_map(|i| match i {
791            TopLevel::FnDef(fd) => Some(fd),
792            _ => None,
793        })
794        .collect();
795
796    let mut candidates: Vec<CandidateRecord<'_>> = Vec::new();
797    for td in entry_items.iter().filter_map(|i| match i {
798        TopLevel::TypeDef(td) => Some(td),
799        _ => None,
800    }) {
801        if let TypeDef::Product { name, fields, .. } = td
802            && fields.len() == 1
803        {
804            let (fname, ftype) = &fields[0];
805            candidates.push(CandidateRecord {
806                scope: None,
807                type_name: name.as_str(),
808                carrier_field: fname.as_str(),
809                carrier_type: ftype.as_str(),
810                fns: entry_fns.clone(),
811            });
812        }
813    }
814    for m in dep_modules {
815        let module_fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
816        for td in &m.type_defs {
817            if let TypeDef::Product { name, fields, .. } = td
818                && fields.len() == 1
819            {
820                let (fname, ftype) = &fields[0];
821                candidates.push(CandidateRecord {
822                    scope: Some(m.prefix.clone()),
823                    type_name: name.as_str(),
824                    carrier_field: fname.as_str(),
825                    carrier_type: ftype.as_str(),
826                    fns: module_fns.clone(),
827                });
828            }
829        }
830    }
831
832    // Walk fns looking for the smart-constructor shape per candidate
833    // record. Mirrors `refinement_info_for_walk`: return type
834    // `Result<TypeName, _>`, exactly one param, body is a single
835    // `match <pred>` with two arms, bool-shaped `true -> Ok` /
836    // `false -> Err` referencing the carrier field + param. The fn
837    // must live in the same scope as the carrier record.
838    for candidate in &candidates {
839        let CandidateRecord {
840            scope,
841            type_name,
842            carrier_field,
843            carrier_type,
844            fns,
845        } = candidate;
846        for fd in fns {
847            if !fd.return_type.starts_with("Result<") {
848                continue;
849            }
850            if !fd.return_type[7..].starts_with(*type_name) {
851                continue;
852            }
853            if fd.params.len() != 1 {
854                continue;
855            }
856            let (param_name, _) = &fd.params[0];
857            let stmts = fd.body.stmts();
858            if stmts.len() != 1 {
859                continue;
860            }
861            let Stmt::Expr(body_expr) = &stmts[0] else {
862                continue;
863            };
864            let Expr::Match { subject, arms } = &body_expr.node else {
865                continue;
866            };
867            if !crate::codegen::common::is_refinement_bool_ok_err_match(
868                arms,
869                type_name,
870                carrier_field,
871                param_name,
872            ) {
873                continue;
874            }
875            out.push(ModulePattern::RefinementSmartConstructor {
876                scope: scope.clone(),
877                type_name: (*type_name).to_string(),
878                carrier_field: (*carrier_field).to_string(),
879                carrier_type: (*carrier_type).to_string(),
880                constructor_fn: fd.name.clone(),
881                param_name: param_name.clone(),
882                predicate: (**subject).clone(),
883            });
884            break;
885        }
886    }
887
888    // Stage 6c of #232: `WrapperOverRecursion` per-scope detection.
889    // Each scope is searched independently (entry items, then each
890    // dep module) — cross-module wrappers aren't claimed yet.
891    detect_wrapper_over_recursion(None, &entry_fns, &mut out);
892    for m in dep_modules {
893        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
894        detect_wrapper_over_recursion(Some(m.prefix.clone()), &fns, &mut out);
895    }
896
897    // Stage 6d of #232: `ResultPipelineChain` per-scope detection.
898    detect_result_pipeline_chain(None, &entry_fns, &mut out);
899    for m in dep_modules {
900        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
901        detect_result_pipeline_chain(Some(m.prefix.clone()), &fns, &mut out);
902    }
903
904    // Stage 6e of #232: `RendererFormatter` per-scope detection.
905    detect_renderer_formatter(None, &entry_fns, &mut out);
906    for m in dep_modules {
907        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
908        detect_renderer_formatter(Some(m.prefix.clone()), &fns, &mut out);
909    }
910
911    // Stage 6f of #232: `MatchDispatcherFold` per-scope detection.
912    detect_match_dispatcher_fold(None, &entry_fns, &mut out);
913    for m in dep_modules {
914        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
915        detect_match_dispatcher_fold(Some(m.prefix.clone()), &fns, &mut out);
916    }
917
918    out
919}
920
921/// Per-scope detector for [`ModulePattern::MatchDispatcherFold`]. See
922/// the variant docs for the detection contract.
923fn detect_match_dispatcher_fold(
924    scope: Option<String>,
925    fns: &[&crate::ast::FnDef],
926    out: &mut Vec<ModulePattern>,
927) {
928    use crate::ast::{Expr, Pattern, Stmt};
929    for fd in fns {
930        let stmts = fd.body.stmts();
931        if stmts.len() != 1 {
932            continue;
933        }
934        let Stmt::Expr(body_expr) = &stmts[0] else {
935            continue;
936        };
937        let Expr::Match { subject, arms } = &body_expr.node else {
938            continue;
939        };
940        // Pre-pipeline: subject is `Expr::Ident(name)`. Post-pipeline:
941        // the resolver rewrites local/param idents to
942        // `Expr::Resolved { name, .. }`. Both forms identify the
943        // matched parameter.
944        let subj_name = match &subject.node {
945            Expr::Ident(n) => n.as_str(),
946            Expr::Resolved { name, .. } => name.as_str(),
947            _ => continue,
948        };
949        if !fd.params.iter().any(|(n, _)| n == subj_name) {
950            continue;
951        }
952        let has_nil = arms.iter().any(|a| matches!(a.pattern, Pattern::EmptyList));
953        let has_cons = arms
954            .iter()
955            .any(|a| matches!(a.pattern, Pattern::Cons(_, _)));
956        if !(has_nil && has_cons) {
957            continue;
958        }
959        if !body_calls_name(&fd.body, &fd.name) {
960            continue;
961        }
962        out.push(ModulePattern::MatchDispatcherFold {
963            scope: scope.clone(),
964            fn_name: fd.name.clone(),
965            list_param: subj_name.to_string(),
966        });
967    }
968}
969
970/// Per-scope detector for [`ModulePattern::RendererFormatter`]. See
971/// the variant docs for the detection contract.
972fn detect_renderer_formatter(
973    scope: Option<String>,
974    fns: &[&crate::ast::FnDef],
975    out: &mut Vec<ModulePattern>,
976) {
977    for fd in fns {
978        if fd.return_type != "String" {
979            continue;
980        }
981        if !fd.effects.is_empty() {
982            continue;
983        }
984        if body_calls_name(&fd.body, &fd.name) {
985            continue;
986        }
987        if !body_has_string_building(&fd.body) {
988            continue;
989        }
990        out.push(ModulePattern::RendererFormatter {
991            scope: scope.clone(),
992            fn_name: fd.name.clone(),
993        });
994    }
995}
996
997/// Walk `body` looking for any `Expr::InterpolatedStr` or string-typed
998/// `+` (`BinOp(Add, ..)`). Conservative: any addition counts since
999/// the typechecker has already restricted what `+` can do at a
1000/// `String`-returning callsite — false positives here would be
1001/// numeric adds that never reach the return slot, which the
1002/// `return_type == "String"` guard already excludes for trivial
1003/// arithmetic-only bodies.
1004fn body_has_string_building(body: &crate::ast::FnBody) -> bool {
1005    for stmt in body.stmts() {
1006        let expr = match stmt {
1007            crate::ast::Stmt::Binding(_, _, e) => e,
1008            crate::ast::Stmt::Expr(e) => e,
1009        };
1010        if expr_has_string_building(expr) {
1011            return true;
1012        }
1013    }
1014    false
1015}
1016
1017fn expr_has_string_building(expr: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
1018    use crate::ast::Expr;
1019    match &expr.node {
1020        Expr::InterpolatedStr(_) => true,
1021        Expr::BinOp(crate::ast::BinOp::Add, _, _) => true,
1022        Expr::FnCall(callee, args) => {
1023            expr_has_string_building(callee) || args.iter().any(expr_has_string_building)
1024        }
1025        Expr::TailCall(td) => td.args.iter().any(expr_has_string_building),
1026        Expr::Match { subject, arms } => {
1027            expr_has_string_building(subject)
1028                || arms.iter().any(|a| expr_has_string_building(&a.body))
1029        }
1030        Expr::BinOp(_, l, r) => expr_has_string_building(l) || expr_has_string_building(r),
1031        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_has_string_building(e),
1032        Expr::Constructor(_, Some(e)) => expr_has_string_building(e),
1033        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1034            xs.iter().any(expr_has_string_building)
1035        }
1036        Expr::MapLiteral(pairs) => pairs
1037            .iter()
1038            .any(|(k, v)| expr_has_string_building(k) || expr_has_string_building(v)),
1039        Expr::RecordCreate { fields, .. } => {
1040            fields.iter().any(|(_, e)| expr_has_string_building(e))
1041        }
1042        Expr::RecordUpdate { base, updates, .. } => {
1043            expr_has_string_building(base)
1044                || updates.iter().any(|(_, e)| expr_has_string_building(e))
1045        }
1046        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1047            false
1048        }
1049    }
1050}
1051
1052/// Per-scope detector for [`ModulePattern::ResultPipelineChain`].
1053/// Counts `Stmt::Binding(_, _, ErrorProp(...))` (the `?` operator)
1054/// in each fn body; emits the pattern when there are ≥2 such
1055/// bindings, the fn returns `Result<…>`, and the tail stmt is an
1056/// expression (`Stmt::Expr`, not another binding).
1057fn detect_result_pipeline_chain(
1058    scope: Option<String>,
1059    fns: &[&crate::ast::FnDef],
1060    out: &mut Vec<ModulePattern>,
1061) {
1062    use crate::ast::{Expr, Stmt};
1063    for fd in fns {
1064        if !fd.return_type.starts_with("Result<") {
1065            continue;
1066        }
1067        let stmts = fd.body.stmts();
1068        if stmts.len() < 2 {
1069            continue;
1070        }
1071        if !matches!(stmts.last(), Some(Stmt::Expr(_))) {
1072            continue;
1073        }
1074        let mut step_fns: Vec<String> = Vec::new();
1075        for stmt in stmts {
1076            if let Stmt::Binding(_, _, value) = stmt
1077                && let Expr::ErrorProp(inner) = &value.node
1078                && let Expr::FnCall(callee, _) = &inner.node
1079                && let Expr::Ident(name) = &callee.node
1080            {
1081                step_fns.push(name.clone());
1082            }
1083        }
1084        if step_fns.len() < 2 {
1085            continue;
1086        }
1087        let step_count = step_fns.len();
1088        out.push(ModulePattern::ResultPipelineChain {
1089            scope: scope.clone(),
1090            fn_name: fd.name.clone(),
1091            step_count,
1092            step_fns,
1093        });
1094    }
1095}
1096
1097/// Per-scope detector for [`ModulePattern::WrapperOverRecursion`].
1098/// Builds the self-recursive set for `fns` (fns that call themselves
1099/// by name anywhere in their body), then walks each non-recursive fn's
1100/// body looking for exactly one qualifying inner call. See the variant
1101/// docs on `WrapperOverRecursion` for the detection contract.
1102fn detect_wrapper_over_recursion(
1103    scope: Option<String>,
1104    fns: &[&crate::ast::FnDef],
1105    out: &mut Vec<ModulePattern>,
1106) {
1107    if fns.is_empty() {
1108        return;
1109    }
1110
1111    let mut recursive: HashSet<String> = HashSet::new();
1112    for fd in fns {
1113        if body_calls_name(&fd.body, &fd.name) {
1114            recursive.insert(fd.name.clone());
1115        }
1116    }
1117    if recursive.is_empty() {
1118        return;
1119    }
1120
1121    for fd in fns {
1122        if recursive.contains(&fd.name) {
1123            continue;
1124        }
1125        if fd.params.is_empty() {
1126            continue;
1127        }
1128        let outer_params: Vec<&str> = fd.params.iter().map(|(n, _)| n.as_str()).collect();
1129        let mut hits: Vec<String> = Vec::new();
1130        collect_qualifying_inner_calls(&fd.body, &outer_params, &recursive, &mut hits);
1131        hits.sort();
1132        hits.dedup();
1133        if hits.len() != 1 {
1134            continue;
1135        }
1136        let inner = hits.into_iter().next().unwrap();
1137        out.push(ModulePattern::WrapperOverRecursion {
1138            wrapper_scope: scope.clone(),
1139            wrapper_fn: fd.name.clone(),
1140            inner_scope: scope.clone(),
1141            inner_fn: inner,
1142        });
1143    }
1144}
1145
1146/// Whether `body` contains any `FnCall(Ident(name), _)` reachable
1147/// through expression nesting. Used to build the self-recursive set
1148/// and to find qualifying inner calls.
1149fn body_calls_name(body: &crate::ast::FnBody, name: &str) -> bool {
1150    for stmt in body.stmts() {
1151        let expr = match stmt {
1152            crate::ast::Stmt::Binding(_, _, e) => e,
1153            crate::ast::Stmt::Expr(e) => e,
1154        };
1155        if expr_calls_name(expr, name) {
1156            return true;
1157        }
1158    }
1159    false
1160}
1161
1162fn expr_calls_name(expr: &crate::ast::Spanned<crate::ast::Expr>, name: &str) -> bool {
1163    use crate::ast::Expr;
1164    match &expr.node {
1165        Expr::FnCall(callee, args) => {
1166            if let Expr::Ident(n) = &callee.node
1167                && n == name
1168            {
1169                return true;
1170            }
1171            if expr_calls_name(callee, name) {
1172                return true;
1173            }
1174            args.iter().any(|a| expr_calls_name(a, name))
1175        }
1176        Expr::TailCall(td) => td.target == name || td.args.iter().any(|a| expr_calls_name(a, name)),
1177        Expr::Match { subject, arms } => {
1178            if expr_calls_name(subject, name) {
1179                return true;
1180            }
1181            arms.iter().any(|a| expr_calls_name(&a.body, name))
1182        }
1183        Expr::BinOp(_, l, r) => expr_calls_name(l, name) || expr_calls_name(r, name),
1184        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_calls_name(e, name),
1185        Expr::Constructor(_, Some(e)) => expr_calls_name(e, name),
1186        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1187            xs.iter().any(|x| expr_calls_name(x, name))
1188        }
1189        Expr::MapLiteral(pairs) => pairs
1190            .iter()
1191            .any(|(k, v)| expr_calls_name(k, name) || expr_calls_name(v, name)),
1192        Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| expr_calls_name(e, name)),
1193        Expr::RecordUpdate { base, updates, .. } => {
1194            expr_calls_name(base, name) || updates.iter().any(|(_, e)| expr_calls_name(e, name))
1195        }
1196        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1197            crate::ast::StrPart::Parsed(e) => expr_calls_name(e, name),
1198            crate::ast::StrPart::Literal(_) => false,
1199        }),
1200        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1201            false
1202        }
1203    }
1204}
1205
1206/// Walk `body` and push every inner-fn name that satisfies the
1207/// `WrapperOverRecursion` qualification rules: callee is a same-scope
1208/// self-recursive fn in `recursive`, arity strictly greater than
1209/// `outer_params.len()`, and every outer-param name appears literally
1210/// as an `Ident` argument somewhere in the call's argument list.
1211fn collect_qualifying_inner_calls(
1212    body: &crate::ast::FnBody,
1213    outer_params: &[&str],
1214    recursive: &HashSet<String>,
1215    out: &mut Vec<String>,
1216) {
1217    for stmt in body.stmts() {
1218        let expr = match stmt {
1219            crate::ast::Stmt::Binding(_, _, e) => e,
1220            crate::ast::Stmt::Expr(e) => e,
1221        };
1222        collect_qualifying_in_expr(expr, outer_params, recursive, out);
1223    }
1224}
1225
1226fn collect_qualifying_in_expr(
1227    expr: &crate::ast::Spanned<crate::ast::Expr>,
1228    outer_params: &[&str],
1229    recursive: &HashSet<String>,
1230    out: &mut Vec<String>,
1231) {
1232    use crate::ast::Expr;
1233    let try_qualify = |callee: &str, args: &[crate::ast::Spanned<Expr>], out: &mut Vec<String>| {
1234        if !recursive.contains(callee) {
1235            return;
1236        }
1237        if args.len() <= outer_params.len() {
1238            return;
1239        }
1240        // Pre-pipeline args are `Expr::Ident(name)`; post-pipeline the
1241        // resolver rewrites local/param idents to
1242        // `Expr::Resolved { name, .. }`. Both shapes need to count.
1243        let mut arg_idents: HashSet<&str> = HashSet::new();
1244        for a in args {
1245            match &a.node {
1246                Expr::Ident(n) => {
1247                    arg_idents.insert(n.as_str());
1248                }
1249                Expr::Resolved { name, .. } => {
1250                    arg_idents.insert(name.as_str());
1251                }
1252                _ => {}
1253            }
1254        }
1255        if outer_params.iter().all(|p| arg_idents.contains(*p)) {
1256            out.push(callee.to_string());
1257        }
1258    };
1259    if let Expr::FnCall(callee, args) = &expr.node
1260        && let Expr::Ident(name) = &callee.node
1261    {
1262        try_qualify(name, args, out);
1263    }
1264    // Post-pipeline AST: tail-position calls become `TailCall`,
1265    // which loses the `FnCall(Ident, ...)` wrapper. The
1266    // `fib(n) -> fibTR(n, 0, 1)` shape is a typical case — pipeline
1267    // recognizes the tail call inside the `match` arm even though
1268    // `fib` itself isn't recursive.
1269    if let Expr::TailCall(td) = &expr.node {
1270        try_qualify(&td.target, &td.args, out);
1271    }
1272    match &expr.node {
1273        Expr::FnCall(callee, args) => {
1274            collect_qualifying_in_expr(callee, outer_params, recursive, out);
1275            for a in args {
1276                collect_qualifying_in_expr(a, outer_params, recursive, out);
1277            }
1278        }
1279        Expr::Match { subject, arms } => {
1280            collect_qualifying_in_expr(subject, outer_params, recursive, out);
1281            for a in arms {
1282                collect_qualifying_in_expr(&a.body, outer_params, recursive, out);
1283            }
1284        }
1285        Expr::BinOp(_, l, r) => {
1286            collect_qualifying_in_expr(l, outer_params, recursive, out);
1287            collect_qualifying_in_expr(r, outer_params, recursive, out);
1288        }
1289        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => {
1290            collect_qualifying_in_expr(e, outer_params, recursive, out);
1291        }
1292        Expr::Constructor(_, Some(e)) => {
1293            collect_qualifying_in_expr(e, outer_params, recursive, out);
1294        }
1295        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1296            for x in xs {
1297                collect_qualifying_in_expr(x, outer_params, recursive, out);
1298            }
1299        }
1300        Expr::MapLiteral(pairs) => {
1301            for (k, v) in pairs {
1302                collect_qualifying_in_expr(k, outer_params, recursive, out);
1303                collect_qualifying_in_expr(v, outer_params, recursive, out);
1304            }
1305        }
1306        Expr::RecordCreate { fields, .. } => {
1307            for (_, e) in fields {
1308                collect_qualifying_in_expr(e, outer_params, recursive, out);
1309            }
1310        }
1311        Expr::RecordUpdate { base, updates, .. } => {
1312            collect_qualifying_in_expr(base, outer_params, recursive, out);
1313            for (_, e) in updates {
1314                collect_qualifying_in_expr(e, outer_params, recursive, out);
1315            }
1316        }
1317        Expr::InterpolatedStr(parts) => {
1318            for p in parts {
1319                if let crate::ast::StrPart::Parsed(e) = p {
1320                    collect_qualifying_in_expr(e, outer_params, recursive, out);
1321                }
1322            }
1323        }
1324        Expr::TailCall(td) => {
1325            for a in &td.args {
1326                collect_qualifying_in_expr(a, outer_params, recursive, out);
1327            }
1328        }
1329        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {}
1330    }
1331}