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