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