05 — Name Resolution (the hir crate)

The hir crate turns the lossless CST / typed AST view (from 04) into a name-resolved High-level IR: every identifier — value, constructor, type, qualifier — is resolved to a DefId (or an explicit Res::Error), imports are processed, qualifier-collision rules are applied, and cross-module visibility is computed. It is the Rust successor to Sky.Canonicalise.* (src/Sky/Canonicalise/{Module,Environment,Expression,Pattern,Type}.hs).

Implementation status. Name resolution is built and behaviour-complete (M2: 0 resolver gaps; qualifier rules, E1001, DefId allocation, cross-module module_exports on demand). But the "salsa queries" framing here is the target: the running code lives in rust/crates/hir and threads through hir::db::SourceDb (a value-threaded struct with a RefCell exports cache), not memoised salsa #[salsa::tracked] queries. The db is deliberately structured so a salsa port is mechanical, and it already delivers the demand-driven module_exports(dep) lookup that replaces the 5-round fixpoint. See 01 status.

The whole subsystem is expressed (in the target) as salsa queries, which is the direct answer to three laws:

Compat contract. This crate reproduces the Haskell canonicaliser's observable accept/reject behaviour exactly, including its quirks, and only then improves. Every quirk that user code relies on is catalogued in §13. Where the query model produces a strictly better result than the 5-round approximation (true convergence on deep alias chains), that is called out as a documented change, per 00 "Compat first, cleverness second".


1. The query surface

flowchart TD
    AST["ast(FileId)"] --> ITEMS["module_items(ModuleId)\n(decls, imports, exposing)"]
    ITEMS --> EXPORTS["module_exports(ModuleId)\n(the DefInfo a dependency publishes)"]
    ITEMS --> GRAPH["module_graph(project)\n(import edges, topo order, cycles)"]
    ITEMS --> RES["resolve(ModuleId) -> ResolveResult"]
    EXPORTS -. "demanded for each import" .-> RES
    RES --> INFER["infer(DefId)   (crate: ty)"]
    RES -. LSP .-> IDE["hover / goto-def / completion / rename"]
QuerySignature (sketch)Replaces
module_itemsfn(&db, ModuleId) -> ModuleItemsSrc.Module field access
module_exportsfn(&db, ModuleId) -> ModuleExportsDepInfo (Module.hs:32) — built on demand, not pre-passed
module_graphfn(&db, ProjectId) -> ModuleGraphModuleGraph.compilationOrder (ModuleGraph.hs:249)
resolvefn(&db, ModuleId) -> ResolveResultcanonicaliseWithDeps (Module.hs:247)

resolve is the headline query. It returns partial results + diagnostics (L7):

pub struct ResolveResult {
    /// One resolved HIR body per top-level definition, keyed by its DefId.
    pub bodies:      IndexMap<DefId, hir::Body>,
    /// Everything this module *declares* (before export filtering).
    pub decls:       ModuleDecls,
    /// The scope map for LSP goto-def / hover (span -> Res).
    pub occurrences: Vec<(Span, Res)>,
    pub diagnostics: Vec<Diagnostic>,
}

Nothing throws; an unresolvable name lands in bodies as Res::Error and in diagnostics, and the walk continues (L7, L8) — the current code already does this for unqualified names by degrading to VarLocal (Expression.hs:149), but silently; we make it explicit and diagnosed.


2. Interned identity — Name, DefId, Res (L3)

Today the canonicaliser keys everything on String and encodes "where a name lives" with three parallel product types — VarHome (Environment.hs:37), CtorHome (Environment.hs:54), TypeHome (Environment.hs:45). We collapse these to one interned DefId space plus a resolution enum.

/// Interned symbol (smol_str). Replaces `String` keys everywhere.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct Name(salsa::Symbol);

/// A definition's stable identity. Arena-allocated, compared by ==.
/// Replaces name-string map keys AND the (home-module, name) pairs the
/// Haskell code carried inside VarHome/CtorHome/TypeHome.
#[derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DefId(u32);

pub struct DefLoc {
    pub module: ModuleId,
    pub name:   Name,
    pub kind:   DefKind,   // Value | Ctor | TypeCon | TypeAlias
    pub span:   Span,      // definition site, for goto-def
}

DefId is allocated in a per-project arena keyed by (ModuleId, Name, DefKind) — the interner is append-only inside the db (the "register-on-first-mention" pattern from 01). Walking DefIds in allocation order is deterministic (L4).

Res — the resolution outcome, the enum a resolved reference points at. It is the union of the three Haskell *Home types, made exhaustive (L6):

pub enum Res {
    /// Lambda/let/case-pattern binding. Carries the binding's HIR local id,
    /// not just "it's local" (today `VarLocal` is payload-free — Environment.hs:38).
    Local(LocalId),
    /// A top-level value defined in `module` (this module or an imported one).
    /// = VarTopLevel !ModuleName.Canonical (Environment.hs:39).
    Def(DefId),
    /// A stdlib kernel function: (kernel-module-name, function-name).
    /// = VarKernel !String !String (Environment.hs:40). Not a DefId because
    /// kernel fns have no Sky-source definition site to point goto-def at;
    /// they resolve to a runtime symbol (see §9).
    Kernel { module: Name, func: Name },
    /// A data constructor, with everything CtorHome carried (Environment.hs:54).
    Ctor(CtorRef),
    /// Resolution failed. Resolution continues; a diagnostic was emitted.
    /// This is the node the Haskell code lacked — it degraded to Local instead.
    Error,
}

pub struct CtorRef {
    pub def:     DefId,     // the constructor's own DefId
    pub type_:   DefId,     // the union type it belongs to  (_ch_type)
    pub index:   u16,       // constructor index in the union (_ch_index)
    pub arity:   u16,       // number of arguments            (_ch_arity)
    // The full union + annotation the Haskell CtorHome inlined (_ch_union,
    // _ch_annot) are recovered on demand via `union_of(db, type_)` — no need
    // to clone a whole Can.Union into every reference.
}

Type resolution mirrors this with TypeRes { con: DefId, arity: u16 } + AliasRef (the successor to TypeHome / AliasInfo, Environment.hs:45/67).


3. The HIR

The resolved IR is the Rust successor to Sky.AST.Canonical (Can.Expr_, Can.Pattern_, Can.Type). One-to-one with the Haskell constructors so the type checker and lowerer port straight across, but every name slot is a Res / DefId rather than a re-decodable string pair.

pub enum Expr {
    // literals
    Int(i64), Float(f64), Str(Box<str>), Chr(Box<str>), Unit,
    List(Vec<ExprId>),
    Tuple(ExprId, ExprId, Vec<ExprId>),
    Record(IndexMap<Name, ExprId>),          // field order by _fieldIndex (L4)

    // references — the payload is the resolution, computed by `resolve`
    Var(Res),                                // was VarLocal/VarTopLevel/VarKernel
    Ctor(CtorRef),                           // was VarCtor
    Binop { op: Name, res: Res, lhs: ExprId, rhs: ExprId },

    // structure
    Negate(ExprId),
    Lambda { params: Vec<PatId>, body: ExprId },
    Call(ExprId, Vec<ExprId>),
    If(Vec<(ExprId, ExprId)>, ExprId),
    Let  { def: DefId, body: ExprId },       // Can.Let
    LetRec { defs: Vec<DefId>, body: ExprId },
    LetDestruct { pat: PatId, val: ExprId, body: ExprId },
    Case(ExprId, Vec<CaseBranch>),
    Accessor(Name),
    Access(ExprId, Spanned<Name>),
    Update { base: ExprId, fields: IndexMap<Name, ExprId> },
}

pub enum Pattern {
    Anything, Var(LocalId), Unit,
    Bool(bool), Chr(Box<str>), Str(Box<str>), Int(i64),
    Record(Vec<Name>),
    Alias(PatId, LocalId),
    Tuple(PatId, PatId, Vec<PatId>),
    List(Vec<PatId>),
    Cons(PatId, PatId),
    Ctor(CtorRef, Vec<PatId>),               // was PCtor { _p_home, _p_type, ... }
}

pub enum Type {
    Var(Name),
    Con { con: TypeRes, args: Vec<TypeId> }, // was TType !home !name [Type]
    Lambda(TypeId, TypeId),
    Tuple(TypeId, TypeId, Vec<TypeId>),
    Record(IndexMap<Name, FieldType>, Option<Name>),  // row-ext var preserved
    Alias { alias: AliasRef, args: Vec<(Name, TypeId)>, body: AliasBody },
    Unit,
}

AliasBody = Filled(TypeId) | Hoisted(TypeId) mirrors Can.AliasType. Note hir::Type::Alias carries both the applied args and the substituted body — the eager-expand-but-keep-identity trick from expandTypeAliases (Module.hs:2251) that lets HM both unfold a record alias for row unification and preserve nominal alias identity. That pass ports verbatim (§11).

Float patterns are rejected (Pattern.hs:85 error "Float patterns not supported") — in Rust this is a diagnostic + Pattern::Anything recovery, not a panic.


4. The resolver & scopes

resolve builds a module environment (the successor to Env, Environment.hs:14) once, then walks each top-level body with a scope stack layered on top. The module env is immutable during the walk; scopes are pushed and popped.

struct Resolver<'db> {
    db:      &'db dyn HirDb,
    module:  ModuleId,
    // ---- module-level namespaces (built once from imports + local decls) ----
    vars:    FxHashMap<Name, Res>,                 // Env._vars   (unqualified values, incl. exposed imports)
    types:   FxHashMap<Name, TypeRes>,             // Env._types
    ctors:   FxHashMap<Name, CtorRef>,             // Env._ctors
    aliases: FxHashMap<Name, AliasRef>,            // Env._aliases
    qual_vars:  FxHashMap<Name, FxHashMap<Name, Res>>,     // Env._qualVars
    qual_ctors: FxHashMap<Name, FxHashMap<Name, CtorRef>>, // Env._qualCtors
    import_aliases: FxHashMap<Name, ModuleId>,     // Env._importAliases
    kernel_mods:    FxHashMap<Name, Name>,         // Env._kernelMods
    // ---- lexical scope, pushed/popped during the walk ----
    scopes:  Vec<FxHashMap<Name, LocalId>>,
    diags:   Vec<Diagnostic>,
}

FxHashMap is fine here — these maps never iterate into emitted output, only answer point lookups (L4 permits HashMap for internal caches). Anything whose iteration reaches emission uses IndexMap / BTreeMap.

Scope kinds

The Haskell canonicaliser has no scope stack; it threads a fresh Env by value and extends _vars with VarLocal entries via addLocals (Environment.hs:150). We keep the same semantics with an explicit push/pop, which shadows correctly because inner-scope lookups are checked first:

ScopeIntroduced byHaskell site
Moduleimports + top-level decls (built once)env0..env4, Module.hs:334-355
Value paramsf x y = … paramsbodyEnv = addLocals paramNames, Module.hs:1973
Lambda\x -> …Expression.hs:71-76
Nested deflet f x = … paramsExpression.hs:490-497
Case armpattern bindersExpression.hs:520-526
let groupall binders of the groupExpression.hs:352-374

Lookup precedence (encapsulated in lookup_var): innermost scope → … → outermost scope → module vars/ctors. This is exactly what addLocals + Map.insert (overwrite-wins) achieve today: a local shadows a top-level or a built-in of the same name.

fn lookup_var(&self, name: Name) -> Res {
    for scope in self.scopes.iter().rev() {
        if let Some(&loc) = scope.get(&name) { return Res::Local(loc); }
    }
    // ctors are checked before plain vars — matches resolveVar (Expression.hs:130)
    if let Some(c) = self.ctors.get(&name) { return Res::Ctor(c.clone()); }
    if let Some(r) = self.vars.get(&name)  { return *r; }
    Res::Error   // ← diagnosed by the caller; NOT silently Local (fix vs Expression.hs:149)
}

let is forward-referencing (mutual recursion)

The single most important scope rule to preserve: every binder in a let group is in scope for every RHS in the group and for the body. Expression.hs:360-370 collects allNames = concatMap nameFromDef defs, builds letEnv = addLocals allNames env, and uses letEnv for both the RHS bodies and the in body. The compat note in CLAUDE.md ("let a = b + 1; b = 5 in a now compiles") depends on this.

fn resolve_let(&mut self, defs: &[ast::Def], body: &ast::Expr) -> ExprId {
    self.push_scope();
    for d in defs { self.bind_all(d.bound_names()); }   // ALL binders first
    let hir_defs = defs.iter().map(|d| self.resolve_def(d)).collect(); // RHS see the whole group
    let hir_body = self.resolve_expr(body);
    self.pop_scope();
    // LetRec vs Let is a codegen concern; a dependency SCC pass (below) picks it.
    self.mk_letrec(hir_defs, hir_body)
}

Note the ordering split: the current dependencySortDefs pass (Expression.hs:396-428) re-orders let bindings for Go codegen friendliness and leaves cycles in source order — that is a lowering concern, not resolution. It moves to the lower crate (07); resolution only records the mutual-visibility scope and the SCC grouping (Let vs LetRec).


5. Building the module environment — imports

resolve builds the module namespaces by folding over imports (successor to processImport, Module.hs:565), then registering local decls (registerTopLevelNames/registerUnions/registerAliases, Module.hs:1869/1877/1918). Order (from Module.hs:334-355): initial builtins → imports → top-level names → unions+ctors → aliases. Local names are registered after imports, so a local top-level shadows an exposed import of the same name (last-Map.insert-wins, addExposed Environment.hs:166).

Each import contributes from exactly one of three sources, with a fixed precedence:

enum ImportSource {
    Dep(ModuleId),   // a user/stdlib Sky-source module (has module_exports)
    Kernel(Name),    // a Go-implemented kernel module (Std.Db, Sky.Core.List…)
    Unknown,         // neither: trust the import, resolve leniently
}

FFI-over-kernel precedence (Module.hs:594-623): when an import path matches both a Sky kernel module and a Go FFI dep with real bindings, the FFI/dep binding wins (useDep = hasDepBindings, useKernel = isKernel && not useDep). The motivating case is import Os: Sky's kernel once claimed Os, and Go's os package binds under alias Os; the FFI wins. This must port exactly.

For a Dep, we do not clone the dependency's unions into the importer. Instead we ask module_exports(dep) (the salsa successor to DepInfo) and pull CtorRefs / Res::Defs from it — this is the seam that makes cross-module visibility a demand-driven query (§8).

Constructor contribution mirrors Module.hs:627-636: for each exposed union, each constructor becomes a CtorRef { def, type_, index, arity } bound under its bare name. Record aliases contribute an auto-constructor value binding (Module.hs:642-648 depVars; locally registerAliases Module.hs:1939) so Piece kind colour and Decode.succeed UserProfile resolve — Elm's type alias Foo = { … }Foo : … -> Foo convention.


6. The qualifier-collision rule (explicit-alias-wins)

Every non-aliased import M exposing (…) also registers M's last segment as an auto-qualifier — CLAUDE.md (v0.17.5+):

import Sky.Core.Prelude exposing (..) binds Prelude.<name> too.

Two imports MAY both try to bind the same qualifier. The resolution is effectiveQualifier (Module.hs:976-991), shared between the binding pass (processImport) and the collision gate (detectImportAliasCollisions) so they never disagree:

/// Port of effectiveQualifier (Module.hs:976).
/// `claims` = every explicit `import M as X` as X -> canonical(M).
fn effective_qualifier(claims: &FxHashMap<Name, ModuleId>, imp: &Import)
    -> Option<Name>                      // None = auto-qualifier suppressed
{
    match imp.alias {
        Some(alias) => Some(alias),      // explicit alias always binds
        None => {
            let last = imp.segments.last();
            match claims.get(&last) {
                // a DIFFERENT module already explicitly claimed this qualifier:
                Some(&claimed) if claimed != imp.module => None,   // suppress; explicit wins
                _ => Some(last),
            }
        }
    }
}

The three cases CLAUDE.md pins, reproduced exactly:

  1. Explicit alias wins. import Std.Db as Db + bare import Lib.Db exposing (conn): Db.<x>Std.Db; conn (unqualified) → Lib.Db. The bare import's Db auto-qualifier is suppressed silently (effective_qualifier returns None; processImport skips the qualifier binding but still adds the exposing names to scope — Module.hs:650-661). No diagnostic.
  2. Two bare, different modules → E1001. import State + import App.State both auto-register State for different canonical modules; no explicit alias breaks the tie. Emit: Import error: two imports both bind the qualifier "State" + a fix-it suggesting import App.State as AppState (Module.hs:1067, formatClash).
  3. Two explicit as X for different modules → E1001. User error; no disambiguation possible.

Non-collisions that must not fire (Module.hs:830-855, detectImportAliasCollisions):

fn detect_qualifier_collisions(&self, imports: &[Import]) -> Vec<Diagnostic> {
    let claims = explicit_alias_claims(imports);             // Module.hs:950
    let mut by_qual: IndexMap<Name, Vec<(CanonSrc, Span, ModulePath)>> = default();
    for imp in imports {
        if let Some(q) = effective_qualifier(&claims, imp) { // suppressed => contributes nothing
            let src = self.kernel_mods.get(&imp.path).copied()  // kernel collapse
                          .unwrap_or(CanonSrc::Path(imp.path));
            by_qual.entry(q).or_default().push((src, imp.span, imp.path));
        }
    }
    by_qual.iter()
        .filter(|(_, g)| distinct_sources(g).len() >= 2)     // >=2 distinct canonical sources
        .map(format_clash)                                    // "two imports both bind the qualifier"
        .collect()
}

Diagnostic-code note (compat call-out). CLAUDE.md labels this "[E1001]", but in the current code E1001 = canonE_UndefinedName (Diagnostic.hs:169) and the qualifier collision actually travels the legacy String path (Module.hs:392-393 returns Left err, never a typed DiagCode). The rewrite should give the collision its own structured code (natural fit: E1002 = AmbiguousName, or a new E1005 = QualifierCollision) while keeping the message text byte-for-byteImport error: two imports both bind the qualifier "X". User-facing text is the compat surface; the code string is an improvement.


6b. Unqualified-name precedence & the ambiguity rule

§6 above resolves a collision between two qualifiers. This section resolves a collision between two bindings of one bare name — a different question, and until 2026-08-11 an unanswered one.

The defect

Every binding of an unqualified name went into one flat vars / ctors map with a plain .insert(), so a name bound by two imports silently resolved to whichever import came last in the file:

import Ambig.Alpha exposing (..)   -- label = "ALPHA"
import Ambig.Beta exposing (..)    -- label = "BETA"
main = println label               -- prints BETA

import Ambig.Beta exposing (..)    -- the SAME program, two lines swapped
import Ambig.Alpha exposing (..)
main = println label               -- prints ALPHA

Both compiled clean, with no diagnostic either way. Reordering imports is something a formatter, a merge, or an added import does routinely, so "last wins" turned a difference that must not matter into a different program — the #164 family. Elm rejects it. The pinned reproduction is corpus/repro/ambiguous-exposing-all/.

Why the naive rule is wrong

"A name bound by two imports is an error" rejects working programs. import Sky.Core.Prelude exposing (..) together with import Sky.Core.Math exposing (..) already double-binds abs / min / max / sqrt, and real examples do exactly that. The rule has to privilege the implicit prelude the way Elm does.

The precedence lattice

hir::resolve::BindLayer — higher wins outright and silently; only a tie inside the winning layer is ambiguous:

LayerWhat enters hereWhy it ranks there
Local (3)this module's own top-level defsa local shadowing an import is long-standing legal Sky (C7) and stays legal
Explicit (2)import M exposing (name)the author named THIS binding; a specific claim outranks a bulk one
Open (1)import M exposing (..)a bulk claim on whatever M happens to export today
Ambient (0)BUILTIN_VARS / BUILTIN_CTORS, and any import in AMBIENT_IMPORTS (Sky.Core.Prelude)autoloaded — sky init's templates emit the Prelude import unconditionally and AGENTS.md documents it as autoloaded, so its presence is not an authorial claim on the name

Two consequences worth stating explicitly:

Reported at the USE SITE, never at the import

Importing two modules that both expose a name you never reference is legal and extremely common: every Sky.Live page imports Std.Html exposing (..) alongside Std.Html.Attributes exposing (..), and those overlap. Reporting at the import would reject programs whose meaning is not order-dependent at all. Elm draws the line in the same place. So resolve_var (and the ctor pattern head) raises [E1012] only when a reference actually reads an ambiguous name.

[E1012] AMBIGUOUS NAME

The message names both modules and shows the qualified forms that fix it:

-- AMBIGUOUS NAME ------------------------------- src/Main.sky:21:13 [E1012]

21 |     println label
   |             ^^^^^

Ambiguous name `label` — it is brought into scope by `Ambig.Alpha` and
`Ambig.Beta`, and this reference does not say which one it means. Sky rejects
this instead of picking one, because the winner would otherwise depend on the
ORDER of your import lines: swapping two imports would silently change what this
program computes. Qualify the reference — write `Alpha.label` or `Beta.label` —
or narrow one import's `exposing (…)` list so only one of them binds `label`.

When an import binds no qualifier (auto-qualifier suppressed by explicit-alias-wins, §6), the message tells the user to add an alias rather than offering a qualified form that would not compile.

[E1012] is gated in project::build before the type-error gate. Ambiguity is the cause and a type clash under it is the consequence: the resolver still has to hand lowering one of the two bindings so resolution stays total, and whichever it picks the use site may then fail to unify. Measured, not reasoned — import Sky.Core.String exposing (..) + import Sky.Core.List exposing (..) with a bare length reported [E2001] type mismatch: List _ vs String and never mentioned the ambiguity.

Scope — values, constructors, and types

All three namespaces. The type namespace was excluded when the lattice first landed, and the reason was specific: several type-binding paths synthesised a DefId when a module did not really export the name, so one conceptual type could carry two identities and a DefId comparison would manufacture false rejections — the #164 failure mode (13-change-verification-and-edge-cases.md D1). The leniency had to be narrowed before the rule could be extended, and that is the order the work happened in.

The lenient sites, and what each one is now

SiteWasIs
bind_exposing_kernel, exposing (T) on a kernel pseudo-moduleself.def(self.module, …) — a FRESH DefId per importing module, so Decoder meant a different identity in every module that imported itone BUILTIN_MOD DefId, program-wide (kernel_implicit_type_def)
bind_exposing_dep, exposing (T) where the module re-exports Tself.def(exports.module, …) — the RE-EXPORTER's identity, so the same type reached directly and through the re-export differedchased through the re-exporter's own import list to the declaring module (chase_reexported_type)
bind_exposing_dep, exposing (T) with nothing authoritative leftfabricated silentlyfabricated, and marked TypeKey::Opaque
bind_exposing_foreign, exposing (T) on a Go FFI packageself.def(self.module, …), indistinguishable from a Sky typeTypeKey::Opaque — a Go type's identity is a (package, name) pair, not a DefId, and cannot be compared against one

chase_reexported_type reads the re-exporter's PARSE, not its resolution, so it adds no resolve → resolve query edge and two mutually-importing modules cannot cycle. It is bounded by a visited set.

TypeKey::Opaque — abstain rather than guess

A binding whose identity had to be fabricated says so, and a winning layer that contains one is never called ambiguous: it resolves to the last binding, exactly as it did before this rule existed. That is a deliberate false NEGATIVE, taken in preference to a false REJECTION. A rule that rejects a working program gets reverted (as #164 was) and closes nothing; a rule that stays quiet where it cannot see leaves today's behaviour untouched.

What this does NOT close — and what closed it

[E1012] fires where a program WRITES an ambiguous type name. It did not, and could not, address the larger fact that the type checker had no cross-module identity for unions at all: ty::sig::rewrite_alias_refs module-qualified DefKind::TypeAlias references only (the #164 fix), and every union collapsed to its bare final segment, which unify.rs then compared as a plain string. Two same-named unions in two modules were therefore ONE type to the checker while lower emitted two distinct Go types for them, bridged by an rt.Coerce that SUCCEEDS (both Go interfaces carry the same method set) and delivers a value no case arm matches — sky check clean, go build clean, sky.Unreachable(case) at runtime. corpus/repro/cross-module-union-conflation/ pinned it: it needs no ambiguous import at all, so no use-site ambiguity rule can reach it.

Closed 2026-08-12 by giving unions the identity aliases already had. See §"Nominal type identity" below.

Nominal type identity (ty::nominal)

ty::nominal is the ONE answer to "are these the same type?".

A nominal name is either qualified ("Conflate.Alpha.Shape"the Shape declared in Conflate.Alpha) or bare ("Shape"some Shape, declaring module unknown). Bare names never contain a .; module names are dotted, so the type name is the LAST segment. No new type was introduced, so nothing downstream changed representation.

Where identity is established:

sitebeforeafter
sig::World::union_keysnew: "<module>.<name>" for every union, built in pass 1a beside alias_keys
sig::record_unionTy::App(bare)Ty::App(union_keys' key) — the DECLARATION half
sig::rewrite_alias_refsqualified TypeAlias onlyqualifies TypeAlias and TypeCon, each against its own key set — the REFERENCE half
unifyn1 == n2nominal::same(n1, n2)

Both halves read the same union_keys, so a declaration and every reference to it agree by construction.

The rule that bounds the blast radius. nominal::same treats a BARE name as a wildcard over its base segment. Two types differ ONLY when both are qualified, their bases agree, and their qualifiers differ — i.e. only when the compiler resolved BOTH sides, with certainty, to two different modules. Everywhere resolution is incomplete (an unimported type, a Go FFI type, an unchaseable re-export, a kernel-implicit type) the name stays bare and behaves exactly as before. This is deliberately the same discipline as TypeKey::Opaque in the [E1012] lattice: prefer a false NEGATIVE over a false REJECTION, because #164 is what the other trade costs.

What stays bare, and why that is safe. Builtins and kernel-implicit types intern into the BUILTIN_MOD sentinel (ModuleId(u32::MAX)) rather than a real module, and rewrite_alias_refs checks the sentinel before qualifying. So Int/List/Dict/Task/Maybe/Result/Cmd/Sub/Decoder/Value are never qualified — which is what keeps ty::dictkey's DICT = "Dict", unify's "List" super-type rules and lower's "Task"/"Cmd" dispatch matching. The bare-keyed World::{ctor_union, union_ctors, aliases} tables also stay bare; ctor_union/union_ctors are read at exactly one site (exhaustive.rs) and only as a fallback behind the already-module-correct union_members_by_def.

Rendering. Ty's printer strips the qualifier, so every signature, snapshot and oracle message is byte-identical to before. unify re-qualifies only when a mismatch's two sides share a base — the one case where the stripped form would print the useless type mismatch: `Shape` vs `Shape` .

Go emission needed no change. lower::goty::app_to_go already resolved a qualified name through nominal_by_module and fell back to the bare final segment on a miss. Five lower sites that compared a FULL Ty::App name against a bare literal ("Cmd", "Dict", the Dict-key primitives, "Task" ×2, and ty_refs_ambiguous) were switched to compare the base segment, the pattern already used for "Result"|"Maybe"|"Task" nearby — otherwise a qualified name would have made them silently inert.

Tests

hir/src/lib.rs's test module carries the rejection cases and an accepted twin for each (Family R convention). For values: unambiguous twin, never-referenced twin, qualified-reference twin, local-shadow twin, Explicit-beats-Open twin in both import orders, the Prelude/Math ambient twin, and the same-definition-twice twin. For types, the same set plus the three twins that ARE the leniency argument — twin_kernel_implicit_type_from_two_modules_is_accepted, twin_type_reexported_and_imported_directly_is_accepted, and twin_builtin_type_is_ambient_and_never_ambiguous. rust/crates/ty/tests/reject/corpus/ambiguous_unqualified_name.sky and ambiguous_type_name.sky pin the single-file stdlib forms with -- rust: reject [E1012].


7. Exposing, re-export, and import-hiding

module_exports(ModuleId) is the successor to DepInfo (Module.hs:32) and to filterDepByExports (Module.hs:51). It returns exactly what a dependency publishes, already narrowed by that dependency's own exposing clause:

pub struct ModuleExports {
    pub module:    ModuleId,
    pub unions:    Vec<ExportedUnion>,      // (type DefId, type vars, ctor DefIds+arities)
    pub aliases:   Vec<DefId>,              // exported alias names
    pub alias_defs: IndexMap<Name, hir::Type>, // alias bodies, for cross-module expansion
    pub values:    Vec<DefId>,              // exported top-level value names
    pub exposing:  Exposing,                // ExportEverything | ExportExplicit(set)
}

Extraction is the projection at Module.hs:6463-6476 / Module.hs:2431 (collectDepAliases): unions ← _unions, alias_defs ← _aliases (bodies are load-bearing — the cross-module alias fix), values ← decl names, exposing ← _exports. The type vars per union are load-bearing (Module.hs:34-39): a cross-module Box x must scheme as forall a. a -> Box a, else HM rejects Box vs Box a. module_exports therefore keeps Can._u_vars.

Exposing::ExportEverything is the no-op fast path (filterDepByExports Module.hs:52); ExportExplicit keeps only listed names.

Import-hiding validation (E1004)

import M exposing (a, B(..), C(Ctor1)) must name things M actually exports — checkImportExposingAgainstDep (Module.hs:484-554). Reproduce exactly:

Kernel-implicit Prelude types (#576) — must preserve. Decoder, Value, Attribute, Handler, Middleware, Session, Store, Route, VNode, Request, Response, Cmd, Sub, Db, Error are globally-available runtime types with no type alias in any .sky source. Listing one in exposing (…) is redundant but accepted as a no-op (isKernelImplicitType, Module.hs:520-524) — import Std.Db.Decode exposing (Decoder, …) must not error. Port the list verbatim.

Exposing collisions (used-unqualified ambiguity — E1002)

Two imports may both expose the same unqualified name. This is tolerated as long as the name is never used unqualified without a local shadow — exactly Elm's rule. detectExposingCollisions (Module.hs:1120) builds name → distinct sources; checkAmbiguousUses (Module.hs:1173) walks bodies and only errors if an ambiguous name is referenced unqualified and not locally defined. Sources that normalise to the same kernel module are one origin (re-exports never collide). Fix-it: add "as <Alias>" to one import and call it qualified.


8. Cross-module visibility without the fixpoint

This is the section that pays for the whole rewrite.

What the current 5-round loop compensates for

typecheckWorkspace (Compile.hs:6257-6522) runs a fixed 5-round canonicalise+solve over the entire workspace (Compile.hs:6453-6500):

        maxRounds = 5 :: Int
        rounds depMap externals iter
            | iter >= maxRounds = runRound depMap externals
            | otherwise = do
                results <- runRound depMap externals
                let canons  = [ (n, cm) | (n, Right (cm,_,_), _,_,_) <- results ]
                    solved  = [ (n, ts) | (n, Right (_,ts,_), _,_,_) <- results ]
                    nextDepMap    = buildDepMap canons
                    nextExternals = buildCrossModuleExternalsWithMods canons solved
                rounds nextDepMap nextExternals (iter + 1)

Two artifacts thread round-to-round, feeding two ordering-sensitive resolutions:

Both are demand/ordering failures: a module needs a resolved-and-solved view of its dependencies, which only exists after those deps are resolved with their deps. The loop brute-forces it — recomputing every module 5× whether or not its inputs changed, because it has no dependency tracking. 5 is empirical (covers the observed 2–3-hop chains; the CLI's convergence-checked loop caps at 16 and settles in 2–4). The DepInfo extraction is duplicated in four places (Compile.hs:6362, 6463, 3417, Index.hs:653) — the "keep in sync" comment at Index.hs:651 is itself the smell.

The query graph that replaces it

Make each threaded artifact a tracked query. Salsa then orders, memoises, and recomputes exactly the demanded slice.

flowchart TD
    parse["parse(mod)"] --> res["resolve(mod)"]
    res -->|"for each import dep"| exp["module_exports(dep)"]
    exp --> res_dep["resolve(dep)"]
    res --> infer["infer(mod)   (crate: ty)"]
    infer -->|"for each dep value used"| solvedty["solved_type(dep, name)"]
    solvedty --> infer_dep["infer(dep)"]

The pure helpers the loop calls port unchanged, because they operate on already-canonical data — only their inputs stop being recomputed redundantly: buildCrossModuleExternalsWithMods + buildGlobalTypeHomeMap/fixupHomes (Compile.hs:11195-11230, the Model vs Model home-fixup), and the alias machinery of §11.

Documented behaviour change (a correctness improvement). The 5-round count silently truncates alias/dep chains deeper than ~3 hops; the query graph converges fully. This can only make a previously-wrong deep chain right. Per 00, we note it: validate against the Haskell oracle on the corpus (chains at issue: Std.Db 2-hop, Std.Webview → Std.Html → Sky.Core.Html 3-hop, the Std.Live multi-alias chain). Any divergence is the query model being more correct, and is recorded, not hidden.

LSP/CLI unification (L2). The LSP needed a separate fixpoint at all only because the CLI's phases write to stdout (the JSON-RPC transport) and mutate a shared scopeStateRef IORef (Compile.hs:6400-6405). With resolution as a pure query over db, the LSP and CLI are two drivers over the same queries — the entire reason a bespoke LSP fixpoint existed evaporates.


9. Kernel & FFI qualifier resolution

Qualified references resolve through a fixed fallback chain — the successor to resolveQualVar (Expression.hs:153-189). Order:

  1. qualified constructor (qual_ctors[qual][name]),
  2. qualified value (qual_vars[qual][name]),
  3. kernel-module registry (kernel_mods[qual]Res::Kernel { module, name }),
  4. import alias (import_aliases[qual]Res::Def in that module),
  5. last-resort Res::Def in a literal Canonical qual module (today) — in the rewrite this final arm becomes a diagnosed Res::Error (see §10).

Step 3 is the crux for bare kernel qualifiers: Crypto.sha256 with no import Crypto resolves because Crypto ∈ kernel_mods (Expression.hs:184-185). Without it the lowerer would emit Crypto_sha256(...) un-prefixed and go build fails.

kernel_mods is the merged static ∪ FFI map (Module.hs:256 Map.union staticKernelModules ffiKernelMods, static wins on collision). The static table is staticKernelModules (Environment.hs:348-503) — both full paths (Sky.Core.List → List, Std.Db → Db, Sky.Core.Prelude → Basics) and bare aliases (List → List, Db → Db) so List.map works unqualified. The FFI half is pinned/committed FFI surface (see 09); it enters the query graph as a salsa input (ffi_surface), so kernel_mods is a derived query, never a global. Deliberate omissions to preserve: Args, Os, Slog, Env, Sha256, Hex are gone; Os → System renamed (bare Os reserved for Go FFI); Std.Html*, Std.Css, Std.Db.Decode, Std.PubSub are Sky-source Layer-3 modules and intentionally absent so the kernel registry never shadows a parsed module.

The static per-module kernel function lists live in staticKernelFunctions (Module.hs:1752-1861) and are merged with FFI kernel functions (Module.hs:822-827); these drive exposing (..) on a kernel import and the ambiguity check. Port both tables as data.


10. Prelude autoload & builtins

Two mechanisms, both seeded into the module env before any import (successor to initialEnv, Environment.hs:86-98):

  1. Unconditional builtins. initialEnv seeds vars, types, ctors, and qual_vars from builtinVars / builtinTypes / builtinCtors / preludeQualVars regardless of imports:
    • builtinVars (Environment.hs:212): identity, always, not, toString, modBy, clamp, fst, snd, errorToString, println, js — all Res::Kernel.
    • builtinTypes (Environment.hs:229): Int/Float/Bool/String/Char (arity 0), List/Maybe (1), Result/Task (2), Error (0, auto-imported from Sky.Core.Error so Result Error a needs no import).
    • builtinCtors (Environment.hs:249): True/False, Just/Nothing, Ok/Err with their full unions + annotations.
    • preludeQualVars (Environment.hs:105-140): auto-qualifiers String.*, List.*, Dict.*, Set.*, Maybe.*, Result.*, Basics.*, Cmd.*, Sub.*, Task.* without an explicit import.
  2. The explicit Prelude import. Templates emit import Sky.Core.Prelude exposing (..) (app/Main.hs:1612); Sky.Core.Prelude → Basics in kernel_mods (Environment.hs:453), pulling the full Basics surface.

CLAUDE.md pins the autoloaded set: Result (Ok/Err), Maybe (Just/Nothing), identity, not, always, fst, snd, clamp, modBy, errorToString. Reproduce the tables as data (they change rarely; a diff against the Haskell lists is a CI check).

Prelude-shadow gate (v0.15.42 §3.2). A user type Result a = Just a | Nothing silently rebinds Just/Nothing/Result everywhere downstream — a soundness regression. detectPreludeShadowing (Module.hs:872-943) hard-errors any user union whose type name or constructor name collides with a Prelude-exposed name (Int/…/Error, True/False/Just/Nothing/Ok/Err), carving out the protected name's own canonical home (Sky.Core.Maybe may define Maybe). Port with the same home carve-out and message shape.


11. Head-position alias unfolding & alias expansion

Two pure passes over already-canonical types, both porting unchanged.

Head-position unfold (unfoldHeadAlias, Module.hs:2036-2046, applied at Module.hs:1999). When a value's whole annotation is a type alias whose body is a function — the canonical f : Handler where type alias Handler = Request -> Task Error Response — the raw canonical form is a nominal Con that arrow_args can't peel, so params would be dropped and the body checked against the unpeeled alias. unfoldHeadAlias peels only the head (arg/return leaves stay nominal), with a (home, name) visited-set guarding mutually-recursive alias chains. arrow_args/arrow_result_n (Module.hs:2019/2052) likewise unwrap a TAlias head when splitting params from the return type. This is contributor PR #123 (v0.16.4) and CLAUDE.md's Handler alias note; it must behave identically so myHandler : Handler keeps its req parameter.

Module-level alias expansion (expandTypeAliases / expandModuleAliases, Module.hs:2215-2289). Rewrites nominal Con(alias, args) into Type::Alias { args, body: Filled(subst) } — eagerly unfolding for HM row unification while preserving alias identity. Keyed by (home, name) via AliasMap (Module.hs:2165) with a unique-body bare-name fallback (lookupAlias, Module.hs:2202) — the two-index design that closed #350 (two deps each exposing Model must not collapse to one body) and #361 (a qualified type transiting a re-exporting intermediate module). collectDepAliases (Module.hs:2431) supplies dep alias bodies; in the query model these come from module_exports(dep).alias_defs. Parametric aliases substitute vars → call-site args before wrapping. Preserve the (home,name) keying and the unique-body fallback exactly — the Model vs Model class depends on it.


12. Error recovery & diagnostics (L7, L8)

Resolution never aborts. Each failure emits a Diagnostic (structured value: span, code, severity, hint, optional fix-it) and yields a recovery node so the walk continues and the LSP still gets a full occurrence map on broken code.

FailureRecovery nodeDiagnosticHaskell site
Unbound unqualified nameRes::Error (today: silent VarLocal)E1001 "Undefined name: X" + typo hintModule.hs:1271, Expression.hs:149
Qualified, module known but name not exportedRes::Error"Module M is imported but does not export N"Module.hs:1309-1340
Unknown qualifierRes::Error"Module M is not imported / not a known kernel module" + did-you-meanModule.hs:1342-1373
Unknown ctor patternPattern::Var (bind as var)none (Elm-compatible)Pattern.hs:116
Unknown qualified ctor patternPattern::Anything (today: error/panic)hard error → diagnosticPattern.hs:139
Float patternPattern::Anything (today: panic)"Float patterns not supported"Pattern.hs:85

Did-you-mean. suggestQualifier + levenshtein (Module.hs:1551-1585): for an unknown qualifier, suggest the closest known qualifier (kernel modules ∪ import aliases ∪ qual_vars/qual_ctors keys) within edit-distance 2; emit nothing if none — silence beats a misleading hint. This edit-distance-2 rule + the candidate set port verbatim; the strsim crate replaces the hand-rolled DP.

The unknown-qualifier gate is load-bearing (v0.15.42 §3.1). Today NotARealModule.foo where the qualifier is neither kernel, alias, nor in the qual maps used to fall through resolveQualVar's final arm to VarTopLevel (Canonical "NotARealModule") "foo", emitting bogus Go that go build rejected with undefined: NotARealModule_foo. The rewrite makes that final arm (§9 step 5) emit Res::Error + this diagnostic directly — the Sky-layer fence stays.

Dedup. foo unbound 12× reports once (dedupeByNameTop, Module.hs:1599). The LSP one-diagnostic-per-identifier split (multiErrorSeparator, Module.hs:120) is a legacy String-packing hack that the structured Vec<Diagnostic> return type makes obsolete — one Diagnostic per site, natively.


13. Compat behaviours that must be preserved exactly

Every row is a quirk user code (or the corpus) relies on. Differential-tested against the Haskell oracle (11).

#BehaviourPinned by
C1Explicit-alias-wins: bare import's last-segment qualifier suppressed silently when an explicit as X claims it; exposing names still bindCLAUDE.md v0.17.5; Module.hs:589-661, 976-991
C2Two bare / two explicit-alias different-module qualifier clash → "two imports both bind the qualifier X" + as <Alias> fix-itCLAUDE.md; Module.hs:1067
C3Same-module double import & multi-path kernel import never collide (kernel collapse to pseudo-module)Module.hs:1024, 830-855
C4FFI-over-kernel precedence (import Os → Go os, not kernel)Module.hs:594-623
C5Bare kernel qualifier resolves with no import (Crypto.sha256)Expression.hs:184; Environment.hs:348
C6let group: all binders visible to all RHS + body (forward refs)Expression.hs:360-370
C7Local shadows exposed import shadows builtin (last-insert-wins)Environment.hs:166; Module.hs:349
C8Ctor checked before var on unqualified lookupExpression.hs:130
C9Record alias auto-constructor (type alias Foo = {…}Foo value binding)Module.hs:1939, 642-648
C10Cross-module ctor keeps its union type vars (Box x : Box a)Module.hs:34-39, 727-743
C11Exposing collision tolerated unless used unqualified & unshadowed (E1002)Module.hs:1120, 1173
C12Kernel-implicit Prelude types accepted as no-op in exposing (…) (#576)Module.hs:520-524
C13Import-hiding rejects non-exported names; kernel imports skip the checkModule.hs:484-554
C14Prelude-shadow gate hard-errors user Result/Just/… with home carve-outModule.hs:872-943
C15Head-position alias unfold keeps params (f : Handler)Module.hs:1999, 2036
C16Alias expansion (home,name)-keyed + unique-body fallback (#350/#361)Module.hs:2165-2213, 2251
C17Unknown-qualifier gate + edit-distance-2 did-you-mean; silent when no candidateModule.hs:1342-1373, 1551-1585
C18Unbound-name dedup (one report per name)Module.hs:1599
C19Auto-imported Error type from Sky.Core.ErrorEnvironment.hs:244
C20main special-cased as Go func main() (resolution keeps the name; emission handles it)CLAUDE.md "Go reserved-name rewriting"; codegen (08)

Two behaviours we deliberately change (documented, oracle-validated):


14. Determinism & module budget (L4, L5)


15. Open questions (resolve before coding)

  1. _qualTypes asymmetry. The Haskell Env has a _qualTypes field that is never populated and has no lookup function (Environment.hs:21); qualified types resolve through aliasMap in Type.hs:131 instead. Decide: unify qualified-type resolution into qual_* maps in the rewrite (cleaner) vs. mirror the two-path Haskell shape (safer for exact compat). Lean unify, gate on the oracle.
  2. Res::Error vs recovery aggressiveness. How far does an Error propagate before infer should stop reporting cascades? Coordinate the recovery contract with 06 so one unbound name doesn't produce a wall of downstream type errors.
  3. Salsa cycle policy for genuine import cycles. Pick salsa's cycle_fn/recovery so a real module cycle yields one clear diagnostic, not a panic — and confirm the corpus contains a mutually-recursive-module fixture (today's DFS silently tolerates it; we must reject or support it deliberately).
  4. FFI surface as input granularity. ffi_surface as one big input invalidates all kernel_mods consumers on any FFI change. Per-package inputs keep incrementality tighter — coordinate with 09.