Skip to main content

aver/ir/
pipeline.rs

1//! Ordered compiler pass pipeline — the single source of truth for what
2//! happens between `parse_*` and `codegen::*` / `vm::*`.
3//!
4//! Two layers of API:
5//!
6//! - **Per-stage entry points** (`pipeline::tco`, `pipeline::typecheck`,
7//!   `pipeline::interp_lower`, `pipeline::buffer_build`, `pipeline::resolve`)
8//!   — each pass exposed individually. Diagnostic and test sites that only
9//!   need one or two passes call these directly. There is no other path
10//!   into a pass; `crate::tco::transform_program` etc. are still public
11//!   internally but new code should not reach for them.
12//!
13//! - **Pipeline orchestrator** (`pipeline::run`) — walks all five stages
14//!   in fixed order, gating each on a per-stage boolean in
15//!   [`PipelineConfig`]. Stages that are off are skipped silently. This
16//!   is what `aver run`, `aver compile`, replay, and the playground use.
17//!
18//! Stages are fixed-order. Buffer-build needs `Expr::TailCall` from TCO,
19//! the resolver assumes traversal lowering is done; what is configurable
20//! is which stages run, not their ordering. There is **no** bundled
21//! "traversal lowering" toggle — `run_interp_lower` and `run_buffer_build`
22//! are independent flags so callers can mix them however they need.
23
24use crate::ast::TopLevel;
25use crate::ir::buffer_build::BufferBuildPassReport;
26use crate::ir::pass_diag::{self, CountsByFn};
27use crate::ir::{AllocPolicy, AnalysisResult, CallLowerCtx};
28use crate::source::LoadedModule;
29use crate::types::checker::{
30    TypeCheckResult, run_type_check_full, run_type_check_full_self_host,
31    run_type_check_with_loaded, run_type_check_with_loaded_self_host,
32};
33
34#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
35pub enum PipelineStage {
36    Tco,
37    Typecheck,
38    InterpLower,
39    BufferBuild,
40    Resolve,
41    LastUse,
42    Analyze,
43    Escape,
44    /// Refinement-via-opaque lift — type-level proof-export stage.
45    /// Walks user type defs + smart constructors, populates
46    /// `ProofIR.refined_types`. Opt-in via `run_refinement_lower`;
47    /// proof exporters (Lean → subtype, Dafny → subset type) enable
48    /// it, runtime backends leave it off.
49    RefinementLower,
50    /// Recursion-shape contract derivation — fn-level proof-export
51    /// stage. Walks recursion plans, populates `ProofIR.fn_contracts`
52    /// with Fuel / Native decisions and proof obligations. Opt-in via
53    /// `run_contract_lower`. Independent of `RefinementLower` — a
54    /// backend could enable one without the other, even though the
55    /// production proof exporters always want both.
56    ContractLower,
57    /// Verify-law universal theorem lowering — walks every
58    /// `VerifyKind::Law(law)` in the source, extracts quantifiers
59    /// from `givens`, premises from `when`, and the claim from
60    /// `lhs == rhs`, then populates `ProofIR.law_theorems`. Strategy
61    /// pinning (Reflexive / Induction / SimpOverLemmas / …) lives
62    /// in subsequent migration steps; the initial drop carries
63    /// `ProofStrategy::BackendDispatch` and the consumer's ad-hoc
64    /// chain still decides.
65    LawLower,
66    /// Build the resolved-identity table (`SymbolTable`) — opaque
67    /// `FnId` / `TypeId` / `CtorId` / `ModuleId` for every named
68    /// declaration. Phase E of #138; populates
69    /// `PipelineResult.symbol_table`. No consumer reads it from
70    /// the pipeline output yet, but downstream migration PRs
71    /// (proof IR maps keyed by `FnId`, backend mangling by
72    /// `FnId → name`) all depend on this stage running.
73    BuildSymbols,
74    /// Lift the post-typecheck `Expr` AST into
75    /// [`crate::ir::hir::ResolvedTopLevel`] — call sites become
76    /// `FnId` / `CtorId`-keyed, constructor / record references
77    /// carry typed identity, tail calls carry their target `FnId`.
78    /// Phase E of #147; populates
79    /// `PipelineResult::resolved_items`. Unconditional like
80    /// `BuildSymbols` — the resolver is part of the canonical
81    /// pipeline output, not an opt-in concern. Backends consume
82    /// `resolved_items` in lieu of re-resolving `Expr` themselves.
83    NameResolve,
84}
85
86impl PipelineStage {
87    pub const fn name(self) -> &'static str {
88        match self {
89            Self::Tco => "tco",
90            Self::Typecheck => "typecheck",
91            Self::InterpLower => "interp_lower",
92            Self::BufferBuild => "buffer_build",
93            Self::Resolve => "resolve",
94            Self::LastUse => "last_use",
95            Self::Analyze => "analyze",
96            Self::Escape => "escape",
97            Self::RefinementLower => "refinement_lower",
98            Self::ContractLower => "contract_lower",
99            Self::LawLower => "law_lower",
100            Self::BuildSymbols => "build_symbols",
101            Self::NameResolve => "name_resolve",
102        }
103    }
104}
105
106/// Hook callback fired after every pipeline stage that ran. Receives the
107/// stage label and the (post-mutation) item slice. Drives `--emit-ir-after=PASS`.
108pub type AfterPassHook<'a> = Box<dyn FnMut(PipelineStage, &[TopLevel]) + 'a>;
109
110/// Optional typecheck driver.
111pub enum TypecheckMode<'a> {
112    /// `run_type_check_full(items, base_dir)`.
113    Full { base_dir: Option<&'a str> },
114    /// `run_type_check_with_loaded(items, loaded)` for in-memory module trees
115    /// (playground virtual fs, multi-file ad-hoc compiles).
116    WithLoaded(&'a [LoadedModule]),
117    /// Self-host variant of [`Full`] — bypasses opaque-type checks
118    /// (construct, field access, pattern match). Used exclusively by
119    /// `aver compile --with-self-host-support` so `domain/builtins.av`
120    /// can round-trip opaque host types through the replay JSON
121    /// contract. See [`run_type_check_full_self_host`](crate::types::checker::run_type_check_full_self_host).
122    FullSelfHost { base_dir: Option<&'a str> },
123    /// Self-host variant of [`WithLoaded`].
124    WithLoadedSelfHost(&'a [LoadedModule]),
125}
126
127pub struct PipelineConfig<'a> {
128    pub run_tco: bool,
129    /// `Some(mode)` runs the type checker with that driver; `None` skips it.
130    pub typecheck: Option<TypecheckMode<'a>>,
131    pub run_interp_lower: bool,
132    pub run_buffer_build: bool,
133    pub run_resolve: bool,
134    /// Whether to run the last-use ownership annotation pass after
135    /// `resolve`. Annotates each `Expr::Resolved` slot reference with
136    /// `last_use: bool`; backends use it to MOVE instead of COPY
137    /// (VM `MOVE_LOCAL`, Rust skips `.clone()`, owned-mutate fast paths).
138    /// Independent of `run_resolve`: enabling LastUse without Resolve is
139    /// a no-op (no resolved slots to annotate); skipping LastUse keeps
140    /// every reference pessimistically marked as "not last".
141    pub run_last_use: bool,
142    /// Whether to run the IR-level analysis pass after `last_use`. The
143    /// pass is read-only — it populates `PipelineResult.analysis` with
144    /// per-fn body shape, thin-kind, locals count, and (when an
145    /// `alloc_policy` is configured) policy-parametrized alloc info.
146    pub run_analyze: bool,
147    /// Whether to run the escape-analysis rewriting pass after
148    /// `analyze`. Detects `FnCall(f, [RecordCreate{…}])` where `f`
149    /// only accesses the record via `Attr` and inlines `f`'s body
150    /// with field substitution — eliminates the fresh struct alloc
151    /// per call. Backend-agnostic: every backend benefits because
152    /// the `RecordCreate` simply disappears from the IR.
153    /// Proof exporters (Lean / Dafny) want the source-level shape
154    /// preserved and skip this stage.
155    pub run_escape: bool,
156    /// Whether to run the refinement-lift pass. Walks user type defs
157    /// and smart constructors, populating `ProofIR.refined_types`.
158    /// Independent of `run_contract_lower` — a backend could opt into
159    /// one without the other, though production proof exporters
160    /// always enable both. Both stages share the same
161    /// `PipelineResult.proof_ir` output sink.
162    pub run_refinement_lower: bool,
163    /// Whether to run the recursion-contract derivation pass. Walks
164    /// recursion plans (Fuel / Native shapes) and populates
165    /// `ProofIR.fn_contracts`. Independent of `run_refinement_lower`.
166    pub run_contract_lower: bool,
167    /// Whether to run the verify-law theorem lowering pass. Walks
168    /// `VerifyKind::Law` blocks and populates `ProofIR.law_theorems`.
169    /// Independent of the other proof stages; production proof
170    /// exporters always enable it alongside refinement_lower and
171    /// contract_lower.
172    pub run_law_lower: bool,
173    /// Whether to run the symbol-table build pass (#138 phase E).
174    /// Populates `PipelineResult.symbol_table` with opaque IDs for
175    /// every named declaration. Cheap to run unconditionally —
176    /// pure traversal of entry items + dep modules, no analysis.
177    /// Independent of every other stage; future consumers (proof
178    /// IR maps keyed by `FnId`, resolved AST emit) require it on.
179    pub run_build_symbols: bool,
180    /// Pre-loaded dependent modules. Carried alongside items so the
181    /// proof-lower pass can walk both the entry and dep type/fn defs
182    /// (refinement records live in either; cross-module recursion is
183    /// classified together). Caller pre-loads via
184    /// `load_compile_deps` / `loaded_to_module_info` before running
185    /// the pipeline. Empty slice when the source is single-file or
186    /// when no dep needs proof-side analysis.
187    pub dep_modules: &'a [crate::codegen::ModuleInfo],
188    /// Allocation policy used by `analyze`. `None` skips the alloc-info
189    /// computation; every other analysis fact is still produced.
190    /// Backends should pass their own policy (`VmAllocPolicy`,
191    /// `WasmAllocPolicy`); diagnostic tools that don't have a backend
192    /// in mind can pass `None` or use the dump module's conservative
193    /// default.
194    pub alloc_policy: Option<&'a dyn AllocPolicy>,
195    /// `CallLowerCtx` for the body classifier. `None` uses a conservative
196    /// stub that knows nothing about local symbols / module paths — fine
197    /// for diagnostic dumps; codegen pipelines should pass a real ctx so
198    /// the classifier returns its full set of body shapes.
199    pub call_ctx: Option<&'a dyn CallLowerCtx>,
200    /// Hook fired after every stage that ran.
201    pub on_after_pass: Option<AfterPassHook<'a>>,
202}
203
204impl<'a> Default for PipelineConfig<'a> {
205    fn default() -> Self {
206        Self {
207            run_tco: true,
208            typecheck: None,
209            run_interp_lower: true,
210            run_buffer_build: true,
211            run_resolve: true,
212            run_last_use: true,
213            run_analyze: true,
214            run_escape: true,
215            run_refinement_lower: false,
216            run_contract_lower: false,
217            run_law_lower: false,
218            run_build_symbols: false,
219            dep_modules: &[],
220            alloc_policy: None,
221            call_ctx: None,
222            on_after_pass: None,
223        }
224    }
225}
226
227/// Typed per-pass report. Each variant carries the structured facts a
228/// CI gate cares about — counts, fn names, error messages — so scripts
229/// don't have to regex-parse human-readable summaries. The text and
230/// JSON renderers in `cmd_explain_passes` derive their output from
231/// these values.
232#[derive(Debug, Clone)]
233pub enum PassReport {
234    Tco {
235        /// Total tail-call rewrites this pass made.
236        tail_calls_added: usize,
237        /// Per-fn before/after, alphabetised by `name`.
238        fns_changed: Vec<FnCountChange>,
239        /// Recursive callsites that did NOT convert (still call the fn
240        /// in non-tail position). Empty when the pass was clean.
241        non_tail_recursive: Vec<NonTailEntry>,
242    },
243    Typecheck {
244        items_checked: usize,
245        errors: usize,
246        /// Up to the first 5 error messages (full list still on
247        /// `PipelineResult.typecheck`).
248        error_messages: Vec<String>,
249    },
250    InterpLower {
251        interpolations_lowered: usize,
252        /// Per-fn before/after counts for fns whose interpolation count
253        /// dropped during the pass.
254        fns_changed: Vec<FnCountChange>,
255    },
256    BufferBuild(BufferBuildPassReport),
257    Resolve {
258        slots_resolved: usize,
259        fns_with_slots: usize,
260        /// Total slot count across all fns whose resolver populated a
261        /// type (one entry per `FnResolution.local_slot_types` element).
262        slot_types_total: usize,
263        /// Slots whose type came back `Type::Invalid` — typically
264        /// wildcards / `_` patterns the resolver counted but never
265        /// produced into. Surfaces unhandled pattern shapes (e.g.
266        /// future variant kinds the slot-types pass hasn't taught
267        /// itself yet) as a non-zero counter.
268        slot_types_invalid: usize,
269    },
270    LastUse {
271        last_use_marked: usize,
272        total_resolved: usize,
273    },
274    Analyze {
275        total_fns: usize,
276        no_alloc_fns: usize,
277        recursive_fns: usize,
278        mutual_tco_members: usize,
279        /// Fns whose `allocates` is `None` because no `alloc_policy` was
280        /// configured. Surfaces a misconfigured pipeline run.
281        unknown_alloc: usize,
282    },
283    Escape {
284        /// How many `FnCall(callee, [RecordCreate{…}])` sites the
285        /// pass rewrote into the inlined-and-substituted body.
286        rewrites: usize,
287    },
288    RefinementLower {
289        /// User types lifted into refinement subtypes (records with
290        /// a single carrier field + matching smart constructor).
291        refined_types: usize,
292    },
293    ContractLower {
294        /// Pure fns with a non-trivial `RecursionContract`. Currently
295        /// only covers the IntCountdownGuarded shape; extends as more
296        /// `RecursionPlan` variants migrate into ProofIR.
297        fn_contracts: usize,
298    },
299    LawLower {
300        /// Verify-law theorems lowered. Each entry pairs `(fn, law)`
301        /// with quantifiers + premises + claim.
302        law_theorems: usize,
303    },
304    BuildSymbols {
305        /// Total fn declarations across entry + dep modules.
306        fns: usize,
307        /// Total type declarations across entry + dep modules.
308        types: usize,
309        /// Total constructors (record + sum variants) across all
310        /// type declarations.
311        ctors: usize,
312        /// Per-module breakdown (entry first, then deps in walk
313        /// order). Shows where each fn/type lives — useful for
314        /// "is this big project actually multi-module?" sanity
315        /// checks at a glance.
316        modules: Vec<BuildSymbolsModule>,
317        /// Number of bare fn names that occur in 2+ different
318        /// scopes (e.g. entry + Module, or Module.A + Module.B).
319        /// Under bare-name keying these would have silently
320        /// merged; opaque `FnId` keeps them distinct. Zero would
321        /// mean phase E migration had no practical effect on
322        /// *this* project — non-zero is the proof it did.
323        fn_name_collisions: usize,
324        /// Same for type names.
325        type_name_collisions: usize,
326    },
327    NameResolve {
328        /// Top-level fn definitions promoted to
329        /// [`crate::ir::hir::ResolvedFnDef`]. One per `FnDef` that
330        /// resolved through the symbol table.
331        promoted_fns: usize,
332        /// Top-level items kept as
333        /// [`crate::ir::hir::ResolvedTopLevel::Passthrough`] (verify
334        /// blocks, decisions, type defs, top-level stmts, and any
335        /// fn whose name didn't resolve — typechecker error
336        /// recovery). The fewer of these, the more the resolved
337        /// HIR captured.
338        passthrough_items: usize,
339        /// Resolver-escape-hatch occurrences in the resolved tree —
340        /// `ResolvedCallee::Unresolved` and `ResolvedCtor::Unresolved`
341        /// summed across every expression. The contract for backends
342        /// that consume `resolved_items` is **zero unresolved for
343        /// well-typed programs**; non-zero implies either a
344        /// typechecker error already reported (the resolver bailed
345        /// to recovery shape) or a resolver gap that needs
346        /// patching. CI gates can compare this count against the
347        /// typecheck error count to catch silent resolver
348        /// regressions.
349        unresolved_count: usize,
350    },
351}
352
353/// Per-module entry in `PassReport::BuildSymbols`. `prefix` is the
354/// dotted module name (`"Models.User"`); the entry scope is
355/// represented by an empty string so JSON consumers can sort/group
356/// uniformly.
357#[derive(Debug, Clone)]
358pub struct BuildSymbolsModule {
359    pub prefix: String,
360    pub fns: usize,
361    pub types: usize,
362    pub ctors: usize,
363}
364
365/// Per-fn counter delta — used by `Tco` and `InterpLower` reports to
366/// surface which functions a pass actually changed.
367#[derive(Debug, Clone)]
368pub struct FnCountChange {
369    pub name: String,
370    pub before: usize,
371    pub after: usize,
372}
373
374/// One non-tail recursive callsite the TCO pass couldn't rewrite.
375#[derive(Debug, Clone)]
376pub struct NonTailEntry {
377    pub fn_name: String,
378    pub recursive_calls: usize,
379    pub line: usize,
380}
381
382/// Per-pass diagnostic record. `report` carries the typed facts; `stage`
383/// labels which pass produced them. Drives `aver compile --explain-passes`
384/// and the future failable-invariant CI checks (`fail if buffer_build
385/// no longer fires on the canonical shape`, `fail if hot fn loses
386/// no-alloc status`, etc.).
387#[derive(Debug, Clone)]
388pub struct PassDiagnostic {
389    pub stage: PipelineStage,
390    pub report: PassReport,
391}
392
393#[derive(Default)]
394pub struct PipelineResult {
395    /// Typecheck output, present iff `config.typecheck` was set. Callers
396    /// inspect `.errors` and decide what to do — the orchestrator does not
397    /// exit on its own.
398    pub typecheck: Option<TypeCheckResult>,
399    /// Buffer-build pass report — sinks fired, synthesized fns,
400    /// per-sink rewrite counts. `None` when the pass was disabled.
401    pub buffer_build: Option<BufferBuildPassReport>,
402    /// IR-level analysis facts (per-fn body shape, thin kind, alloc info)
403    /// when `run_analyze` was on. `None` when the stage was disabled.
404    pub analysis: Option<AnalysisResult>,
405    /// Backend-neutral proof-export decisions populated by the
406    /// `RefinementLower` / `ContractLower` stages. `Some` when at
407    /// least one of the two ran; carries whichever fields were
408    /// populated by the stages that ran (an opt-in to only
409    /// RefinementLower leaves `fn_contracts` empty, vice versa).
410    pub proof_ir: Option<crate::ir::ProofIR>,
411    /// Resolved-identity table — opaque `FnId` / `TypeId` /
412    /// `CtorId` / `ModuleId` for every named declaration in the
413    /// program (#138 phase E). Always populated: the pipeline
414    /// builds it unconditionally at the head of `run` (it's the
415    /// universal foundation for proof IR + backend identity).
416    /// `run_build_symbols` in `PipelineConfig` controls whether
417    /// the BuildSymbols stage fires its `on_after_pass` hook for
418    /// `--emit-ir-after=build_symbols`; the table itself is built
419    /// regardless.
420    pub symbol_table: crate::ir::SymbolTable,
421    /// Resolved HIR — post-typecheck AST lifted onto opaque
422    /// identities (Phase E of #147). Always populated: the
423    /// pipeline runs `NameResolve` unconditionally after
424    /// `BuildSymbols`, mirroring how the symbol table is always
425    /// built. Backends consume this in lieu of re-resolving
426    /// `Expr` themselves. The migration of individual backends
427    /// (VM / Rust / wasm-gc / Lean / Dafny / self-host) is staged
428    /// across follow-up PRs in the Phase E stack.
429    pub resolved_items: Vec<crate::ir::hir::ResolvedTopLevel>,
430    /// Per-stage diagnostic records — one per pass that actually ran.
431    /// Drives `aver compile --explain-passes`; consumed by the future
432    /// CI failable-invariant checks.
433    pub pass_diagnostics: Vec<PassDiagnostic>,
434}
435
436// ── Per-stage entry points ──────────────────────────────────────────
437//
438// Three argument shapes, each reflecting what the stage actually does:
439//
440//   `&[TopLevel]`      — read-only (typecheck)
441//   `&mut [TopLevel]`  — mutate in place (tco, interp_lower, resolve)
442//   `&mut Vec<TopLevel>` — mutate and append (buffer_build synthesizes
443//                          new top-level fn defs)
444//
445// Looks inconsistent on the surface but the categories are real. Faking
446// uniformity by forcing `&mut Vec` everywhere triggers `clippy::ptr_arg`
447// for good reason: it lies about what the function does. Callers always
448// have a `Vec<TopLevel>` so passing `&mut items` works for every shape.
449
450/// Tail-call rewrite pass.
451pub fn tco(items: &mut [TopLevel]) {
452    crate::tco::transform_program(items);
453}
454
455/// Run the type checker against `items` using the provided driver.
456pub fn typecheck(items: &[TopLevel], mode: &TypecheckMode<'_>) -> TypeCheckResult {
457    match mode {
458        TypecheckMode::Full { base_dir } => run_type_check_full(items, *base_dir),
459        TypecheckMode::WithLoaded(loaded) => run_type_check_with_loaded(items, loaded),
460        TypecheckMode::FullSelfHost { base_dir } => run_type_check_full_self_host(items, *base_dir),
461        TypecheckMode::WithLoadedSelfHost(loaded) => {
462            run_type_check_with_loaded_self_host(items, loaded)
463        }
464    }
465}
466
467/// Lower `"a${x}b"` interpolation literals into the buffer pipeline.
468/// Skipped by proof exporters (Lean/Dafny) which want the source-level form.
469pub fn interp_lower(items: &mut [TopLevel]) {
470    crate::ir::lower_interpolation_pass(items);
471}
472
473/// Buffer-build deforestation pass — detects `String.join(<builder>(args, []), sep)`
474/// shapes, rewrites them to `__buf_finalize(<builder>__buffered(...))`, and
475/// appends the synthesized buffered variants to `items`. Returns a
476/// [`BufferBuildPassReport`] describing what fired.
477pub fn buffer_build(items: &mut Vec<TopLevel>) -> BufferBuildPassReport {
478    crate::ir::run_buffer_build_pass(items)
479}
480
481/// Resolve local bindings — maps `Expr::Ident(name)` → `Expr::Resolved { slot, .. }`
482/// per fn. Does not annotate last-use; that's a separate stage.
483pub fn resolve(items: &mut [TopLevel]) {
484    crate::resolver::resolve_program(items);
485}
486
487/// Last-use ownership annotation. Walks each fn body backwards, sets
488/// `last_use: true` on every `Expr::Resolved` whose slot is not
489/// referenced again afterwards. Requires `Resolve` to have run; on
490/// pre-resolve IR it's a no-op (no resolved slots to annotate).
491pub fn last_use(items: &mut [TopLevel]) {
492    crate::ir::last_use::annotate_program_last_use(items);
493}
494
495// ── Orchestrator ────────────────────────────────────────────────────
496
497/// Run the canonical compiler pipeline on `items`. Each stage is gated
498/// on its corresponding `PipelineConfig` flag — disabled stages are
499/// skipped without complaint.
500///
501/// If typecheck runs and surfaces errors, later stages are skipped so
502/// callers can render diagnostics without seeing partially-lowered IR.
503/// The typecheck result still lands in `PipelineResult::typecheck`.
504pub fn run(items: &mut Vec<TopLevel>, mut cfg: PipelineConfig<'_>) -> PipelineResult {
505    let mut result = PipelineResult::default();
506
507    if cfg.run_tco {
508        let pre = pass_diag::collect(items);
509        tco(items);
510        let post = pass_diag::collect(items);
511        result
512            .pass_diagnostics
513            .push(diag_for_tco(&pre, &post, items));
514        fire(&mut cfg, PipelineStage::Tco, items);
515    }
516
517    if let Some(mode) = cfg.typecheck.as_ref() {
518        let tc = typecheck(items, mode);
519        let has_errors = !tc.errors.is_empty();
520        result
521            .pass_diagnostics
522            .push(diag_for_typecheck(&tc, items.len()));
523        result.typecheck = Some(tc);
524        fire(&mut cfg, PipelineStage::Typecheck, items);
525        if has_errors {
526            return result;
527        }
528    }
529
530    if cfg.run_interp_lower {
531        let pre = pass_diag::collect(items);
532        interp_lower(items);
533        let post = pass_diag::collect(items);
534        result
535            .pass_diagnostics
536            .push(diag_for_interp_lower(&pre, &post));
537        fire(&mut cfg, PipelineStage::InterpLower, items);
538    }
539
540    if cfg.run_buffer_build {
541        let report = buffer_build(items);
542        result.pass_diagnostics.push(diag_for_buffer_build(&report));
543        result.buffer_build = Some(report);
544        fire(&mut cfg, PipelineStage::BufferBuild, items);
545    }
546
547    if cfg.run_resolve {
548        resolve(items);
549        let post = pass_diag::collect(items);
550        result.pass_diagnostics.push(diag_for_resolve(&post, items));
551        fire(&mut cfg, PipelineStage::Resolve, items);
552    }
553
554    if cfg.run_analyze {
555        // The body classifier needs a `CallLowerCtx`. When no real ctx is
556        // configured we use `StubCallCtx`, which under-classifies `direct`
557        // shapes (a body that calls a fn whose name looks like a local
558        // gets seen as a generic call). Acceptable for `--emit-ir` dumps;
559        // codegen pipelines should plumb a real ctx through `cfg.call_ctx`
560        // once the inliner needs accurate body shape data.
561        let adapter = CallCtxAdapter(cfg.call_ctx);
562        let analysis = crate::ir::analyze(items, cfg.alloc_policy, &adapter);
563        result.pass_diagnostics.push(diag_for_analyze(&analysis));
564        result.analysis = Some(analysis);
565        fire(&mut cfg, PipelineStage::Analyze, items);
566    }
567
568    if cfg.run_escape {
569        let rewrites = crate::ir::escape::run(items);
570        result.pass_diagnostics.push(diag_for_escape(rewrites));
571        fire(&mut cfg, PipelineStage::Escape, items);
572    }
573
574    // `last_use` runs *after* every rewrite pass — escape duplicates
575    // `Attr(p, field)` into each use site, so a "last use" annotation
576    // computed pre-inline would become stale on the duplicated reads,
577    // and the VM's `MOVE_LOCAL` (emitted on `last_use=true`) would
578    // clear the slot before the second read sees it. Annotating once
579    // at the end keeps the pass cheap (single forward walk) and means
580    // every backend reads markers that match the IR they actually
581    // codegen against.
582    if cfg.run_last_use {
583        last_use(items);
584        let post = pass_diag::collect(items);
585        result.pass_diagnostics.push(diag_for_last_use(&post));
586        fire(&mut cfg, PipelineStage::LastUse, items);
587        // Alias-slot annotation rides on the same gate — backends only
588        // ever consume `aliased_slots` together with `last_use` (the
589        // VM's owned-mask uses both, the wasm-gc clone-on-write skip
590        // uses both). No public stage flag yet; the data is opt-in by
591        // backends via `FnResolution.aliased_slots`.
592        crate::ir::alias::annotate_program_alias_slots(items);
593    }
594
595    // Resolved-identity table is the universal foundation for proof
596    // IR + every backend's name lookup, so it's built unconditionally.
597    // `run_build_symbols` in PipelineConfig controls only whether the
598    // BuildSymbols stage fires its `on_after_pass` hook (for
599    // `--emit-ir-after=build_symbols`); the table itself lands in
600    // `result.symbol_table` regardless.
601    {
602        let symbol_table = crate::ir::SymbolTable::build(items, cfg.dep_modules);
603        result
604            .pass_diagnostics
605            .push(diag_for_build_symbols(&symbol_table));
606        result.symbol_table = symbol_table;
607        if cfg.run_build_symbols {
608            fire(&mut cfg, PipelineStage::BuildSymbols, items);
609        }
610    }
611
612    // Name-resolve runs after BuildSymbols (needs the symbol
613    // table) and after the slot resolver + last_use (so
614    // `Expr::Resolved` slots / `last_use` flags are populated and
615    // carry through to the resolved AST). Unconditional: like
616    // `BuildSymbols`, the resolved HIR is part of the canonical
617    // pipeline output — every backend can rely on
618    // `PipelineResult::resolved_items` being populated without
619    // opt-in setup. Cost is O(n) over the AST (mirror of what
620    // typecheck already walks) — small enough to make the
621    // architectural simplicity worth it.
622    {
623        let resolved = crate::ir::hir::resolve_program(&result.symbol_table, items);
624        result
625            .pass_diagnostics
626            .push(diag_for_name_resolve(&resolved));
627        result.resolved_items = resolved;
628        fire(&mut cfg, PipelineStage::NameResolve, items);
629    }
630
631    if cfg.run_refinement_lower || cfg.run_contract_lower || cfg.run_law_lower {
632        // Round-5: union entry's analyze with each dep module's
633        // `analysis.recursive_fns`. Without this, multi-module proof
634        // export's `populate_fn_contracts` only sees entry-recursive
635        // fns and classifies module fns as "outside subset" — even
636        // when each module's analyze already saw their countdown /
637        // structural-recursion shape. Aver's module DAG invariant
638        // rules out cross-module recursion SCCs, so unioning the
639        // per-scope `recursive_fns` sets matches the build-time
640        // `ctx.recursive_fns` view in `codegen::build_context`.
641        // Project per-scope bare-name sets to opaque FnIds through the
642        // (already-built) symbol table — same flow `build_context`
643        // uses to populate `ctx.recursive_fns`. Without a symbol
644        // table the producer side of proof_lower can't run anyway
645        // (populate panics by design), so default to empty.
646        let symbols = &result.symbol_table;
647        let recursive_fns_owned: std::collections::HashSet<crate::ir::FnId> = {
648            let mut set = std::collections::HashSet::new();
649            if let Some(a) = result.analysis.as_ref() {
650                set.extend(crate::codegen::scc::analysis_set_to_fn_ids(
651                    &a.recursive_fns,
652                    symbols,
653                    None,
654                ));
655            }
656            for m in cfg.dep_modules {
657                if let Some(a) = m.analysis.as_ref() {
658                    set.extend(crate::codegen::scc::analysis_set_to_fn_ids(
659                        &a.recursive_fns,
660                        symbols,
661                        Some(&m.prefix),
662                    ));
663                }
664            }
665            set
666        };
667        let module_prefixes: std::collections::HashSet<String> =
668            cfg.dep_modules.iter().map(|m| m.prefix.clone()).collect();
669        // Stage 6b of #232: build ProgramShape once here so
670        // refinement_info_for and other proof-lower detectors read
671        // from typed patterns instead of rewalking the AST. The
672        // module-level patterns (RefinementSmartConstructor) walk
673        // dep modules' source-level FnDefs directly; the per-fn
674        // archetype facts here only see entry-module resolved fns,
675        // which is enough for the current refinement adapter (dep
676        // modules don't expose ResolvedFnDef to the pipeline yet).
677        let entry_resolved_fns: Vec<&crate::ir::hir::ResolvedFnDef> = result
678            .resolved_items
679            .iter()
680            .filter_map(|t| match t {
681                crate::ir::hir::ResolvedTopLevel::FnDef(fd) => Some(fd),
682                _ => None,
683            })
684            .collect();
685        let program_shape = crate::analysis::shape::analyze_program_with_modules(
686            &entry_resolved_fns,
687            items,
688            cfg.dep_modules,
689        );
690        let inputs = crate::codegen::proof_lower::ProofLowerInputs {
691            entry_items: items,
692            dep_modules: cfg.dep_modules,
693            module_prefixes: &module_prefixes,
694            recursive_fns: &recursive_fns_owned,
695            symbol_table: symbols,
696            program_shape: Some(&program_shape),
697        };
698        let mut ir = result.proof_ir.take().unwrap_or_default();
699        if cfg.run_refinement_lower {
700            crate::codegen::proof_lower::populate_refined_types(&inputs, &mut ir);
701            result.pass_diagnostics.push(diag_for_refinement_lower(&ir));
702            fire(&mut cfg, PipelineStage::RefinementLower, items);
703        }
704        if cfg.run_contract_lower {
705            crate::codegen::proof_lower::populate_fn_contracts(&inputs, &mut ir);
706            result.pass_diagnostics.push(diag_for_contract_lower(&ir));
707            fire(&mut cfg, PipelineStage::ContractLower, items);
708        }
709        if cfg.run_law_lower {
710            crate::codegen::proof_lower::populate_law_theorems(&inputs, &mut ir);
711            result.pass_diagnostics.push(diag_for_law_lower(&ir));
712            fire(&mut cfg, PipelineStage::LawLower, items);
713        }
714        result.proof_ir = Some(ir);
715    }
716
717    result
718}
719
720/// Bridges the trait-object `cfg.call_ctx: Option<&dyn CallLowerCtx>`
721/// into the generic-impl world that the IR classifiers (`classify_call_plan`,
722/// `classify_thin_fn_def`, …) expect (`&impl CallLowerCtx`). When the
723/// option is `None` every method returns the conservative answer.
724struct CallCtxAdapter<'a>(Option<&'a dyn CallLowerCtx>);
725
726impl<'a> CallLowerCtx for CallCtxAdapter<'a> {
727    fn is_local_value(&self, name: &str) -> bool {
728        self.0.is_some_and(|c| c.is_local_value(name))
729    }
730    fn is_user_type(&self, name: &str) -> bool {
731        self.0.is_some_and(|c| c.is_user_type(name))
732    }
733    fn resolve_module_call<'b>(&self, dotted: &'b str) -> Option<(&'b str, &'b str)> {
734        self.0.and_then(|c| c.resolve_module_call(dotted))
735    }
736}
737
738fn fire(cfg: &mut PipelineConfig<'_>, stage: PipelineStage, items: &[TopLevel]) {
739    if let Some(cb) = cfg.on_after_pass.as_mut() {
740        cb(stage, items);
741    }
742}
743
744// ── PassDiagnostic builders ─────────────────────────────────────────
745
746fn diag_for_tco(pre: &CountsByFn, post: &CountsByFn, items: &[TopLevel]) -> PassDiagnostic {
747    let pre_total = pass_diag::total(pre);
748    let post_total = pass_diag::total(post);
749    let tail_calls_added = post_total.tail_calls.saturating_sub(pre_total.tail_calls);
750
751    let fns_changed: Vec<FnCountChange> = pass_diag::fns_that_grew(pre, post, |c| c.tail_calls)
752        .into_iter()
753        .map(|name| {
754            let before = pre.get(&name).copied().unwrap_or_default().tail_calls;
755            let after = post.get(&name).copied().unwrap_or_default().tail_calls;
756            FnCountChange {
757                name,
758                before,
759                after,
760            }
761        })
762        .collect();
763
764    let non_tail_recursive: Vec<NonTailEntry> =
765        crate::tail_check::collect_non_tail_recursion_warnings(items)
766            .into_iter()
767            .map(|w| NonTailEntry {
768                fn_name: w.fn_name,
769                recursive_calls: w.recursive_calls,
770                line: w.line,
771            })
772            .collect();
773
774    PassDiagnostic {
775        stage: PipelineStage::Tco,
776        report: PassReport::Tco {
777            tail_calls_added,
778            fns_changed,
779            non_tail_recursive,
780        },
781    }
782}
783
784fn diag_for_typecheck(tc: &TypeCheckResult, item_count: usize) -> PassDiagnostic {
785    let error_messages = if tc.errors.is_empty() {
786        Vec::new()
787    } else {
788        tc.errors
789            .iter()
790            .take(5)
791            .map(|e| e.message.clone())
792            .collect()
793    };
794    PassDiagnostic {
795        stage: PipelineStage::Typecheck,
796        report: PassReport::Typecheck {
797            items_checked: item_count,
798            errors: tc.errors.len(),
799            error_messages,
800        },
801    }
802}
803
804fn diag_for_interp_lower(pre: &CountsByFn, post: &CountsByFn) -> PassDiagnostic {
805    let interpolations_lowered = pass_diag::total(pre)
806        .interpolations
807        .saturating_sub(pass_diag::total(post).interpolations);
808    let fns_changed: Vec<FnCountChange> = pass_diag::fns_that_grew(post, pre, |c| c.interpolations)
809        .into_iter()
810        .map(|name| {
811            let before = pre.get(&name).copied().unwrap_or_default().interpolations;
812            let after = post.get(&name).copied().unwrap_or_default().interpolations;
813            FnCountChange {
814                name,
815                before,
816                after,
817            }
818        })
819        .collect();
820    PassDiagnostic {
821        stage: PipelineStage::InterpLower,
822        report: PassReport::InterpLower {
823            interpolations_lowered,
824            fns_changed,
825        },
826    }
827}
828
829fn diag_for_buffer_build(report: &BufferBuildPassReport) -> PassDiagnostic {
830    PassDiagnostic {
831        stage: PipelineStage::BufferBuild,
832        report: PassReport::BufferBuild(report.clone()),
833    }
834}
835
836fn diag_for_resolve(post: &CountsByFn, items: &[TopLevel]) -> PassDiagnostic {
837    let slots_resolved = pass_diag::total(post).resolved;
838    let fns_with_slots = post.values().filter(|c| c.resolved > 0).count();
839    let mut slot_types_total = 0usize;
840    let mut slot_types_invalid = 0usize;
841    for item in items {
842        if let TopLevel::FnDef(fd) = item
843            && let Some(res) = fd.resolution.as_ref()
844        {
845            slot_types_total += res.local_slot_types.len();
846            slot_types_invalid += res
847                .local_slot_types
848                .iter()
849                .filter(|t| matches!(t, crate::ast::Type::Invalid))
850                .count();
851        }
852    }
853    PassDiagnostic {
854        stage: PipelineStage::Resolve,
855        report: PassReport::Resolve {
856            slots_resolved,
857            fns_with_slots,
858            slot_types_total,
859            slot_types_invalid,
860        },
861    }
862}
863
864fn diag_for_last_use(post: &CountsByFn) -> PassDiagnostic {
865    let totals = pass_diag::total(post);
866    PassDiagnostic {
867        stage: PipelineStage::LastUse,
868        report: PassReport::LastUse {
869            last_use_marked: totals.last_use_resolved,
870            total_resolved: totals.resolved,
871        },
872    }
873}
874
875fn diag_for_escape(rewrites: usize) -> PassDiagnostic {
876    PassDiagnostic {
877        stage: PipelineStage::Escape,
878        report: PassReport::Escape { rewrites },
879    }
880}
881
882fn diag_for_refinement_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
883    PassDiagnostic {
884        stage: PipelineStage::RefinementLower,
885        report: PassReport::RefinementLower {
886            refined_types: ir.refined_types.len(),
887        },
888    }
889}
890
891fn diag_for_contract_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
892    PassDiagnostic {
893        stage: PipelineStage::ContractLower,
894        report: PassReport::ContractLower {
895            fn_contracts: ir.fn_contracts.len(),
896        },
897    }
898}
899
900fn diag_for_law_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
901    PassDiagnostic {
902        stage: PipelineStage::LawLower,
903        report: PassReport::LawLower {
904            law_theorems: ir.law_theorems.len(),
905        },
906    }
907}
908
909fn diag_for_build_symbols(table: &crate::ir::SymbolTable) -> PassDiagnostic {
910    // Per-module breakdown: walk every fn/type/ctor, bucket by its
911    // owning module, count. Single pass, O(fns + types + ctors).
912    let mut per_module: Vec<BuildSymbolsModule> = table
913        .modules
914        .iter()
915        .map(|m| BuildSymbolsModule {
916            prefix: m.prefix.clone().unwrap_or_default(),
917            fns: 0,
918            types: 0,
919            ctors: 0,
920        })
921        .collect();
922    for fe in &table.fns {
923        per_module[fe.module.0 as usize].fns += 1;
924    }
925    for te in &table.types {
926        per_module[te.module.0 as usize].types += 1;
927    }
928    for ce in &table.ctors {
929        let owning_module = table.types[ce.owning_type.0 as usize].module;
930        per_module[owning_module.0 as usize].ctors += 1;
931    }
932
933    // Bare-name collision count: a fn/type with the same `key.name`
934    // appearing in 2+ different scopes. Under bare-name keying these
935    // would silently merge; under opaque IDs each gets its own slot.
936    use std::collections::HashMap;
937    let mut fn_buckets: HashMap<&str, usize> = HashMap::new();
938    for fe in &table.fns {
939        *fn_buckets.entry(fe.key.name.as_str()).or_default() += 1;
940    }
941    let fn_name_collisions = fn_buckets.values().filter(|c| **c >= 2).count();
942    let mut type_buckets: HashMap<&str, usize> = HashMap::new();
943    for te in &table.types {
944        *type_buckets.entry(te.key.name.as_str()).or_default() += 1;
945    }
946    let type_name_collisions = type_buckets.values().filter(|c| **c >= 2).count();
947
948    PassDiagnostic {
949        stage: PipelineStage::BuildSymbols,
950        report: PassReport::BuildSymbols {
951            fns: table.fns.len(),
952            types: table.types.len(),
953            ctors: table.ctors.len(),
954            modules: per_module,
955            fn_name_collisions,
956            type_name_collisions,
957        },
958    }
959}
960
961fn diag_for_name_resolve(items: &[crate::ir::hir::ResolvedTopLevel]) -> PassDiagnostic {
962    use crate::ir::hir::ResolvedTopLevel;
963    let mut promoted_fns = 0;
964    let mut passthrough_items = 0;
965    let mut unresolved_count = 0;
966    for item in items {
967        match item {
968            ResolvedTopLevel::FnDef(fd) => {
969                promoted_fns += 1;
970                unresolved_count += crate::ir::hir::count_unresolved_in_fn(fd);
971            }
972            ResolvedTopLevel::Passthrough(_) => passthrough_items += 1,
973            ResolvedTopLevel::Module(_) => {}
974        }
975    }
976    PassDiagnostic {
977        stage: PipelineStage::NameResolve,
978        report: PassReport::NameResolve {
979            promoted_fns,
980            passthrough_items,
981            unresolved_count,
982        },
983    }
984}
985
986fn diag_for_analyze(analysis: &AnalysisResult) -> PassDiagnostic {
987    let total_fns = analysis.fn_analyses.len();
988    let no_alloc_fns = analysis
989        .fn_analyses
990        .values()
991        .filter(|fa| fa.allocates == Some(false))
992        .count();
993    let unknown_alloc = analysis
994        .fn_analyses
995        .values()
996        .filter(|fa| fa.allocates.is_none())
997        .count();
998    PassDiagnostic {
999        stage: PipelineStage::Analyze,
1000        report: PassReport::Analyze {
1001            total_fns,
1002            no_alloc_fns,
1003            recursive_fns: analysis.recursive_fns.len(),
1004            mutual_tco_members: analysis.mutual_tco_members.len(),
1005            unknown_alloc,
1006        },
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use crate::source::parse_source;
1014
1015    fn parse(src: &str) -> Vec<TopLevel> {
1016        parse_source(src).expect("parse failed")
1017    }
1018
1019    #[test]
1020    fn default_config_fires_every_stage_in_order() {
1021        let mut items = parse(
1022            r#"
1023module M
1024    intent = "test"
1025    depends []
1026
1027fn id(n: Int) -> Int
1028    n
1029"#,
1030        );
1031        let mut fired: Vec<PipelineStage> = Vec::new();
1032        run(
1033            &mut items,
1034            PipelineConfig {
1035                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1036                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1037                ..Default::default()
1038            },
1039        );
1040        assert_eq!(
1041            fired,
1042            vec![
1043                PipelineStage::Tco,
1044                PipelineStage::Typecheck,
1045                PipelineStage::InterpLower,
1046                PipelineStage::BufferBuild,
1047                PipelineStage::Resolve,
1048                PipelineStage::Analyze,
1049                PipelineStage::Escape,
1050                PipelineStage::LastUse,
1051                // Phase E (#147): NameResolve runs unconditionally
1052                // after BuildSymbols (which fires only via flag, but
1053                // the resolved AST is always built).
1054                PipelineStage::NameResolve,
1055            ]
1056        );
1057    }
1058
1059    #[test]
1060    fn disabled_stages_dont_fire() {
1061        let mut items = parse(
1062            r#"
1063module M
1064    intent = "test"
1065    depends []
1066
1067fn id(n: Int) -> Int
1068    n
1069"#,
1070        );
1071        let mut fired: Vec<PipelineStage> = Vec::new();
1072        run(
1073            &mut items,
1074            PipelineConfig {
1075                typecheck: None,
1076                run_interp_lower: false,
1077                run_buffer_build: false,
1078                run_last_use: false,
1079                run_analyze: false,
1080                run_escape: false,
1081                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1082                ..Default::default()
1083            },
1084        );
1085        // NameResolve runs unconditionally after BuildSymbols, even
1086        // when most other stages are disabled.
1087        assert_eq!(
1088            fired,
1089            vec![
1090                PipelineStage::Tco,
1091                PipelineStage::Resolve,
1092                PipelineStage::NameResolve,
1093            ]
1094        );
1095    }
1096
1097    #[test]
1098    fn typecheck_errors_skip_later_stages() {
1099        // Reference an undefined identifier so typecheck reports an error.
1100        let mut items = parse(
1101            r#"
1102module M
1103    intent = "test"
1104    depends []
1105
1106fn broken() -> Int
1107    undefined_thing
1108"#,
1109        );
1110        let mut fired: Vec<PipelineStage> = Vec::new();
1111        let result = run(
1112            &mut items,
1113            PipelineConfig {
1114                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1115                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1116                ..Default::default()
1117            },
1118        );
1119        assert!(
1120            !result.typecheck.unwrap().errors.is_empty(),
1121            "typecheck must surface the undefined identifier"
1122        );
1123        // Tco fired, typecheck fired, then we bailed out — no later stages.
1124        assert_eq!(fired, vec![PipelineStage::Tco, PipelineStage::Typecheck]);
1125    }
1126
1127    #[test]
1128    fn analyze_populates_result_when_enabled() {
1129        let mut items = parse(
1130            r#"
1131module M
1132    intent = "test"
1133    depends []
1134
1135fn id(n: Int) -> Int
1136    n
1137
1138fn dub(n: Int) -> Int
1139    n + n
1140"#,
1141        );
1142        let result = run(
1143            &mut items,
1144            PipelineConfig {
1145                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1146                ..Default::default()
1147            },
1148        );
1149        let analysis = result
1150            .analysis
1151            .expect("analyze runs by default and must populate result");
1152        assert!(
1153            analysis.fn_analyses.contains_key("id"),
1154            "every user fn shows up in fn_analyses, got keys: {:?}",
1155            analysis.fn_analyses.keys().collect::<Vec<_>>()
1156        );
1157        assert!(analysis.fn_analyses.contains_key("dub"));
1158    }
1159
1160    #[test]
1161    fn analyze_skipped_when_disabled() {
1162        let mut items = parse(
1163            r#"
1164module M
1165    intent = "test"
1166    depends []
1167
1168fn id(n: Int) -> Int
1169    n
1170"#,
1171        );
1172        let result = run(
1173            &mut items,
1174            PipelineConfig {
1175                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1176                run_analyze: false,
1177                ..Default::default()
1178            },
1179        );
1180        assert!(
1181            result.analysis.is_none(),
1182            "run_analyze=false must leave PipelineResult.analysis as None"
1183        );
1184    }
1185
1186    #[test]
1187    fn alloc_policy_populates_per_fn_allocates() {
1188        let mut items = parse(
1189            r#"
1190module M
1191    intent = "test"
1192    depends []
1193
1194fn pure_one() -> Int
1195    1
1196
1197fn allocates_list(n: Int) -> List<Int>
1198    [n, n, n]
1199"#,
1200        );
1201        let policy = crate::ir::NeutralAllocPolicy;
1202        let result = run(
1203            &mut items,
1204            PipelineConfig {
1205                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1206                alloc_policy: Some(&policy),
1207                ..Default::default()
1208            },
1209        );
1210        let analysis = result.analysis.expect("analyze ran");
1211        assert_eq!(
1212            analysis
1213                .fn_analyses
1214                .get("pure_one")
1215                .and_then(|fa| fa.allocates),
1216            Some(false),
1217            "pure_one returns a literal — proven not to allocate"
1218        );
1219        assert_eq!(
1220            analysis
1221                .fn_analyses
1222                .get("allocates_list")
1223                .and_then(|fa| fa.allocates),
1224            Some(true),
1225            "list literal allocates under the neutral policy"
1226        );
1227    }
1228
1229    #[test]
1230    fn analyze_without_policy_leaves_allocates_unset() {
1231        let mut items = parse(
1232            r#"
1233module M
1234    intent = "test"
1235    depends []
1236
1237fn id(n: Int) -> Int
1238    n
1239"#,
1240        );
1241        let result = run(
1242            &mut items,
1243            PipelineConfig {
1244                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1245                // alloc_policy: None — analyze runs but skips compute_alloc_info
1246                ..Default::default()
1247            },
1248        );
1249        let analysis = result.analysis.expect("analyze ran");
1250        let fa = analysis
1251            .fn_analyses
1252            .get("id")
1253            .expect("id is in the analysis");
1254        assert!(
1255            fa.allocates.is_none(),
1256            "without an alloc_policy, allocates stays None (every other field still set)"
1257        );
1258    }
1259
1260    #[test]
1261    fn last_use_runs_only_after_resolve() {
1262        // Pipeline ordering invariant: LastUse needs Resolved nodes to
1263        // annotate. Skipping Resolve while running LastUse is legal but
1264        // the pass becomes a no-op (no resolved slots in the IR yet).
1265        // Here we verify it doesn't panic and pipeline returns normally.
1266        let mut items = parse(
1267            r#"
1268module M
1269    intent = "test"
1270    depends []
1271
1272fn id(n: Int) -> Int
1273    n
1274"#,
1275        );
1276        let mut fired: Vec<PipelineStage> = Vec::new();
1277        run(
1278            &mut items,
1279            PipelineConfig {
1280                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1281                run_resolve: false,
1282                run_analyze: false,
1283                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1284                ..Default::default()
1285            },
1286        );
1287        assert_eq!(
1288            fired,
1289            vec![
1290                PipelineStage::Tco,
1291                PipelineStage::Typecheck,
1292                PipelineStage::InterpLower,
1293                PipelineStage::BufferBuild,
1294                PipelineStage::Escape,
1295                PipelineStage::LastUse, // fires even without Resolve — a no-op pass
1296                // NameResolve runs unconditionally after
1297                // BuildSymbols (Phase E of #147).
1298                PipelineStage::NameResolve,
1299            ]
1300        );
1301    }
1302
1303    #[test]
1304    fn pass_diagnostics_recorded_for_each_stage_that_ran() {
1305        let mut items = parse(
1306            r#"
1307module M
1308    intent = "test"
1309    depends []
1310
1311fn factorial(n: Int, acc: Int) -> Int
1312    match n
1313        0 -> acc
1314        _ -> factorial(n - 1, acc * n)
1315"#,
1316        );
1317        let result = run(
1318            &mut items,
1319            PipelineConfig {
1320                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1321                ..Default::default()
1322            },
1323        );
1324        let stages: Vec<PipelineStage> = result.pass_diagnostics.iter().map(|d| d.stage).collect();
1325        assert_eq!(
1326            stages,
1327            vec![
1328                PipelineStage::Tco,
1329                PipelineStage::Typecheck,
1330                PipelineStage::InterpLower,
1331                PipelineStage::BufferBuild,
1332                PipelineStage::Resolve,
1333                PipelineStage::Analyze,
1334                PipelineStage::Escape,
1335                PipelineStage::LastUse,
1336                // Symbol table is built unconditionally (universal
1337                // foundation for proof IR + backend identity); the
1338                // diagnostic always lands in `pass_diagnostics`.
1339                PipelineStage::BuildSymbols,
1340                // NameResolve same — unconditional Phase E pass.
1341                PipelineStage::NameResolve,
1342            ]
1343        );
1344
1345        let tco_diag = &result.pass_diagnostics[0];
1346        match &tco_diag.report {
1347            PassReport::Tco {
1348                tail_calls_added,
1349                fns_changed,
1350                ..
1351            } => {
1352                assert!(*tail_calls_added >= 1, "factorial got at least one TCO");
1353                assert!(
1354                    fns_changed.iter().any(|c| c.name == "factorial"),
1355                    "fns_changed must list factorial: {fns_changed:?}"
1356                );
1357            }
1358            other => panic!("expected Tco report, got {other:?}"),
1359        }
1360
1361        let bb_diag = result
1362            .pass_diagnostics
1363            .iter()
1364            .find(|d| d.stage == PipelineStage::BufferBuild)
1365            .unwrap();
1366        match &bb_diag.report {
1367            PassReport::BufferBuild(r) => {
1368                assert_eq!(r.rewrites, 0, "factorial-only program has no fusion sites")
1369            }
1370            other => panic!("expected BufferBuild report, got {other:?}"),
1371        }
1372
1373        let resolve_diag = result
1374            .pass_diagnostics
1375            .iter()
1376            .find(|d| d.stage == PipelineStage::Resolve)
1377            .unwrap();
1378        match &resolve_diag.report {
1379            PassReport::Resolve { slots_resolved, .. } => assert!(
1380                *slots_resolved > 0,
1381                "factorial body resolves at least one ident"
1382            ),
1383            other => panic!("expected Resolve report, got {other:?}"),
1384        }
1385    }
1386
1387    #[test]
1388    fn name_resolve_runs_unconditionally() {
1389        let mut items = parse(
1390            r#"
1391module M
1392    intent = "test"
1393    depends []
1394
1395fn id(n: Int) -> Int
1396    n
1397"#,
1398        );
1399        let result = run(
1400            &mut items,
1401            PipelineConfig {
1402                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1403                ..Default::default()
1404            },
1405        );
1406        // Phase E (#147): NameResolve runs unconditionally, like
1407        // `BuildSymbols`. `resolved_items` is always populated —
1408        // the pipeline is the resolver's caller, and backends
1409        // consume the result rather than re-resolving themselves.
1410        assert!(
1411            !result.resolved_items.is_empty(),
1412            "NameResolve runs unconditionally; resolved_items must be populated"
1413        );
1414        let saw_name_resolve = result
1415            .pass_diagnostics
1416            .iter()
1417            .any(|d| d.stage == PipelineStage::NameResolve);
1418        assert!(
1419            saw_name_resolve,
1420            "NameResolve stage must always fire its diagnostic"
1421        );
1422    }
1423
1424    #[test]
1425    fn name_resolve_populates_resolved_items() {
1426        let mut items = parse(
1427            r#"
1428module M
1429    intent = "test"
1430    depends []
1431
1432fn helper(n: Int) -> Int
1433    n + 1
1434
1435fn main() -> Int
1436    helper(7)
1437"#,
1438        );
1439        let result = run(
1440            &mut items,
1441            PipelineConfig {
1442                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1443                ..Default::default()
1444            },
1445        );
1446        let resolved = result.resolved_items;
1447        // Two FnDefs (helper + main) promoted; the Module item
1448        // lands as ResolvedTopLevel::Module — neither promoted nor
1449        // passthrough. So exactly 2 promoted, 0 passthrough.
1450        use crate::ir::hir::ResolvedTopLevel;
1451        let promoted = resolved
1452            .iter()
1453            .filter(|t| matches!(t, ResolvedTopLevel::FnDef(_)))
1454            .count();
1455        let passthrough = resolved
1456            .iter()
1457            .filter(|t| matches!(t, ResolvedTopLevel::Passthrough(_)))
1458            .count();
1459        assert_eq!(promoted, 2, "helper + main should both promote");
1460        assert_eq!(passthrough, 0, "no non-fn items in this fixture");
1461
1462        // Diagnostic mirrors the counts.
1463        let diag = result
1464            .pass_diagnostics
1465            .iter()
1466            .find(|d| d.stage == PipelineStage::NameResolve)
1467            .expect("NameResolve diagnostic missing");
1468        match &diag.report {
1469            PassReport::NameResolve {
1470                promoted_fns,
1471                passthrough_items,
1472                unresolved_count,
1473            } => {
1474                assert_eq!(*promoted_fns, 2);
1475                assert_eq!(*passthrough_items, 0);
1476                // Well-typed input — the resolver MUST classify
1477                // every reference. Non-zero would mean the resolver
1478                // bailed to a `ResolvedCallee::Unresolved` /
1479                // `ResolvedCtor::Unresolved` escape hatch.
1480                assert_eq!(
1481                    *unresolved_count, 0,
1482                    "well-typed program must produce zero unresolved nodes"
1483                );
1484            }
1485            other => panic!("expected NameResolve report, got {other:?}"),
1486        }
1487    }
1488
1489    /// Phase E contract gate: for well-typed programs, the resolver
1490    /// MUST classify every reference. `ResolvedCallee::Unresolved` /
1491    /// `ResolvedCtor::Unresolved` are recovery escape hatches for
1492    /// typecheck-error programs; their occurrence in well-typed
1493    /// input is a resolver gap, not a legitimate state.
1494    #[test]
1495    fn name_resolve_invariant_zero_unresolved_for_well_typed_programs() {
1496        // Battery of well-typed fixtures exercising every shape the
1497        // resolver classifies: user fn calls (incl. recursion + TCO),
1498        // builtin namespace methods, user ctors, builtin ctors,
1499        // record create + update, match patterns, binding
1500        // annotations.
1501        let fixtures: &[&str] = &[
1502            // Recursive fn, builtin call, binding annotation.
1503            r#"
1504fn count(n: Int, acc: Int) -> Int
1505    match n
1506        0 -> acc
1507        _ -> count(n - 1, acc + Int.abs(-1))
1508
1509fn main() -> Int
1510    x: Int = count(5, 0)
1511    x
1512"#,
1513            // User sum type + variants + match arms.
1514            r#"
1515type Shape
1516    Circle(Float)
1517    Square(Float)
1518
1519fn area(s: Shape) -> Float
1520    match s
1521        Shape.Circle(r) -> r * r
1522        Shape.Square(s) -> s * s
1523
1524fn main() -> Float
1525    area(Shape.Circle(3.0))
1526"#,
1527            // Record create + update + builtin ctor.
1528            r#"
1529record Point
1530    x: Int
1531    y: Int
1532
1533fn origin() -> Point
1534    Point(x = 0, y = 0)
1535
1536fn shift(p: Point) -> Result<Point, String>
1537    Result.Ok(Point.update(p, x = p.x + 1))
1538"#,
1539            // Interpolation — exercises `interp_lower` synthesizing
1540            // `__buf_*` / `__to_str` intrinsics. With the post-Phase-E
1541            // resolver these land as `ResolvedCallee::Intrinsic(_)`,
1542            // not as `Unresolved`, so the invariant must hold even
1543            // after lowering passes have run.
1544            r#"
1545fn greet(n: Int) -> String
1546    "hi ${n}!"
1547
1548fn main() -> String
1549    greet(7)
1550"#,
1551        ];
1552        for (i, src) in fixtures.iter().enumerate() {
1553            let mut items = parse(src);
1554            let result = run(
1555                &mut items,
1556                PipelineConfig {
1557                    typecheck: Some(TypecheckMode::Full { base_dir: None }),
1558                    ..Default::default()
1559                },
1560            );
1561            // Sanity: input must actually typecheck — otherwise the
1562            // invariant doesn't apply (recovery escapes are allowed
1563            // for broken programs).
1564            if let Some(tc) = result.typecheck.as_ref() {
1565                assert!(
1566                    tc.errors.is_empty(),
1567                    "fixture #{i} did not typecheck: {:?}",
1568                    tc.errors
1569                );
1570            }
1571            let diag = result
1572                .pass_diagnostics
1573                .iter()
1574                .find(|d| d.stage == PipelineStage::NameResolve)
1575                .expect("NameResolve diagnostic missing");
1576            match &diag.report {
1577                PassReport::NameResolve {
1578                    unresolved_count, ..
1579                } => {
1580                    assert_eq!(
1581                        *unresolved_count, 0,
1582                        "fixture #{i}: well-typed program produced {} unresolved nodes",
1583                        *unresolved_count
1584                    );
1585                }
1586                other => panic!("expected NameResolve report, got {other:?}"),
1587            }
1588        }
1589    }
1590
1591    #[test]
1592    fn name_resolve_runs_after_build_symbols() {
1593        // The resolver needs the symbol table — it must always come
1594        // after BuildSymbols in the firing order.
1595        let mut items = parse(
1596            r#"
1597module M
1598    intent = "test"
1599    depends []
1600
1601fn id(n: Int) -> Int
1602    n
1603"#,
1604        );
1605        let mut fired: Vec<PipelineStage> = Vec::new();
1606        run(
1607            &mut items,
1608            PipelineConfig {
1609                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1610                run_build_symbols: true,
1611                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1612                ..Default::default()
1613            },
1614        );
1615        let build_pos = fired
1616            .iter()
1617            .position(|s| *s == PipelineStage::BuildSymbols)
1618            .expect("BuildSymbols fired");
1619        let resolve_pos = fired
1620            .iter()
1621            .position(|s| *s == PipelineStage::NameResolve)
1622            .expect("NameResolve fired");
1623        assert!(
1624            resolve_pos > build_pos,
1625            "NameResolve must fire after BuildSymbols (saw {fired:?})"
1626        );
1627    }
1628}