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