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        let inputs = crate::codegen::proof_lower::ProofLowerInputs {
670            entry_items: items,
671            dep_modules: cfg.dep_modules,
672            module_prefixes: &module_prefixes,
673            recursive_fns: &recursive_fns_owned,
674            symbol_table: symbols,
675        };
676        let mut ir = result.proof_ir.take().unwrap_or_default();
677        if cfg.run_refinement_lower {
678            crate::codegen::proof_lower::populate_refined_types(&inputs, &mut ir);
679            result.pass_diagnostics.push(diag_for_refinement_lower(&ir));
680            fire(&mut cfg, PipelineStage::RefinementLower, items);
681        }
682        if cfg.run_contract_lower {
683            crate::codegen::proof_lower::populate_fn_contracts(&inputs, &mut ir);
684            result.pass_diagnostics.push(diag_for_contract_lower(&ir));
685            fire(&mut cfg, PipelineStage::ContractLower, items);
686        }
687        if cfg.run_law_lower {
688            crate::codegen::proof_lower::populate_law_theorems(&inputs, &mut ir);
689            result.pass_diagnostics.push(diag_for_law_lower(&ir));
690            fire(&mut cfg, PipelineStage::LawLower, items);
691        }
692        result.proof_ir = Some(ir);
693    }
694
695    result
696}
697
698/// Bridges the trait-object `cfg.call_ctx: Option<&dyn CallLowerCtx>`
699/// into the generic-impl world that the IR classifiers (`classify_call_plan`,
700/// `classify_thin_fn_def`, …) expect (`&impl CallLowerCtx`). When the
701/// option is `None` every method returns the conservative answer.
702struct CallCtxAdapter<'a>(Option<&'a dyn CallLowerCtx>);
703
704impl<'a> CallLowerCtx for CallCtxAdapter<'a> {
705    fn is_local_value(&self, name: &str) -> bool {
706        self.0.is_some_and(|c| c.is_local_value(name))
707    }
708    fn is_user_type(&self, name: &str) -> bool {
709        self.0.is_some_and(|c| c.is_user_type(name))
710    }
711    fn resolve_module_call<'b>(&self, dotted: &'b str) -> Option<(&'b str, &'b str)> {
712        self.0.and_then(|c| c.resolve_module_call(dotted))
713    }
714}
715
716fn fire(cfg: &mut PipelineConfig<'_>, stage: PipelineStage, items: &[TopLevel]) {
717    if let Some(cb) = cfg.on_after_pass.as_mut() {
718        cb(stage, items);
719    }
720}
721
722// ── PassDiagnostic builders ─────────────────────────────────────────
723
724fn diag_for_tco(pre: &CountsByFn, post: &CountsByFn, items: &[TopLevel]) -> PassDiagnostic {
725    let pre_total = pass_diag::total(pre);
726    let post_total = pass_diag::total(post);
727    let tail_calls_added = post_total.tail_calls.saturating_sub(pre_total.tail_calls);
728
729    let fns_changed: Vec<FnCountChange> = pass_diag::fns_that_grew(pre, post, |c| c.tail_calls)
730        .into_iter()
731        .map(|name| {
732            let before = pre.get(&name).copied().unwrap_or_default().tail_calls;
733            let after = post.get(&name).copied().unwrap_or_default().tail_calls;
734            FnCountChange {
735                name,
736                before,
737                after,
738            }
739        })
740        .collect();
741
742    let non_tail_recursive: Vec<NonTailEntry> =
743        crate::tail_check::collect_non_tail_recursion_warnings(items)
744            .into_iter()
745            .map(|w| NonTailEntry {
746                fn_name: w.fn_name,
747                recursive_calls: w.recursive_calls,
748                line: w.line,
749            })
750            .collect();
751
752    PassDiagnostic {
753        stage: PipelineStage::Tco,
754        report: PassReport::Tco {
755            tail_calls_added,
756            fns_changed,
757            non_tail_recursive,
758        },
759    }
760}
761
762fn diag_for_typecheck(tc: &TypeCheckResult, item_count: usize) -> PassDiagnostic {
763    let error_messages = if tc.errors.is_empty() {
764        Vec::new()
765    } else {
766        tc.errors
767            .iter()
768            .take(5)
769            .map(|e| e.message.clone())
770            .collect()
771    };
772    PassDiagnostic {
773        stage: PipelineStage::Typecheck,
774        report: PassReport::Typecheck {
775            items_checked: item_count,
776            errors: tc.errors.len(),
777            error_messages,
778        },
779    }
780}
781
782fn diag_for_interp_lower(pre: &CountsByFn, post: &CountsByFn) -> PassDiagnostic {
783    let interpolations_lowered = pass_diag::total(pre)
784        .interpolations
785        .saturating_sub(pass_diag::total(post).interpolations);
786    let fns_changed: Vec<FnCountChange> = pass_diag::fns_that_grew(post, pre, |c| c.interpolations)
787        .into_iter()
788        .map(|name| {
789            let before = pre.get(&name).copied().unwrap_or_default().interpolations;
790            let after = post.get(&name).copied().unwrap_or_default().interpolations;
791            FnCountChange {
792                name,
793                before,
794                after,
795            }
796        })
797        .collect();
798    PassDiagnostic {
799        stage: PipelineStage::InterpLower,
800        report: PassReport::InterpLower {
801            interpolations_lowered,
802            fns_changed,
803        },
804    }
805}
806
807fn diag_for_buffer_build(report: &BufferBuildPassReport) -> PassDiagnostic {
808    PassDiagnostic {
809        stage: PipelineStage::BufferBuild,
810        report: PassReport::BufferBuild(report.clone()),
811    }
812}
813
814fn diag_for_resolve(post: &CountsByFn, items: &[TopLevel]) -> PassDiagnostic {
815    let slots_resolved = pass_diag::total(post).resolved;
816    let fns_with_slots = post.values().filter(|c| c.resolved > 0).count();
817    let mut slot_types_total = 0usize;
818    let mut slot_types_invalid = 0usize;
819    for item in items {
820        if let TopLevel::FnDef(fd) = item
821            && let Some(res) = fd.resolution.as_ref()
822        {
823            slot_types_total += res.local_slot_types.len();
824            slot_types_invalid += res
825                .local_slot_types
826                .iter()
827                .filter(|t| matches!(t, crate::ast::Type::Invalid))
828                .count();
829        }
830    }
831    PassDiagnostic {
832        stage: PipelineStage::Resolve,
833        report: PassReport::Resolve {
834            slots_resolved,
835            fns_with_slots,
836            slot_types_total,
837            slot_types_invalid,
838        },
839    }
840}
841
842fn diag_for_last_use(post: &CountsByFn) -> PassDiagnostic {
843    let totals = pass_diag::total(post);
844    PassDiagnostic {
845        stage: PipelineStage::LastUse,
846        report: PassReport::LastUse {
847            last_use_marked: totals.last_use_resolved,
848            total_resolved: totals.resolved,
849        },
850    }
851}
852
853fn diag_for_escape(rewrites: usize) -> PassDiagnostic {
854    PassDiagnostic {
855        stage: PipelineStage::Escape,
856        report: PassReport::Escape { rewrites },
857    }
858}
859
860fn diag_for_refinement_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
861    PassDiagnostic {
862        stage: PipelineStage::RefinementLower,
863        report: PassReport::RefinementLower {
864            refined_types: ir.refined_types.len(),
865        },
866    }
867}
868
869fn diag_for_contract_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
870    PassDiagnostic {
871        stage: PipelineStage::ContractLower,
872        report: PassReport::ContractLower {
873            fn_contracts: ir.fn_contracts.len(),
874        },
875    }
876}
877
878fn diag_for_law_lower(ir: &crate::ir::ProofIR) -> PassDiagnostic {
879    PassDiagnostic {
880        stage: PipelineStage::LawLower,
881        report: PassReport::LawLower {
882            law_theorems: ir.law_theorems.len(),
883        },
884    }
885}
886
887fn diag_for_build_symbols(table: &crate::ir::SymbolTable) -> PassDiagnostic {
888    // Per-module breakdown: walk every fn/type/ctor, bucket by its
889    // owning module, count. Single pass, O(fns + types + ctors).
890    let mut per_module: Vec<BuildSymbolsModule> = table
891        .modules
892        .iter()
893        .map(|m| BuildSymbolsModule {
894            prefix: m.prefix.clone().unwrap_or_default(),
895            fns: 0,
896            types: 0,
897            ctors: 0,
898        })
899        .collect();
900    for fe in &table.fns {
901        per_module[fe.module.0 as usize].fns += 1;
902    }
903    for te in &table.types {
904        per_module[te.module.0 as usize].types += 1;
905    }
906    for ce in &table.ctors {
907        let owning_module = table.types[ce.owning_type.0 as usize].module;
908        per_module[owning_module.0 as usize].ctors += 1;
909    }
910
911    // Bare-name collision count: a fn/type with the same `key.name`
912    // appearing in 2+ different scopes. Under bare-name keying these
913    // would silently merge; under opaque IDs each gets its own slot.
914    use std::collections::HashMap;
915    let mut fn_buckets: HashMap<&str, usize> = HashMap::new();
916    for fe in &table.fns {
917        *fn_buckets.entry(fe.key.name.as_str()).or_default() += 1;
918    }
919    let fn_name_collisions = fn_buckets.values().filter(|c| **c >= 2).count();
920    let mut type_buckets: HashMap<&str, usize> = HashMap::new();
921    for te in &table.types {
922        *type_buckets.entry(te.key.name.as_str()).or_default() += 1;
923    }
924    let type_name_collisions = type_buckets.values().filter(|c| **c >= 2).count();
925
926    PassDiagnostic {
927        stage: PipelineStage::BuildSymbols,
928        report: PassReport::BuildSymbols {
929            fns: table.fns.len(),
930            types: table.types.len(),
931            ctors: table.ctors.len(),
932            modules: per_module,
933            fn_name_collisions,
934            type_name_collisions,
935        },
936    }
937}
938
939fn diag_for_name_resolve(items: &[crate::ir::hir::ResolvedTopLevel]) -> PassDiagnostic {
940    use crate::ir::hir::ResolvedTopLevel;
941    let mut promoted_fns = 0;
942    let mut passthrough_items = 0;
943    let mut unresolved_count = 0;
944    for item in items {
945        match item {
946            ResolvedTopLevel::FnDef(fd) => {
947                promoted_fns += 1;
948                unresolved_count += crate::ir::hir::count_unresolved_in_fn(fd);
949            }
950            ResolvedTopLevel::Passthrough(_) => passthrough_items += 1,
951            ResolvedTopLevel::Module(_) => {}
952        }
953    }
954    PassDiagnostic {
955        stage: PipelineStage::NameResolve,
956        report: PassReport::NameResolve {
957            promoted_fns,
958            passthrough_items,
959            unresolved_count,
960        },
961    }
962}
963
964fn diag_for_analyze(analysis: &AnalysisResult) -> PassDiagnostic {
965    let total_fns = analysis.fn_analyses.len();
966    let no_alloc_fns = analysis
967        .fn_analyses
968        .values()
969        .filter(|fa| fa.allocates == Some(false))
970        .count();
971    let unknown_alloc = analysis
972        .fn_analyses
973        .values()
974        .filter(|fa| fa.allocates.is_none())
975        .count();
976    PassDiagnostic {
977        stage: PipelineStage::Analyze,
978        report: PassReport::Analyze {
979            total_fns,
980            no_alloc_fns,
981            recursive_fns: analysis.recursive_fns.len(),
982            mutual_tco_members: analysis.mutual_tco_members.len(),
983            unknown_alloc,
984        },
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use crate::source::parse_source;
992
993    fn parse(src: &str) -> Vec<TopLevel> {
994        parse_source(src).expect("parse failed")
995    }
996
997    #[test]
998    fn default_config_fires_every_stage_in_order() {
999        let mut items = parse(
1000            r#"
1001module M
1002    intent = "test"
1003    depends []
1004
1005fn id(n: Int) -> Int
1006    n
1007"#,
1008        );
1009        let mut fired: Vec<PipelineStage> = Vec::new();
1010        run(
1011            &mut items,
1012            PipelineConfig {
1013                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1014                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1015                ..Default::default()
1016            },
1017        );
1018        assert_eq!(
1019            fired,
1020            vec![
1021                PipelineStage::Tco,
1022                PipelineStage::Typecheck,
1023                PipelineStage::InterpLower,
1024                PipelineStage::BufferBuild,
1025                PipelineStage::Resolve,
1026                PipelineStage::Analyze,
1027                PipelineStage::Escape,
1028                PipelineStage::LastUse,
1029                // Phase E (#147): NameResolve runs unconditionally
1030                // after BuildSymbols (which fires only via flag, but
1031                // the resolved AST is always built).
1032                PipelineStage::NameResolve,
1033            ]
1034        );
1035    }
1036
1037    #[test]
1038    fn disabled_stages_dont_fire() {
1039        let mut items = parse(
1040            r#"
1041module M
1042    intent = "test"
1043    depends []
1044
1045fn id(n: Int) -> Int
1046    n
1047"#,
1048        );
1049        let mut fired: Vec<PipelineStage> = Vec::new();
1050        run(
1051            &mut items,
1052            PipelineConfig {
1053                typecheck: None,
1054                run_interp_lower: false,
1055                run_buffer_build: false,
1056                run_last_use: false,
1057                run_analyze: false,
1058                run_escape: false,
1059                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1060                ..Default::default()
1061            },
1062        );
1063        // NameResolve runs unconditionally after BuildSymbols, even
1064        // when most other stages are disabled.
1065        assert_eq!(
1066            fired,
1067            vec![
1068                PipelineStage::Tco,
1069                PipelineStage::Resolve,
1070                PipelineStage::NameResolve,
1071            ]
1072        );
1073    }
1074
1075    #[test]
1076    fn typecheck_errors_skip_later_stages() {
1077        // Reference an undefined identifier so typecheck reports an error.
1078        let mut items = parse(
1079            r#"
1080module M
1081    intent = "test"
1082    depends []
1083
1084fn broken() -> Int
1085    undefined_thing
1086"#,
1087        );
1088        let mut fired: Vec<PipelineStage> = Vec::new();
1089        let result = run(
1090            &mut items,
1091            PipelineConfig {
1092                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1093                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1094                ..Default::default()
1095            },
1096        );
1097        assert!(
1098            !result.typecheck.unwrap().errors.is_empty(),
1099            "typecheck must surface the undefined identifier"
1100        );
1101        // Tco fired, typecheck fired, then we bailed out — no later stages.
1102        assert_eq!(fired, vec![PipelineStage::Tco, PipelineStage::Typecheck]);
1103    }
1104
1105    #[test]
1106    fn analyze_populates_result_when_enabled() {
1107        let mut items = parse(
1108            r#"
1109module M
1110    intent = "test"
1111    depends []
1112
1113fn id(n: Int) -> Int
1114    n
1115
1116fn dub(n: Int) -> Int
1117    n + n
1118"#,
1119        );
1120        let result = run(
1121            &mut items,
1122            PipelineConfig {
1123                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1124                ..Default::default()
1125            },
1126        );
1127        let analysis = result
1128            .analysis
1129            .expect("analyze runs by default and must populate result");
1130        assert!(
1131            analysis.fn_analyses.contains_key("id"),
1132            "every user fn shows up in fn_analyses, got keys: {:?}",
1133            analysis.fn_analyses.keys().collect::<Vec<_>>()
1134        );
1135        assert!(analysis.fn_analyses.contains_key("dub"));
1136    }
1137
1138    #[test]
1139    fn analyze_skipped_when_disabled() {
1140        let mut items = parse(
1141            r#"
1142module M
1143    intent = "test"
1144    depends []
1145
1146fn id(n: Int) -> Int
1147    n
1148"#,
1149        );
1150        let result = run(
1151            &mut items,
1152            PipelineConfig {
1153                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1154                run_analyze: false,
1155                ..Default::default()
1156            },
1157        );
1158        assert!(
1159            result.analysis.is_none(),
1160            "run_analyze=false must leave PipelineResult.analysis as None"
1161        );
1162    }
1163
1164    #[test]
1165    fn alloc_policy_populates_per_fn_allocates() {
1166        let mut items = parse(
1167            r#"
1168module M
1169    intent = "test"
1170    depends []
1171
1172fn pure_one() -> Int
1173    1
1174
1175fn allocates_list(n: Int) -> List<Int>
1176    [n, n, n]
1177"#,
1178        );
1179        let policy = crate::ir::NeutralAllocPolicy;
1180        let result = run(
1181            &mut items,
1182            PipelineConfig {
1183                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1184                alloc_policy: Some(&policy),
1185                ..Default::default()
1186            },
1187        );
1188        let analysis = result.analysis.expect("analyze ran");
1189        assert_eq!(
1190            analysis
1191                .fn_analyses
1192                .get("pure_one")
1193                .and_then(|fa| fa.allocates),
1194            Some(false),
1195            "pure_one returns a literal — proven not to allocate"
1196        );
1197        assert_eq!(
1198            analysis
1199                .fn_analyses
1200                .get("allocates_list")
1201                .and_then(|fa| fa.allocates),
1202            Some(true),
1203            "list literal allocates under the neutral policy"
1204        );
1205    }
1206
1207    #[test]
1208    fn analyze_without_policy_leaves_allocates_unset() {
1209        let mut items = parse(
1210            r#"
1211module M
1212    intent = "test"
1213    depends []
1214
1215fn id(n: Int) -> Int
1216    n
1217"#,
1218        );
1219        let result = run(
1220            &mut items,
1221            PipelineConfig {
1222                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1223                // alloc_policy: None — analyze runs but skips compute_alloc_info
1224                ..Default::default()
1225            },
1226        );
1227        let analysis = result.analysis.expect("analyze ran");
1228        let fa = analysis
1229            .fn_analyses
1230            .get("id")
1231            .expect("id is in the analysis");
1232        assert!(
1233            fa.allocates.is_none(),
1234            "without an alloc_policy, allocates stays None (every other field still set)"
1235        );
1236    }
1237
1238    #[test]
1239    fn last_use_runs_only_after_resolve() {
1240        // Pipeline ordering invariant: LastUse needs Resolved nodes to
1241        // annotate. Skipping Resolve while running LastUse is legal but
1242        // the pass becomes a no-op (no resolved slots in the IR yet).
1243        // Here we verify it doesn't panic and pipeline returns normally.
1244        let mut items = parse(
1245            r#"
1246module M
1247    intent = "test"
1248    depends []
1249
1250fn id(n: Int) -> Int
1251    n
1252"#,
1253        );
1254        let mut fired: Vec<PipelineStage> = Vec::new();
1255        run(
1256            &mut items,
1257            PipelineConfig {
1258                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1259                run_resolve: false,
1260                run_analyze: false,
1261                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1262                ..Default::default()
1263            },
1264        );
1265        assert_eq!(
1266            fired,
1267            vec![
1268                PipelineStage::Tco,
1269                PipelineStage::Typecheck,
1270                PipelineStage::InterpLower,
1271                PipelineStage::BufferBuild,
1272                PipelineStage::Escape,
1273                PipelineStage::LastUse, // fires even without Resolve — a no-op pass
1274                // NameResolve runs unconditionally after
1275                // BuildSymbols (Phase E of #147).
1276                PipelineStage::NameResolve,
1277            ]
1278        );
1279    }
1280
1281    #[test]
1282    fn pass_diagnostics_recorded_for_each_stage_that_ran() {
1283        let mut items = parse(
1284            r#"
1285module M
1286    intent = "test"
1287    depends []
1288
1289fn factorial(n: Int, acc: Int) -> Int
1290    match n
1291        0 -> acc
1292        _ -> factorial(n - 1, acc * n)
1293"#,
1294        );
1295        let result = run(
1296            &mut items,
1297            PipelineConfig {
1298                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1299                ..Default::default()
1300            },
1301        );
1302        let stages: Vec<PipelineStage> = result.pass_diagnostics.iter().map(|d| d.stage).collect();
1303        assert_eq!(
1304            stages,
1305            vec![
1306                PipelineStage::Tco,
1307                PipelineStage::Typecheck,
1308                PipelineStage::InterpLower,
1309                PipelineStage::BufferBuild,
1310                PipelineStage::Resolve,
1311                PipelineStage::Analyze,
1312                PipelineStage::Escape,
1313                PipelineStage::LastUse,
1314                // Symbol table is built unconditionally (universal
1315                // foundation for proof IR + backend identity); the
1316                // diagnostic always lands in `pass_diagnostics`.
1317                PipelineStage::BuildSymbols,
1318                // NameResolve same — unconditional Phase E pass.
1319                PipelineStage::NameResolve,
1320            ]
1321        );
1322
1323        let tco_diag = &result.pass_diagnostics[0];
1324        match &tco_diag.report {
1325            PassReport::Tco {
1326                tail_calls_added,
1327                fns_changed,
1328                ..
1329            } => {
1330                assert!(*tail_calls_added >= 1, "factorial got at least one TCO");
1331                assert!(
1332                    fns_changed.iter().any(|c| c.name == "factorial"),
1333                    "fns_changed must list factorial: {fns_changed:?}"
1334                );
1335            }
1336            other => panic!("expected Tco report, got {other:?}"),
1337        }
1338
1339        let bb_diag = result
1340            .pass_diagnostics
1341            .iter()
1342            .find(|d| d.stage == PipelineStage::BufferBuild)
1343            .unwrap();
1344        match &bb_diag.report {
1345            PassReport::BufferBuild(r) => {
1346                assert_eq!(r.rewrites, 0, "factorial-only program has no fusion sites")
1347            }
1348            other => panic!("expected BufferBuild report, got {other:?}"),
1349        }
1350
1351        let resolve_diag = result
1352            .pass_diagnostics
1353            .iter()
1354            .find(|d| d.stage == PipelineStage::Resolve)
1355            .unwrap();
1356        match &resolve_diag.report {
1357            PassReport::Resolve { slots_resolved, .. } => assert!(
1358                *slots_resolved > 0,
1359                "factorial body resolves at least one ident"
1360            ),
1361            other => panic!("expected Resolve report, got {other:?}"),
1362        }
1363    }
1364
1365    #[test]
1366    fn name_resolve_runs_unconditionally() {
1367        let mut items = parse(
1368            r#"
1369module M
1370    intent = "test"
1371    depends []
1372
1373fn id(n: Int) -> Int
1374    n
1375"#,
1376        );
1377        let result = run(
1378            &mut items,
1379            PipelineConfig {
1380                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1381                ..Default::default()
1382            },
1383        );
1384        // Phase E (#147): NameResolve runs unconditionally, like
1385        // `BuildSymbols`. `resolved_items` is always populated —
1386        // the pipeline is the resolver's caller, and backends
1387        // consume the result rather than re-resolving themselves.
1388        assert!(
1389            !result.resolved_items.is_empty(),
1390            "NameResolve runs unconditionally; resolved_items must be populated"
1391        );
1392        let saw_name_resolve = result
1393            .pass_diagnostics
1394            .iter()
1395            .any(|d| d.stage == PipelineStage::NameResolve);
1396        assert!(
1397            saw_name_resolve,
1398            "NameResolve stage must always fire its diagnostic"
1399        );
1400    }
1401
1402    #[test]
1403    fn name_resolve_populates_resolved_items() {
1404        let mut items = parse(
1405            r#"
1406module M
1407    intent = "test"
1408    depends []
1409
1410fn helper(n: Int) -> Int
1411    n + 1
1412
1413fn main() -> Int
1414    helper(7)
1415"#,
1416        );
1417        let result = run(
1418            &mut items,
1419            PipelineConfig {
1420                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1421                ..Default::default()
1422            },
1423        );
1424        let resolved = result.resolved_items;
1425        // Two FnDefs (helper + main) promoted; the Module item
1426        // lands as ResolvedTopLevel::Module — neither promoted nor
1427        // passthrough. So exactly 2 promoted, 0 passthrough.
1428        use crate::ir::hir::ResolvedTopLevel;
1429        let promoted = resolved
1430            .iter()
1431            .filter(|t| matches!(t, ResolvedTopLevel::FnDef(_)))
1432            .count();
1433        let passthrough = resolved
1434            .iter()
1435            .filter(|t| matches!(t, ResolvedTopLevel::Passthrough(_)))
1436            .count();
1437        assert_eq!(promoted, 2, "helper + main should both promote");
1438        assert_eq!(passthrough, 0, "no non-fn items in this fixture");
1439
1440        // Diagnostic mirrors the counts.
1441        let diag = result
1442            .pass_diagnostics
1443            .iter()
1444            .find(|d| d.stage == PipelineStage::NameResolve)
1445            .expect("NameResolve diagnostic missing");
1446        match &diag.report {
1447            PassReport::NameResolve {
1448                promoted_fns,
1449                passthrough_items,
1450                unresolved_count,
1451            } => {
1452                assert_eq!(*promoted_fns, 2);
1453                assert_eq!(*passthrough_items, 0);
1454                // Well-typed input — the resolver MUST classify
1455                // every reference. Non-zero would mean the resolver
1456                // bailed to a `ResolvedCallee::Unresolved` /
1457                // `ResolvedCtor::Unresolved` escape hatch.
1458                assert_eq!(
1459                    *unresolved_count, 0,
1460                    "well-typed program must produce zero unresolved nodes"
1461                );
1462            }
1463            other => panic!("expected NameResolve report, got {other:?}"),
1464        }
1465    }
1466
1467    /// Phase E contract gate: for well-typed programs, the resolver
1468    /// MUST classify every reference. `ResolvedCallee::Unresolved` /
1469    /// `ResolvedCtor::Unresolved` are recovery escape hatches for
1470    /// typecheck-error programs; their occurrence in well-typed
1471    /// input is a resolver gap, not a legitimate state.
1472    #[test]
1473    fn name_resolve_invariant_zero_unresolved_for_well_typed_programs() {
1474        // Battery of well-typed fixtures exercising every shape the
1475        // resolver classifies: user fn calls (incl. recursion + TCO),
1476        // builtin namespace methods, user ctors, builtin ctors,
1477        // record create + update, match patterns, binding
1478        // annotations.
1479        let fixtures: &[&str] = &[
1480            // Recursive fn, builtin call, binding annotation.
1481            r#"
1482fn count(n: Int, acc: Int) -> Int
1483    match n
1484        0 -> acc
1485        _ -> count(n - 1, acc + Int.abs(-1))
1486
1487fn main() -> Int
1488    x: Int = count(5, 0)
1489    x
1490"#,
1491            // User sum type + variants + match arms.
1492            r#"
1493type Shape
1494    Circle(Float)
1495    Square(Float)
1496
1497fn area(s: Shape) -> Float
1498    match s
1499        Shape.Circle(r) -> r * r
1500        Shape.Square(s) -> s * s
1501
1502fn main() -> Float
1503    area(Shape.Circle(3.0))
1504"#,
1505            // Record create + update + builtin ctor.
1506            r#"
1507record Point
1508    x: Int
1509    y: Int
1510
1511fn origin() -> Point
1512    Point(x = 0, y = 0)
1513
1514fn shift(p: Point) -> Result<Point, String>
1515    Result.Ok(Point.update(p, x = p.x + 1))
1516"#,
1517            // Interpolation — exercises `interp_lower` synthesizing
1518            // `__buf_*` / `__to_str` intrinsics. With the post-Phase-E
1519            // resolver these land as `ResolvedCallee::Intrinsic(_)`,
1520            // not as `Unresolved`, so the invariant must hold even
1521            // after lowering passes have run.
1522            r#"
1523fn greet(n: Int) -> String
1524    "hi ${n}!"
1525
1526fn main() -> String
1527    greet(7)
1528"#,
1529        ];
1530        for (i, src) in fixtures.iter().enumerate() {
1531            let mut items = parse(src);
1532            let result = run(
1533                &mut items,
1534                PipelineConfig {
1535                    typecheck: Some(TypecheckMode::Full { base_dir: None }),
1536                    ..Default::default()
1537                },
1538            );
1539            // Sanity: input must actually typecheck — otherwise the
1540            // invariant doesn't apply (recovery escapes are allowed
1541            // for broken programs).
1542            if let Some(tc) = result.typecheck.as_ref() {
1543                assert!(
1544                    tc.errors.is_empty(),
1545                    "fixture #{i} did not typecheck: {:?}",
1546                    tc.errors
1547                );
1548            }
1549            let diag = result
1550                .pass_diagnostics
1551                .iter()
1552                .find(|d| d.stage == PipelineStage::NameResolve)
1553                .expect("NameResolve diagnostic missing");
1554            match &diag.report {
1555                PassReport::NameResolve {
1556                    unresolved_count, ..
1557                } => {
1558                    assert_eq!(
1559                        *unresolved_count, 0,
1560                        "fixture #{i}: well-typed program produced {} unresolved nodes",
1561                        *unresolved_count
1562                    );
1563                }
1564                other => panic!("expected NameResolve report, got {other:?}"),
1565            }
1566        }
1567    }
1568
1569    #[test]
1570    fn name_resolve_runs_after_build_symbols() {
1571        // The resolver needs the symbol table — it must always come
1572        // after BuildSymbols in the firing order.
1573        let mut items = parse(
1574            r#"
1575module M
1576    intent = "test"
1577    depends []
1578
1579fn id(n: Int) -> Int
1580    n
1581"#,
1582        );
1583        let mut fired: Vec<PipelineStage> = Vec::new();
1584        run(
1585            &mut items,
1586            PipelineConfig {
1587                typecheck: Some(TypecheckMode::Full { base_dir: None }),
1588                run_build_symbols: true,
1589                on_after_pass: Some(Box::new(|stage, _| fired.push(stage))),
1590                ..Default::default()
1591            },
1592        );
1593        let build_pos = fired
1594            .iter()
1595            .position(|s| *s == PipelineStage::BuildSymbols)
1596            .expect("BuildSymbols fired");
1597        let resolve_pos = fired
1598            .iter()
1599            .position(|s| *s == PipelineStage::NameResolve)
1600            .expect("NameResolve fired");
1601        assert!(
1602            resolve_pos > build_pos,
1603            "NameResolve must fire after BuildSymbols (saw {fired:?})"
1604        );
1605    }
1606}