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    let mut index = 0u32;
414    let mut stack: Vec<FnId> = Vec::new();
415    let mut on_stack: HashSet<FnId> = HashSet::new();
416    let mut indices: HashMap<FnId, u32> = HashMap::new();
417    let mut lowlinks: HashMap<FnId, u32> = HashMap::new();
418    let mut multi_scc: HashSet<FnId> = HashSet::new();
419
420    #[allow(clippy::too_many_arguments)]
421    fn strongconnect(
422        v: FnId,
423        graph: &HashMap<FnId, Vec<FnId>>,
424        index: &mut u32,
425        stack: &mut Vec<FnId>,
426        on_stack: &mut HashSet<FnId>,
427        indices: &mut HashMap<FnId, u32>,
428        lowlinks: &mut HashMap<FnId, u32>,
429        multi_scc: &mut HashSet<FnId>,
430    ) {
431        indices.insert(v, *index);
432        lowlinks.insert(v, *index);
433        *index += 1;
434        stack.push(v);
435        on_stack.insert(v);
436        if let Some(neighbors) = graph.get(&v).cloned() {
437            for w in neighbors {
438                if !indices.contains_key(&w) {
439                    strongconnect(
440                        w, graph, index, stack, on_stack, indices, lowlinks, multi_scc,
441                    );
442                    let lv = *lowlinks.get(&v).unwrap();
443                    let lw = *lowlinks.get(&w).unwrap();
444                    lowlinks.insert(v, lv.min(lw));
445                } else if on_stack.contains(&w) {
446                    let lv = *lowlinks.get(&v).unwrap();
447                    let iw = *indices.get(&w).unwrap();
448                    lowlinks.insert(v, lv.min(iw));
449                }
450            }
451        }
452        if lowlinks.get(&v) == indices.get(&v) {
453            let mut scc: Vec<FnId> = Vec::new();
454            loop {
455                let w = stack.pop().unwrap();
456                on_stack.remove(&w);
457                scc.push(w);
458                if w == v {
459                    break;
460                }
461            }
462            if scc.len() > 1 {
463                multi_scc.extend(scc);
464            }
465        }
466    }
467
468    let nodes: Vec<FnId> = graph.keys().copied().collect();
469    for v in nodes {
470        if !indices.contains_key(&v) {
471            strongconnect(
472                v,
473                &graph,
474                &mut index,
475                &mut stack,
476                &mut on_stack,
477                &mut indices,
478                &mut lowlinks,
479                &mut multi_scc,
480            );
481        }
482    }
483    multi_scc
484}
485
486// ─── Program-level shape (stage 4 of #232) ───────────────────────────────────
487
488/// Per-fn recognition output: precedence-picked primary archetype +
489/// the full multi-label set the classifier fired on.
490///
491/// Facts (the AST walker output) intentionally don't escape here —
492/// they're cheap to recompute if a future consumer wants them, and
493/// keeping the `ProgramShape` value small means downstream passes
494/// (proof_lower, future inliner / monomorphizer) can clone it freely.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct FnRecognition {
497    pub primary: Archetype,
498    pub labels: Vec<Archetype>,
499}
500
501/// Whole-program shape facts produced by [`analyze_program`].
502///
503/// Stage 4 of issue #232 (0.23 "Shape"). This is the *recognition*
504/// substrate every downstream consumer reads from — `aver shape` CLI,
505/// proof_lower's strategy router, future inliner / monomorphizer.
506/// Stage 6 will grow `patterns: Vec<ModulePattern>` and
507/// `relations: Vec<FnRelation>` next to `per_fn` for the higher-arity
508/// recognitions (`WrapperOverRecursion`, `RefinementSmartConstructor`,
509/// `ResultPipelineChain`, …).
510///
511/// Computed once per compilation per the peer-review note ("compute
512/// ProgramShape once per compilation / per HIR snapshot — don't
513/// persistent-cache per FnId yet"). Threaded as a read-only borrow
514/// from the call site; the analysis tier never mutates it.
515#[derive(Debug, Clone, Default)]
516pub struct ProgramShape {
517    /// Per-fn recognition keyed by stable `FnId`.
518    pub per_fn: std::collections::HashMap<FnId, FnRecognition>,
519    /// Multi-node SCC participants in the local call graph. Kept here
520    /// so consumers don't have to recompute Tarjan to ask
521    /// "is this fn part of a mutual-recursion group?".
522    pub sccs: HashSet<FnId>,
523    /// Whole-module typed patterns (stage 6 of #232). Populated by
524    /// [`analyze_program_with_modules`]; [`analyze_program`] leaves
525    /// this empty since it only sees the resolved-fn snapshot, not
526    /// the source items needed to detect module-level shapes like
527    /// `RefinementSmartConstructor`.
528    pub patterns: Vec<ModulePattern>,
529    /// Source-level sum type names that are eligible as induction
530    /// targets: directly self-referential in at least one variant
531    /// and not indirectly recursive through nested generics the
532    /// per-variant emit can't case-split (e.g. `Some(List<Self>)`).
533    /// Mirrors `proof_lower::detect_induction_target`'s inline
534    /// scan so the detector can read this set instead of
535    /// re-walking type defs.
536    pub inductable_sum_types: HashSet<String>,
537}
538
539impl ProgramShape {
540    /// Recognition for one fn by id. Returns `None` for fns that
541    /// weren't included in the call to [`analyze_program`] (e.g. an
542    /// out-of-tree id or a stale lookup against a refreshed view).
543    pub fn for_fn(&self, fn_id: FnId) -> Option<&FnRecognition> {
544        self.per_fn.get(&fn_id)
545    }
546}
547
548/// Build a [`ProgramShape`] over the post-resolver fn snapshot in one
549/// pass. Caller picks which fns participate (typically every
550/// `ResolvedTopLevel::FnDef` of the entry module, sometimes plus
551/// dep-module fns when a cross-module analysis needs the broader
552/// view).
553///
554/// Two-pass internally: facts first (needed to build the call graph
555/// for SCC detection), then classify with the facts + SCC ready.
556/// `O(N)` over fn bodies, `O(N+E)` Tarjan over the call graph; the
557/// per-compilation cache budget the peer-review pinned.
558pub fn analyze_program(resolved_fns: &[&ResolvedFnDef]) -> ProgramShape {
559    let mut facts_by_id: std::collections::HashMap<FnId, Facts> =
560        std::collections::HashMap::with_capacity(resolved_fns.len());
561    for fd in resolved_fns {
562        facts_by_id.insert(fd.fn_id, extract_facts(fd));
563    }
564    let facts_refs: std::collections::HashMap<FnId, &Facts> =
565        facts_by_id.iter().map(|(k, v)| (*k, v)).collect();
566    let sccs = compute_sccs(resolved_fns, &facts_refs);
567
568    let mut per_fn = std::collections::HashMap::with_capacity(resolved_fns.len());
569    for fd in resolved_fns {
570        let facts = &facts_by_id[&fd.fn_id];
571        let labels = classify(fd, facts, &sccs);
572        let primary = primary_label(&labels);
573        per_fn.insert(fd.fn_id, FnRecognition { primary, labels });
574    }
575
576    ProgramShape {
577        per_fn,
578        sccs,
579        patterns: Vec::new(),
580        inductable_sum_types: HashSet::new(),
581    }
582}
583
584/// Same as [`analyze_program`] but also detects module-level patterns
585/// (`ModulePattern::RefinementSmartConstructor`, …) by walking the
586/// source `items` and dep modules. Callers that have both the
587/// resolved-fn snapshot and the source items should prefer this.
588pub fn analyze_program_with_modules(
589    resolved_fns: &[&ResolvedFnDef],
590    entry_items: &[crate::ast::TopLevel],
591    dep_modules: &[crate::codegen::ModuleInfo],
592) -> ProgramShape {
593    let mut shape = analyze_program(resolved_fns);
594    shape.patterns = detect_module_patterns(entry_items, dep_modules);
595    shape.inductable_sum_types = collect_inductable_sum_types(entry_items, dep_modules);
596    shape
597}
598
599// ─── Module-level typed patterns (stage 6 of #232) ───────────────────────────
600
601/// A `ModulePattern` is a *recognized structural fact* about a whole
602/// module's surface — the level above per-fn archetypes — carrying the
603/// **typed payload** downstream consumers need to act on it.
604///
605/// The first variant is `RefinementSmartConstructor`, the canonical
606/// `refinement-via-opaque` shape (single-field record + validating
607/// smart constructor) the proof export already recognizes via
608/// [`crate::codegen::common::refinement_info_for`]. Stage 6 lifts the
609/// recognition into the analysis tier so other consumers (`aver shape`
610/// LSP, future inliner, monomorphizer) don't each re-walk the AST to
611/// ask the same question.
612///
613/// Peer-review note from issue #232: "kind == SmartConstructor is too
614/// compressed to be source of truth for proof routing — proof needs
615/// typed payload". This enum carries that payload (carrier field +
616/// type, constructor fn name, predicate expression).
617///
618/// Stage 6a (this commit) only **detects** the pattern; the proof
619/// export still walks via the legacy `refinement_info_for` API.
620/// Stage 6b refactors that fn into a thin adapter over
621/// `ProgramShape::patterns`. Stage 6c+ adds the next pattern
622/// (`WrapperOverRecursion`, `ResultPipelineChain`, …).
623#[derive(Debug, Clone)]
624pub enum ModulePattern {
625    /// `refinement-via-opaque` shape: a single-field
626    /// `record T { <carrier_field>: <carrier_type> }` paired with a
627    /// validating smart constructor
628    ///   `fn <constructor_fn>(<param_name>: <carrier_type>) -> Result<T, _>`
629    ///   `    match <predicate>`
630    ///   `        true  -> Result.Ok(T(<carrier_field> = <param_name>))`
631    ///   `        false -> Result.Err("...")`
632    RefinementSmartConstructor {
633        /// Where this pattern lives: `None` = entry items,
634        /// `Some(prefix)` = dep module with that prefix. Lets the
635        /// scope-aware adapter (`refinement_info_for_in_scope`) pick
636        /// the predicate from the right module when two modules
637        /// declare a refined record with the same bare name
638        /// (e.g. `A.Natural` vs `B.Natural`).
639        scope: Option<String>,
640        /// Source-level type name (`"Natural"`, `"Positive"`, …).
641        /// FnId / TypeId migration deferred — name keys match what
642        /// the current `refinement_info_for` adapter uses.
643        type_name: String,
644        /// Carrier-field name (e.g. `"value"`). Lean projects through
645        /// `.val` on a `Subtype`; this is the field that gets renamed
646        /// in the lifted view.
647        carrier_field: String,
648        /// Carrier type annotation as written in the record field
649        /// (`"Int"`, `"Float"`, …). Backends emit it as the subset's
650        /// underlying type.
651        carrier_type: String,
652        /// Source-level name of the smart constructor (`"fromInt"`).
653        constructor_fn: String,
654        /// Parameter name on the smart constructor signature
655        /// (`"n"` in `fromInt(n: Int) -> Result<Natural, _>`). Used
656        /// when substituting the law's quantified variable into the
657        /// predicate.
658        param_name: String,
659        /// Cloned bool predicate the smart constructor branches on —
660        /// the body's `match <predicate>` subject. Owned so
661        /// `ProgramShape` doesn't borrow source items.
662        predicate: crate::ast::Spanned<crate::ast::Expr>,
663    },
664    /// `wrapper-over-recursion` shape: a non-recursive `wrapper_fn` whose
665    /// body's only recursive call is to a self-recursive `inner_fn`
666    /// living in the same scope, with `inner_fn` taking the wrapper's
667    /// parameters as a prefix (literally, as `Ident` args) plus at
668    /// least one additional argument (typically an accumulator initial
669    /// value). `fib(n) -> fibTR(n, 0, 1)` is the canonical example;
670    /// `aver fmt` / proof export use this to route the wrapper through
671    /// the inner's induction certificate.
672    ///
673    /// Conservative detection rules (stage 6c):
674    /// - wrapper is itself non-recursive (no self-call)
675    /// - exactly one inner call to a self-recursive same-scope fn
676    /// - every wrapper parameter appears literally (`Ident`) somewhere
677    ///   in the inner's argument list
678    /// - inner's arity is strictly greater than the wrapper's arity
679    ///
680    /// These rules keep false positives near zero on the shipped
681    /// corpus; mutual recursion across fns isn't claimed yet
682    /// (`inner_fn` must self-recurse, not participate in a larger SCC).
683    WrapperOverRecursion {
684        /// Scope of the wrapper (`None` = entry, `Some(prefix)` = dep
685        /// module). `inner_scope` is always equal to `wrapper_scope`
686        /// in stage 6c — cross-module wrappers aren't claimed.
687        wrapper_scope: Option<String>,
688        /// Source-level wrapper fn name (the outer, non-recursive one).
689        wrapper_fn: String,
690        /// Scope of the recursive inner fn. Mirrors `wrapper_scope`
691        /// while stage 6c keeps this same-scope-only.
692        inner_scope: Option<String>,
693        /// Source-level inner fn name (the recursive one).
694        inner_fn: String,
695    },
696    /// `?`-propagating Result pipeline: a fn whose body is a sequence
697    /// of `let x = step()?` bindings followed by a tail expression
698    /// (typically `Result.Ok(final)`). Canonical example:
699    /// `examples/core/result_pipeline.av::validateAndCombine` — six
700    /// `?` steps that short-circuit on the first Err.
701    ///
702    /// Detection rules (stage 6d):
703    /// - fn return type starts with `Result<`
704    /// - body has at least two `Stmt::Binding` whose value is
705    ///   `Expr::ErrorProp(...)` (the `?` operator)
706    /// - the tail stmt is an expression, not a binding
707    ///
708    /// `step_count` is the number of `?` bindings; downstream
709    /// consumers can use it to size the staged result type or to
710    /// pick between inlined and trampoline lowerings. No proof-export
711    /// consumer yet — this is substrate-only.
712    ResultPipelineChain {
713        scope: Option<String>,
714        fn_name: String,
715        step_count: usize,
716        /// Source names of the step fns called via `?` in body
717        /// order. Captured here because the post-pipeline AST
718        /// desugars `?` into nested `match` arms — downstream
719        /// consumers that need the original step list (e.g. the
720        /// proof_lower `ResultPipelineChain` strategy) read from
721        /// this field instead of re-walking.
722        step_fns: Vec<String>,
723    },
724    /// Non-recursive pure renderer: a fn whose return type is `String`,
725    /// effects list is empty, and body contains an `InterpolatedStr`
726    /// or a `String`-typed `+` concatenation. Canonical examples are
727    /// `examples/data/rle.av::showRun` (single interpolation) and the
728    /// `show*` family in `examples/data/fibonacci.av`.
729    ///
730    /// Detection rules (stage 6e):
731    /// - return type is exactly `String`
732    /// - effects list is empty
733    /// - fn does not call itself anywhere in its body
734    /// - body contains at least one `Expr::InterpolatedStr` or
735    ///   `Expr::BinOp(Add, ..)` reachable through nesting
736    ///
737    /// Recursive structural renderers (`showRuns`, `showListIntInner`)
738    /// are intentionally excluded — they belong to a future
739    /// `StructuralRenderer` pattern paired with structural induction.
740    RendererFormatter {
741        scope: Option<String>,
742        fn_name: String,
743    },
744    /// Self-recursive structural fold over a `List<T>` parameter:
745    /// fn body is a single `match <param>` with at minimum
746    /// `[] -> ...` and `[head, ..tail] -> ...` arms, and the fn
747    /// calls itself somewhere in its body (typically passing
748    /// `tail` to recur). `nthOrZero(xs, index)` from
749    /// `examples/data/fibonacci.av` is the canonical example.
750    ///
751    /// Detection rules (stage 6f):
752    /// - body is a single `Stmt::Expr(Match)`
753    /// - subject is `Ident(p)` where `p` is one of the fn's params
754    /// - arms include both `Pattern::EmptyList` and `Pattern::Cons`
755    /// - fn is self-recursive
756    ///
757    /// Aver's stdlib has no `List.map/fold`, so this hand-rolled
758    /// structural fold shows up across the corpus. Recognizing it
759    /// unlocks two future moves: list-induction proof obligation
760    /// emission, and a deforestation rewrite that fuses the fold
761    /// with its consumer.
762    MatchDispatcherFold {
763        scope: Option<String>,
764        fn_name: String,
765        list_param: String,
766    },
767}
768
769/// Walk entry items + dep modules and collect the names of sum types
770/// that pass the proof-export induction eligibility check: directly
771/// self-referential in at least one variant, and not indirectly
772/// recursive through nested generics that the per-variant emit
773/// would have to give up on. Mirrors the inline scan that
774/// `proof_lower::detect_induction_target` used to perform so the
775/// detector can read from `ProgramShape.inductable_sum_types` and
776/// avoid re-walking the AST.
777pub fn collect_inductable_sum_types(
778    entry_items: &[crate::ast::TopLevel],
779    dep_modules: &[crate::codegen::ModuleInfo],
780) -> HashSet<String> {
781    use crate::ast::{TopLevel, TypeDef};
782    let mut out = HashSet::new();
783    let mut consider = |td: &TypeDef| {
784        if let TypeDef::Sum { name, variants, .. } = td
785            && crate::codegen::common::is_recursive_sum(name, variants)
786            && !indirect_rec_variants(variants, name)
787        {
788            out.insert(name.clone());
789        }
790    };
791    for item in entry_items {
792        if let TopLevel::TypeDef(td) = item {
793            consider(td);
794        }
795    }
796    for m in dep_modules {
797        for td in &m.type_defs {
798            consider(td);
799        }
800    }
801    out
802}
803
804/// Mirror of `proof_lower::has_indirect_rec_variants`: a variant
805/// field that contains `type_name` nested past one `<` is rejected
806/// because the per-variant induction case-split can't decompose it.
807fn indirect_rec_variants(variants: &[crate::ast::TypeVariant], type_name: &str) -> bool {
808    for variant in variants {
809        for field in &variant.fields {
810            let f = field.trim();
811            if f == type_name {
812                continue;
813            }
814            let opens = f.matches('<').count();
815            if opens > 1 && f.contains(type_name) {
816                return true;
817            }
818        }
819    }
820    false
821}
822
823/// Walk entry items + dep modules and emit every typed
824/// `ModulePattern` we can recognize. Used by `analyze_program_with_modules`
825/// to populate `ProgramShape.patterns`.
826///
827/// Mirrors the recognition rules in
828/// `codegen::common::refinement_info_for_walk` so downstream consumers
829/// see the same set of refinement records; Stage 6b will retire the
830/// legacy fn and route through this output.
831pub fn detect_module_patterns(
832    entry_items: &[crate::ast::TopLevel],
833    dep_modules: &[crate::codegen::ModuleInfo],
834) -> Vec<ModulePattern> {
835    use crate::ast::{Expr, Stmt, TopLevel, TypeDef};
836
837    let mut out = Vec::new();
838
839    // Per-scope candidate records. `scope = None` = entry items,
840    // `scope = Some(prefix)` = dep module. The smart-constructor
841    // walk searches inside the same scope as the record — that's the
842    // `refinement-via-opaque` invariant (constructor lives next to
843    // the carrier field, since `exposes opaque` hides the field from
844    // other modules).
845    struct CandidateRecord<'a> {
846        scope: Option<String>,
847        type_name: &'a str,
848        carrier_field: &'a str,
849        carrier_type: &'a str,
850        fns: Vec<&'a crate::ast::FnDef>,
851    }
852
853    let entry_fns: Vec<&crate::ast::FnDef> = entry_items
854        .iter()
855        .filter_map(|i| match i {
856            TopLevel::FnDef(fd) => Some(fd),
857            _ => None,
858        })
859        .collect();
860
861    let mut candidates: Vec<CandidateRecord<'_>> = Vec::new();
862    for td in entry_items.iter().filter_map(|i| match i {
863        TopLevel::TypeDef(td) => Some(td),
864        _ => None,
865    }) {
866        if let TypeDef::Product { name, fields, .. } = td
867            && fields.len() == 1
868        {
869            let (fname, ftype) = &fields[0];
870            candidates.push(CandidateRecord {
871                scope: None,
872                type_name: name.as_str(),
873                carrier_field: fname.as_str(),
874                carrier_type: ftype.as_str(),
875                fns: entry_fns.clone(),
876            });
877        }
878    }
879    for m in dep_modules {
880        let module_fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
881        for td in &m.type_defs {
882            if let TypeDef::Product { name, fields, .. } = td
883                && fields.len() == 1
884            {
885                let (fname, ftype) = &fields[0];
886                candidates.push(CandidateRecord {
887                    scope: Some(m.prefix.clone()),
888                    type_name: name.as_str(),
889                    carrier_field: fname.as_str(),
890                    carrier_type: ftype.as_str(),
891                    fns: module_fns.clone(),
892                });
893            }
894        }
895    }
896
897    // Walk fns looking for the smart-constructor shape per candidate
898    // record. Mirrors `refinement_info_for_walk`: return type
899    // `Result<TypeName, _>`, exactly one param, body is a single
900    // `match <pred>` with two arms, bool-shaped `true -> Ok` /
901    // `false -> Err` referencing the carrier field + param. The fn
902    // must live in the same scope as the carrier record.
903    for candidate in &candidates {
904        let CandidateRecord {
905            scope,
906            type_name,
907            carrier_field,
908            carrier_type,
909            fns,
910        } = candidate;
911        for fd in fns {
912            if !fd.return_type.starts_with("Result<") {
913                continue;
914            }
915            if !fd.return_type[7..].starts_with(*type_name) {
916                continue;
917            }
918            if fd.params.len() != 1 {
919                continue;
920            }
921            let (param_name, _) = &fd.params[0];
922            let stmts = fd.body.stmts();
923            if stmts.len() != 1 {
924                continue;
925            }
926            let Stmt::Expr(body_expr) = &stmts[0] else {
927                continue;
928            };
929            let Expr::Match { subject, arms } = &body_expr.node else {
930                continue;
931            };
932            if !crate::codegen::common::is_refinement_bool_ok_err_match(
933                arms,
934                type_name,
935                carrier_field,
936                param_name,
937            ) {
938                continue;
939            }
940            out.push(ModulePattern::RefinementSmartConstructor {
941                scope: scope.clone(),
942                type_name: (*type_name).to_string(),
943                carrier_field: (*carrier_field).to_string(),
944                carrier_type: (*carrier_type).to_string(),
945                constructor_fn: fd.name.clone(),
946                param_name: param_name.clone(),
947                predicate: (**subject).clone(),
948            });
949            break;
950        }
951    }
952
953    // Stage 6c of #232: `WrapperOverRecursion` per-scope detection.
954    // Each scope is searched independently (entry items, then each
955    // dep module) — cross-module wrappers aren't claimed yet.
956    detect_wrapper_over_recursion(None, &entry_fns, &mut out);
957    for m in dep_modules {
958        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
959        detect_wrapper_over_recursion(Some(m.prefix.clone()), &fns, &mut out);
960    }
961
962    // Stage 6d of #232: `ResultPipelineChain` per-scope detection.
963    detect_result_pipeline_chain(None, &entry_fns, &mut out);
964    for m in dep_modules {
965        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
966        detect_result_pipeline_chain(Some(m.prefix.clone()), &fns, &mut out);
967    }
968
969    // Stage 6e of #232: `RendererFormatter` per-scope detection.
970    detect_renderer_formatter(None, &entry_fns, &mut out);
971    for m in dep_modules {
972        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
973        detect_renderer_formatter(Some(m.prefix.clone()), &fns, &mut out);
974    }
975
976    // Stage 6f of #232: `MatchDispatcherFold` per-scope detection.
977    detect_match_dispatcher_fold(None, &entry_fns, &mut out);
978    for m in dep_modules {
979        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
980        detect_match_dispatcher_fold(Some(m.prefix.clone()), &fns, &mut out);
981    }
982
983    out
984}
985
986/// Per-scope detector for [`ModulePattern::MatchDispatcherFold`]. See
987/// the variant docs for the detection contract.
988fn detect_match_dispatcher_fold(
989    scope: Option<String>,
990    fns: &[&crate::ast::FnDef],
991    out: &mut Vec<ModulePattern>,
992) {
993    use crate::ast::{Expr, Pattern, Stmt};
994    for fd in fns {
995        let stmts = fd.body.stmts();
996        if stmts.len() != 1 {
997            continue;
998        }
999        let Stmt::Expr(body_expr) = &stmts[0] else {
1000            continue;
1001        };
1002        let Expr::Match { subject, arms } = &body_expr.node else {
1003            continue;
1004        };
1005        // Pre-pipeline: subject is `Expr::Ident(name)`. Post-pipeline:
1006        // the resolver rewrites local/param idents to
1007        // `Expr::Resolved { name, .. }`. Both forms identify the
1008        // matched parameter.
1009        let subj_name = match &subject.node {
1010            Expr::Ident(n) => n.as_str(),
1011            Expr::Resolved { name, .. } => name.as_str(),
1012            _ => continue,
1013        };
1014        if !fd.params.iter().any(|(n, _)| n == subj_name) {
1015            continue;
1016        }
1017        let has_nil = arms.iter().any(|a| matches!(a.pattern, Pattern::EmptyList));
1018        let has_cons = arms
1019            .iter()
1020            .any(|a| matches!(a.pattern, Pattern::Cons(_, _)));
1021        if !(has_nil && has_cons) {
1022            continue;
1023        }
1024        if !body_calls_name(&fd.body, &fd.name) {
1025            continue;
1026        }
1027        out.push(ModulePattern::MatchDispatcherFold {
1028            scope: scope.clone(),
1029            fn_name: fd.name.clone(),
1030            list_param: subj_name.to_string(),
1031        });
1032    }
1033}
1034
1035/// Per-scope detector for [`ModulePattern::RendererFormatter`]. See
1036/// the variant docs for the detection contract.
1037fn detect_renderer_formatter(
1038    scope: Option<String>,
1039    fns: &[&crate::ast::FnDef],
1040    out: &mut Vec<ModulePattern>,
1041) {
1042    for fd in fns {
1043        if fd.return_type != "String" {
1044            continue;
1045        }
1046        if !fd.effects.is_empty() {
1047            continue;
1048        }
1049        if body_calls_name(&fd.body, &fd.name) {
1050            continue;
1051        }
1052        if !body_has_string_building(&fd.body) {
1053            continue;
1054        }
1055        out.push(ModulePattern::RendererFormatter {
1056            scope: scope.clone(),
1057            fn_name: fd.name.clone(),
1058        });
1059    }
1060}
1061
1062/// Walk `body` looking for any `Expr::InterpolatedStr` or string-typed
1063/// `+` (`BinOp(Add, ..)`). Conservative: any addition counts since
1064/// the typechecker has already restricted what `+` can do at a
1065/// `String`-returning callsite — false positives here would be
1066/// numeric adds that never reach the return slot, which the
1067/// `return_type == "String"` guard already excludes for trivial
1068/// arithmetic-only bodies.
1069fn body_has_string_building(body: &crate::ast::FnBody) -> bool {
1070    for stmt in body.stmts() {
1071        let expr = match stmt {
1072            crate::ast::Stmt::Binding(_, _, e) => e,
1073            crate::ast::Stmt::Expr(e) => e,
1074        };
1075        if expr_has_string_building(expr) {
1076            return true;
1077        }
1078    }
1079    false
1080}
1081
1082fn expr_has_string_building(expr: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
1083    use crate::ast::Expr;
1084    match &expr.node {
1085        Expr::InterpolatedStr(_) => true,
1086        Expr::BinOp(crate::ast::BinOp::Add, _, _) => true,
1087        Expr::FnCall(callee, args) => {
1088            expr_has_string_building(callee) || args.iter().any(expr_has_string_building)
1089        }
1090        Expr::TailCall(td) => td.args.iter().any(expr_has_string_building),
1091        Expr::Match { subject, arms } => {
1092            expr_has_string_building(subject)
1093                || arms.iter().any(|a| expr_has_string_building(&a.body))
1094        }
1095        Expr::BinOp(_, l, r) => expr_has_string_building(l) || expr_has_string_building(r),
1096        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_has_string_building(e),
1097        Expr::Constructor(_, Some(e)) => expr_has_string_building(e),
1098        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1099            xs.iter().any(expr_has_string_building)
1100        }
1101        Expr::MapLiteral(pairs) => pairs
1102            .iter()
1103            .any(|(k, v)| expr_has_string_building(k) || expr_has_string_building(v)),
1104        Expr::RecordCreate { fields, .. } => {
1105            fields.iter().any(|(_, e)| expr_has_string_building(e))
1106        }
1107        Expr::RecordUpdate { base, updates, .. } => {
1108            expr_has_string_building(base)
1109                || updates.iter().any(|(_, e)| expr_has_string_building(e))
1110        }
1111        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1112            false
1113        }
1114    }
1115}
1116
1117/// Per-scope detector for [`ModulePattern::ResultPipelineChain`].
1118/// Counts `Stmt::Binding(_, _, ErrorProp(...))` (the `?` operator)
1119/// in each fn body; emits the pattern when there are ≥2 such
1120/// bindings, the fn returns `Result<…>`, and the tail stmt is an
1121/// expression (`Stmt::Expr`, not another binding).
1122fn detect_result_pipeline_chain(
1123    scope: Option<String>,
1124    fns: &[&crate::ast::FnDef],
1125    out: &mut Vec<ModulePattern>,
1126) {
1127    use crate::ast::{Expr, Stmt};
1128    for fd in fns {
1129        if !fd.return_type.starts_with("Result<") {
1130            continue;
1131        }
1132        let stmts = fd.body.stmts();
1133        if stmts.len() < 2 {
1134            continue;
1135        }
1136        if !matches!(stmts.last(), Some(Stmt::Expr(_))) {
1137            continue;
1138        }
1139        let mut step_fns: Vec<String> = Vec::new();
1140        for stmt in stmts {
1141            if let Stmt::Binding(_, _, value) = stmt
1142                && let Expr::ErrorProp(inner) = &value.node
1143                && let Expr::FnCall(callee, _) = &inner.node
1144                && let Expr::Ident(name) = &callee.node
1145            {
1146                step_fns.push(name.clone());
1147            }
1148        }
1149        if step_fns.len() < 2 {
1150            continue;
1151        }
1152        let step_count = step_fns.len();
1153        out.push(ModulePattern::ResultPipelineChain {
1154            scope: scope.clone(),
1155            fn_name: fd.name.clone(),
1156            step_count,
1157            step_fns,
1158        });
1159    }
1160}
1161
1162/// Per-scope detector for [`ModulePattern::WrapperOverRecursion`].
1163/// Builds the self-recursive set for `fns` (fns that call themselves
1164/// by name anywhere in their body), then walks each non-recursive fn's
1165/// body looking for exactly one qualifying inner call. See the variant
1166/// docs on `WrapperOverRecursion` for the detection contract.
1167fn detect_wrapper_over_recursion(
1168    scope: Option<String>,
1169    fns: &[&crate::ast::FnDef],
1170    out: &mut Vec<ModulePattern>,
1171) {
1172    if fns.is_empty() {
1173        return;
1174    }
1175
1176    let mut recursive: HashSet<String> = HashSet::new();
1177    for fd in fns {
1178        if body_calls_name(&fd.body, &fd.name) {
1179            recursive.insert(fd.name.clone());
1180        }
1181    }
1182    if recursive.is_empty() {
1183        return;
1184    }
1185
1186    for fd in fns {
1187        if recursive.contains(&fd.name) {
1188            continue;
1189        }
1190        if fd.params.is_empty() {
1191            continue;
1192        }
1193        let outer_params: Vec<&str> = fd.params.iter().map(|(n, _)| n.as_str()).collect();
1194        let mut hits: Vec<String> = Vec::new();
1195        collect_qualifying_inner_calls(&fd.body, &outer_params, &recursive, &mut hits);
1196        hits.sort();
1197        hits.dedup();
1198        if hits.len() != 1 {
1199            continue;
1200        }
1201        let inner = hits.into_iter().next().unwrap();
1202        out.push(ModulePattern::WrapperOverRecursion {
1203            wrapper_scope: scope.clone(),
1204            wrapper_fn: fd.name.clone(),
1205            inner_scope: scope.clone(),
1206            inner_fn: inner,
1207        });
1208    }
1209}
1210
1211/// Whether `body` contains any `FnCall(Ident(name), _)` reachable
1212/// through expression nesting. Used to build the self-recursive set
1213/// and to find qualifying inner calls.
1214fn body_calls_name(body: &crate::ast::FnBody, name: &str) -> bool {
1215    for stmt in body.stmts() {
1216        let expr = match stmt {
1217            crate::ast::Stmt::Binding(_, _, e) => e,
1218            crate::ast::Stmt::Expr(e) => e,
1219        };
1220        if expr_calls_name(expr, name) {
1221            return true;
1222        }
1223    }
1224    false
1225}
1226
1227fn expr_calls_name(expr: &crate::ast::Spanned<crate::ast::Expr>, name: &str) -> bool {
1228    use crate::ast::Expr;
1229    match &expr.node {
1230        Expr::FnCall(callee, args) => {
1231            if let Expr::Ident(n) = &callee.node
1232                && n == name
1233            {
1234                return true;
1235            }
1236            if expr_calls_name(callee, name) {
1237                return true;
1238            }
1239            args.iter().any(|a| expr_calls_name(a, name))
1240        }
1241        Expr::TailCall(td) => td.target == name || td.args.iter().any(|a| expr_calls_name(a, name)),
1242        Expr::Match { subject, arms } => {
1243            if expr_calls_name(subject, name) {
1244                return true;
1245            }
1246            arms.iter().any(|a| expr_calls_name(&a.body, name))
1247        }
1248        Expr::BinOp(_, l, r) => expr_calls_name(l, name) || expr_calls_name(r, name),
1249        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_calls_name(e, name),
1250        Expr::Constructor(_, Some(e)) => expr_calls_name(e, name),
1251        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1252            xs.iter().any(|x| expr_calls_name(x, name))
1253        }
1254        Expr::MapLiteral(pairs) => pairs
1255            .iter()
1256            .any(|(k, v)| expr_calls_name(k, name) || expr_calls_name(v, name)),
1257        Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| expr_calls_name(e, name)),
1258        Expr::RecordUpdate { base, updates, .. } => {
1259            expr_calls_name(base, name) || updates.iter().any(|(_, e)| expr_calls_name(e, name))
1260        }
1261        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1262            crate::ast::StrPart::Parsed(e) => expr_calls_name(e, name),
1263            crate::ast::StrPart::Literal(_) => false,
1264        }),
1265        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1266            false
1267        }
1268    }
1269}
1270
1271/// Walk `body` and push every inner-fn name that satisfies the
1272/// `WrapperOverRecursion` qualification rules: callee is a same-scope
1273/// self-recursive fn in `recursive`, arity strictly greater than
1274/// `outer_params.len()`, and every outer-param name appears literally
1275/// as an `Ident` argument somewhere in the call's argument list.
1276fn collect_qualifying_inner_calls(
1277    body: &crate::ast::FnBody,
1278    outer_params: &[&str],
1279    recursive: &HashSet<String>,
1280    out: &mut Vec<String>,
1281) {
1282    for stmt in body.stmts() {
1283        let expr = match stmt {
1284            crate::ast::Stmt::Binding(_, _, e) => e,
1285            crate::ast::Stmt::Expr(e) => e,
1286        };
1287        collect_qualifying_in_expr(expr, outer_params, recursive, out);
1288    }
1289}
1290
1291fn collect_qualifying_in_expr(
1292    expr: &crate::ast::Spanned<crate::ast::Expr>,
1293    outer_params: &[&str],
1294    recursive: &HashSet<String>,
1295    out: &mut Vec<String>,
1296) {
1297    use crate::ast::Expr;
1298    let try_qualify = |callee: &str, args: &[crate::ast::Spanned<Expr>], out: &mut Vec<String>| {
1299        if !recursive.contains(callee) {
1300            return;
1301        }
1302        if args.len() <= outer_params.len() {
1303            return;
1304        }
1305        // Pre-pipeline args are `Expr::Ident(name)`; post-pipeline the
1306        // resolver rewrites local/param idents to
1307        // `Expr::Resolved { name, .. }`. Both shapes need to count.
1308        let mut arg_idents: HashSet<&str> = HashSet::new();
1309        for a in args {
1310            match &a.node {
1311                Expr::Ident(n) => {
1312                    arg_idents.insert(n.as_str());
1313                }
1314                Expr::Resolved { name, .. } => {
1315                    arg_idents.insert(name.as_str());
1316                }
1317                _ => {}
1318            }
1319        }
1320        if outer_params.iter().all(|p| arg_idents.contains(*p)) {
1321            out.push(callee.to_string());
1322        }
1323    };
1324    if let Expr::FnCall(callee, args) = &expr.node
1325        && let Expr::Ident(name) = &callee.node
1326    {
1327        try_qualify(name, args, out);
1328    }
1329    // Post-pipeline AST: tail-position calls become `TailCall`,
1330    // which loses the `FnCall(Ident, ...)` wrapper. The
1331    // `fib(n) -> fibTR(n, 0, 1)` shape is a typical case — pipeline
1332    // recognizes the tail call inside the `match` arm even though
1333    // `fib` itself isn't recursive.
1334    if let Expr::TailCall(td) = &expr.node {
1335        try_qualify(&td.target, &td.args, out);
1336    }
1337    match &expr.node {
1338        Expr::FnCall(callee, args) => {
1339            collect_qualifying_in_expr(callee, outer_params, recursive, out);
1340            for a in args {
1341                collect_qualifying_in_expr(a, outer_params, recursive, out);
1342            }
1343        }
1344        Expr::Match { subject, arms } => {
1345            collect_qualifying_in_expr(subject, outer_params, recursive, out);
1346            for a in arms {
1347                collect_qualifying_in_expr(&a.body, outer_params, recursive, out);
1348            }
1349        }
1350        Expr::BinOp(_, l, r) => {
1351            collect_qualifying_in_expr(l, outer_params, recursive, out);
1352            collect_qualifying_in_expr(r, outer_params, recursive, out);
1353        }
1354        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => {
1355            collect_qualifying_in_expr(e, outer_params, recursive, out);
1356        }
1357        Expr::Constructor(_, Some(e)) => {
1358            collect_qualifying_in_expr(e, outer_params, recursive, out);
1359        }
1360        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1361            for x in xs {
1362                collect_qualifying_in_expr(x, outer_params, recursive, out);
1363            }
1364        }
1365        Expr::MapLiteral(pairs) => {
1366            for (k, v) in pairs {
1367                collect_qualifying_in_expr(k, outer_params, recursive, out);
1368                collect_qualifying_in_expr(v, outer_params, recursive, out);
1369            }
1370        }
1371        Expr::RecordCreate { fields, .. } => {
1372            for (_, e) in fields {
1373                collect_qualifying_in_expr(e, outer_params, recursive, out);
1374            }
1375        }
1376        Expr::RecordUpdate { base, updates, .. } => {
1377            collect_qualifying_in_expr(base, outer_params, recursive, out);
1378            for (_, e) in updates {
1379                collect_qualifying_in_expr(e, outer_params, recursive, out);
1380            }
1381        }
1382        Expr::InterpolatedStr(parts) => {
1383            for p in parts {
1384                if let crate::ast::StrPart::Parsed(e) = p {
1385                    collect_qualifying_in_expr(e, outer_params, recursive, out);
1386                }
1387            }
1388        }
1389        Expr::TailCall(td) => {
1390            for a in &td.args {
1391                collect_qualifying_in_expr(a, outer_params, recursive, out);
1392            }
1393        }
1394        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {}
1395    }
1396}