Wado

WEP: Elaborator God-Object Dismantlement — Decl Signatures, Scope, and the Body Walker

Companion to wep-2026-05-26-elaborator-rearchitecture.md. That WEP's Phase 1 (data decomposition: TypeSystem / ModuleSemantics / Reify) and most of Phase 2 (query migration onto TypeSystem) are done. This WEP owns the rest: the design that removes the remaining God-Object couplings from Elaborator and fixes its end state. It supersedes the old WEP's "Remaining" list; the design here was produced from a fresh survey of the code (2026-07), not by extrapolating the old plan.

Context

Elaborator is down to 19 fields, each with a documented home. Reify, InferCtx, CtrlFlowCtx, TypeLookup, and the impl TypeSystem query clusters already stand alone, so the decomposition pattern is proven. What still makes Elaborator a God Object is no longer data. It is four couplings.

One receiver, three roles

About 47k lines under elaborator/ extend the same struct. Walker code (expr / stmt / operators / item / module / handlers / closure / assert / the resolve_call / resolve_method_call trunks — the resolve_* recursion that writes sem and emits diagnostics) and query code (method_lookup / trait_query residue plus the callee-signature lookups in call.rs / method_call.rs) share one &mut self. Nothing but review discipline stops a query from mutating walk state or a walker arm from open-coding a query.

Queries re-resolve foreign declaration ASTs on demand

Roughly 40 loaded_modules reads survive outside reify. Every one exists to fetch a declaration's AST and re-resolve its signature at the use site:

Category Sites Consumed
Free-function signatures call.rs ×8, expr.rs ×2 type params, param types, return, is_mut
Impl headers + impl-method signatures method_lookup.rs, method_call.rs, call.rs, handlers.rs, expr.rs (~24 via get_impl_block and whole-module scans) impl ty / type params / assoc types / trait ref; method signatures
Trait-decl methods trait_query.rs ×3 signatures + has_body; bodies only for default-method synthesis
Effect ops / resource statics call.rs, method_call.rs ×3 op signatures, #[cm] attrs
Globals / data section / assoc-type bounds / type-decl collection / import scopes expr.rs, module.rs, type_resolution.rs, orchestration.rs, elaborator.rs decl types, bounds, module metadata
Param-default expressions call.rs, method_call.rs ×2 ast::Expr clones (irreducibly AST)

No site outside reify ever reads a method body except trait default-method synthesis. Signatures are the whole coupling.

This on-demand re-resolution is also the root of three secondary structures:

It is quadratic in places: resolve_module's preamble rescans every loaded module for associated constants per module (O(modules²), with ast::Expr clones), and the driver recomputes function_return_types / imported_functions per module from ASTs.

Hand-rolled scope save/restore

The TypeParamScope RAII guard exists (~35 sites), but three clusters still save/restore by hand: the Item::Impl arm of resolve_module (manual trait_ctx clone with three restore exits), the self_type triple in method_lookup.rs (manual save around with_module_perspective_for closures), and the current_effect_params / current_effect_param_decls mem::take pairs. Each is a panic-unsafe restore path the guard was built to eliminate.

Side channels and mode flags as struct fields

pending_method_dispatch and pending_operator_ast_id carry what should be return values / parameters. capture_tuple_overlays is constructor-constant true (dead flag). suppress_reference_recording is the query-suppression gate above. Each is per-call-frame data living at struct scope.

Decision

Elaborate's end state is four components. The boundary between them is enforced by types, not review:

TypeSystem (+ Signatures)  — pipeline-wide queries; no AST, no sem writes, no logging
ModuleSemantics            — per-module facts (unchanged)
Annotator (today: Elaborator) — the per-module walker: AST in, facts out
Reify                      — facts in, TIR out (unchanged)

The one new load-bearing piece is Signatures.

Signatures — every declaration signature becomes a decl-pass fact

Rule: after annotate_decls, no phase re-resolves a declaration signature from AST. Each signature is resolved exactly once, by the decl pass, in its own declaration frame, and stored as TypeId-level facts:

The canonical frame: a signature is resolved under its declaring module's import scope, with Self bound to the impl target, impl and method type params as TypeParam slots, and associated types as the impl's own bindings. Use sites substitute into that frame — the same substitution MethodInfo consumers already perform — and never re-resolve. A signature whose meaning would depend on the use site cannot exist under this rule; if migration finds one, that is a design bug to surface fail-loud, not a licence to re-resolve.

AST inside the digest is allowed only where the value is irreducibly AST and the consumer is the walker or reify, never a query: param-default exprs (resolved per call site under the callee's scope, per WEP 2026-04-11), associated-const value exprs (already digested this way on ModuleDecls), trait default-method bodies.

What this deletes, structurally:

Placement: Signatures is a field on TypeSystem (Rc, seeded from the stdlib snapshot like the all_* tables). Impl-method and trait-method signatures extend the existing ImplHeader / TraitDeclHeader on TraitEnv. Membership rule: "the TypeId-level signature of a source declaration" — anything else is rejected.

Scope — transient walk state with RAII-only mutation

One Scope struct (elaborator/scope.rs) absorbs annotate_ctx and default_scope_module. Effect parameters move into TraitContext itself: they are declared in a signature's type_params list, so they are generic-scope state and the TypeParamScope guard restores them with the rest of the context. All mutation goes through guards — TypeParamScope, with_self_type / with_self_type_if_known, with_default_scope_module (one shared field-restore guard behind the with_* helpers) — and every manual save/restore is deleted. Enforceable by inspection: no mem::replace / manual clone-restore of scope fields outside scope.rs.

TypeSystem — completed query surface, and the no-logging rule

The remaining queries move: the lookup_method_info cluster, trait-method-for-type, arithmetic / indexing / static-method lookups, and the callee-signature lookups in call.rs. Signature shape: fn query(&self, ctx: &Scope, scope: &TypeLookup, …) -> ….

Three rules define the boundary: TypeSystem never sees AST, never mutates ModuleSemantics, never logs. Queries return data — including reason chains (WEP 2026-06-02) — and the walker turns them into diagnostics. This is already the pattern for the migrated trait_query half; it becomes the rule for all of it.

Annotator — the walker Elaborator honestly is

End-state shape (6 fields, from 19):

pub struct Annotator<'a, H: CompilerHost> {
    env: ElabEnv<'a, H>,   // symbols, logger, interner, invocations, entry module
    tysys: TypeSystem,     // shared handle (+ Signatures)
    sem: ModuleSemantics,  // owned; driver swaps per module
    module: ModuleCx<'a>,  // current module source + items, set at entry
    scope: Scope,          // guard-managed transient state
    infer_holes: InferHoleTable,
}

Driver

annotate_decls   — types → TraitEnv + Signatures → per-module decl facts
annotate_bodies  — ×N, the walker
liveness         — unchanged
reify            — ×N, unchanged

AnnotateState dissolves (its own doc predicts this): tysys and module_semantics land on Semantics, the rest are driver locals. The per-module construction site collapses from 19 fields (two of them placeholders) to Annotator::new(&env, tysys.clone(), sem).

Rejected alternative

Passing a narrow "resolution context" (type_table + reference sink + logger

Implementation

Slices land independently, each keeping mise run test, the WIR goldens, and the LSP query tests green. Converted consumers read the digest via .expect(…) — a missing entry is a loud panic, never a fallback to AST re-resolution (the reify Stage-7 precedent).

Ordering: S1–S3 are mutually independent and independent of S4–S6. S4–S6 build one digest each and can land per consumer category. S7 requires S4–S6. S8–S9 are last. Progress metric: loaded_modules reads outside reify (~40 → 0), manual scope save/restores (3 clusters → 0), Elaborator fields (19 → 6).

Consequences

Benefits

Trade-offs

Risks and mitigations

See Also