Skip to content

Colliding glob imports: typechecker binds the LAST import, flatten_imports binds the FIRST (and the docstring documents the wrong one) #743

Description

@hyperpolymath

Summary

On colliding glob imports (use A::* and use B::* where both export the same public name), the typechecker binds the LAST import and the flattening path that feeds codegen binds the FIRST. The program type-checks against one definition and runs another.

lib/module_loader.ml documents the typechecker's rule on the function that implements the opposite one, which is why this has survived: the docstring and the code disagree, and the docstring is the half a reader trusts.

The two rules

Typechecker — LAST wins. lib/resolve.ml:781, in the ImportGlob arm:

Hashtbl.replace type_ctx.Typecheck.var_types  sym.Symbol.sym_id   scheme;
Hashtbl.replace type_ctx.Typecheck.name_types sym.Symbol.sym_name scheme

name_types is keyed on the bare symbol name, and imports are folded in declaration order. Hashtbl.replace overwrites, so the later use B::* clobbers the scheme installed by the earlier use A::*.

Flattening for codegen — FIRST wins. lib/module_loader.ml:338:

List.filter_map (fun (name, decl_kind) ->
  if Hashtbl.mem already_in name then None
  else begin
    Hashtbl.add already_in name ();
    ...

already_in is seeded with local decl names and then accumulated across imports in declaration order, so the first import to contribute a name claims it and every later one is dropped.

The docstring is inverted

lib/module_loader.ml:265-267, on flatten_imports itself:

Imports are processed in declaration order; later imports override earlier ones with the same fn name. Local decls in prog.prog_decls always win over imported ones.

Sentence two is implemented correctly (locals are pre-seeded into already_in, so they do win). Sentence one is the exact opposite of what line 338 does. It happens to describe resolve.ml's behaviour — the other side of the mismatch.

Expected failure shape

A.affine:  pub fn render(x: Int) -> String
B.affine:  pub fn render(x: String) -> String

main.affine:
  use A::*
  use B::*
  render("hi")     // typechecks against B (last), runs A::render (first)

Typecheck succeeds against B.render : String -> String; the flattened program contains A.render : Int -> String. On a dynamically-typed backend this is a silent wrong answer or a coercion crash at the call site rather than a type error at the import — the worst place for it to surface.

Whichever rule is chosen, this should be a hard error at the import site rather than a silent pick: a glob-glob collision has no correct silent resolution.

Scope and provenance — please read

  • Verified by reading the code on main, at the line numbers cited (resolve.ml:781, module_loader.ml:265-267 and :338). Both hunks are present upstream today.
  • NOT verified by a runtime repro. The example above is the predicted shape, not an executed test. It was first noticed by an automated port probe that terminated before preserving its reproduction, so the observation is being filed on the strength of the code reading rather than that probe's report.
  • Affects the non-WASM path. flatten_imports is the inlining route for backends without a module system; the WASM backend goes through codegen.ml's gen_imports/register_imported_types instead, which dedups separately (seen) and may have a third resolution order. Not audited here.
  • No fix proposed, per the standing hold on type-system work until REAL-LIFT lands. This is filed so the finding is not lost.

Related

Refs #519 (resolver determinism / import-cycle rejection / qualified-path canonicality, ADR-014) — adjacent but not a duplicate: #519 is about resolver determinism and cycles, this is a divergence between two resolvers that are each internally deterministic. A determinism proof over resolve.ml alone would pass while this bug remains.

Refs #228 (no module-qualified path in the type/effect grammar) — a qualified-path spelling would give users a way to disambiguate a collision that today has none.

Read against main 2026-09-08.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationDocs, prose, diagrams, READMEs, ADRs

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions