Invisible link to canonical for Microformats

ADR-017 Cyclic imports, live bindings, and module initialization semantics


Status

Accepted

Context

Slug’s module loader caches a module object before evaluating its top-level statements. This is necessary to break recursive loads, but it allows a module to be observed in a half-loaded state during circular imports (e.g., A -> B -> A). The symptoms included:

  • Importers seeing missing/partial exports (because the module env had not been populated yet).
  • Runtime “identifier not found” inside a module that was “loaded enough” to be cached.
  • Subtle import-order behavior and low-quality error reporting in cycles.

At the same time, Slug values are immutable, and Slug prioritizes explicitness and developer experience. We want cyclic imports to be supported, without introducing “spooky” partial modules or snapshot semantics.

Decision

1) Support cyclic imports via two-phase module initialization

Modules are loaded in two phases:

  1. Declare (predeclare) phase
    • Predeclare statically knowable top-level bindings (var / val identifiers and spread-bound names) in the module environment.
    • Bind each predeclared name to the sentinel BINDING_UNINITIALIZED.
    • Predeclare does not execute top-level code.
    • Predeclare skips patterns that cannot be known statically (e.g., var {*} = import(...)).
  2. Execute phase
    • Evaluate the module program in the module’s environment.
    • Bindings transition from BINDING_UNINITIALIZED to concrete values as statements execute in order.

The runtime still caches the module object early (before execution) to break recursion, but because names are predeclared, cyclic import observation becomes predictable.

2) Introduce Uninitialized sentinel for declared-but-not-ready bindings

A runtime-only object sentinel is used:

  • Uninitialized object type
  • singleton instance BINDING_UNINITIALIZED

Accessing a binding whose current value is BINDING_UNINITIALIZED raises a targeted runtime error:

  • <name> used before initialization (likely circular import)”
  • includes accurate source position
  • (future enhancement) include import cycle chain A -> B -> C -> A

3) Make imports “live” via BindingRef indirection (invisible to users)

A runtime-only indirection object is introduced:

  • BindingRef{Env *Environment, Name string}

import() does not snapshot exported values. Instead, imported members (for non-function exports) are represented as BindingRefs pointing at the exporting module’s environment + binding name.

Evaluator semantics:

  • Dereferencing is invisible to Slug developers.
  • The evaluator transparently resolves BindingRef to the referenced binding’s current value.
  • Deref loops through nested refs.
  • If a deref hits BINDING_UNINITIALIZED, it errors (see above).

4) Import map tagging and destructuring remain unchanged

import() returns a Map tagged with @import. Existing destructuring patterns (e.g., var {*} = import(...)) continue to work as runtime binding operations.

Predeclare does not attempt to predict var {*} = import(...) bindings; those names are bound at runtime during destructuring.

5) Collision behavior favors dev experience, with deterministic rules

Collisions are handled deterministically and do not panic. Behavior is:

  • Local declarations override imported names (warning)
    • If an existing binding is marked Meta.IsImport == true, a local var/val of the same name is allowed and emits a warning (“imported name shadowed by local definition”).
    • Redefinition of a real local val remains an error.
  • Import-vs-import collisions (warning)
    • For non-function exports: warn and keep the first binding.
    • For function exports: merge function groups by signature (see below).

6) Function group composition across imports: merge by signature, no silent overwrite

Slug supports multi-arm functions via FunctionGroup (map of signatures to implementations). When multiple imported modules export the same name:

  • If both exports are function groups:
    • merge their signatures into the imported view
    • duplicate signature is a collision (warning)
    • keep first definition on duplicate signature
  • If one side is a function group and the other is not:
    • warn and keep the first

Consequences

Positive

  • Cyclic imports are supported with predictable behavior and clear failure modes.
  • “Half-loaded module” bugs become “used before initialization” errors with good positions.
  • Imports become live (no snapshotting), reducing surprises and enabling future hot reload.
  • Function polymorphism composes across modules in a controlled way (signature-based).
  • Star-import / destructuring stays ergonomic; local definitions can override imports (warning).

Negative

  • Module loading and import semantics are more complex than a hard “no cycles” rule.
  • Developers can still create initialization-order dependencies; they will fail at runtime if accessed too early.
  • Live bindings require evaluator care (must dereference in key places: identifiers, calls, indexing).

Neutral

  • Predeclare only handles statically knowable bindings; dynamic destructuring (var {*} = ...) remains runtime-only.
  • Collision policy is currently intentionally permissive (warnings). It may be tightened later without changing the model (only the severity).