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