18. The module system
- Status: Accepted
- Date: 2026-09-03
Context
Every Korrin program so far has been a single file. Real programs are not: they
split into pieces that import each other, and they reach for a standard
library. Milestone 2 adds both. This ADR covers the system — how a module is
named, found, loaded, and used; ADR 0019 covers the
built-in modules that ride on it.
The decisions in play:
- What is a module? A file? A namespace object? A set of names spliced into the importer?
- How is one named — by path, by a registered name, by package?
- What does
importbind — the module, or names out of it? - Isolation — does an imported module see the importer's globals?
- Cycles — allowed (with partially-initialised modules, like Python), or an error?
The user chose: path + module object. import "./utils" binds utils;
members are reached as utils.foo(). import "./a/config" as cfg handles a name
collision. Top-level names are public; _name is private by convention. A
circular import is an error. A bare import "math" (no ./) is a built-in
module.
Decision
Syntax
import "<specifier>" # binds the specifier's final component
import "<specifier>" as <name> # binds <name>
import is a statement and may appear anywhere a statement may (it binds its
alias under the normal scoping rules — at the top level that means a global).
The specifier is a plain string — no interpolation holes. When there is no
as, the alias is the specifier's last path component with any extension
removed ("./a/config" → config, "math" → math); if that is not a valid
identifier, as is required (E0108).
Resolution
- A specifier starting with
./or../is a file, resolved relative to the directory of the file doing the importing (not the process's working directory, and not the entry file)..koris appended if the specifier has no extension. The path is canonicalised; a file that cannot be found or read isE0314. - Any other specifier is a built-in module (ADR 0019).
An unknown one is
E0314.
Relative imports need a file on disk to resolve against, so they only work when
the program was started from a file (korrin run file.kor, or korrin::run_file
for an embedder). In-memory execution (korrin::run) has no directory: a
relative import from it is E0314, while built-in
imports still work.
The module object
import evaluates the module's file (or builds the built-in) once and binds
a module object — type() reports "module". Loading is cached by
canonical path (or built-in name), so importing the same module from two places
yields the same object (== is identity).
A module's file runs in its own scope: a fresh global with only the builtins
and Error, then the module's top level. It does not see the importing
program's names, and the importer does not see the module's names except through
the object.
module.foo reads foo from the module's top-level bindings.
- A name that is not bound is
E0309("modulexhas no memberfoo"). - A name starting with
_is private: reading it through the module isE0309, even though it exists. Code inside the module uses its_-names normally; privacy is only enforced at the boundary.
There is no way to enumerate, re-export, or splice a module's names. import
binds exactly one object.
Cycles
While a module is being loaded its path is marked "in progress". An import
that resolves to a module already in progress is
E0315 — Korrin does not expose half-initialised
modules. Break the cycle by moving the shared thing into a third module both
import.
Implementation
Value::Module(Rc<ModuleObject>)whereModuleObjectholds the name, origin (for diagnostics), and the module's scopeEnvironment.Interpreterholds aModuleRegistry— a directory stack (for./resolution), a cache, and an in-progress set.korrin::run_fileseeds the stack with the entry file's directory; each nested file import pushes its own.- Compile errors inside an imported module surface as a single
E0314at theimportsite, carrying the module's own error text. Per-file diagnostic rendering (a caret into the imported file) is a known gap, tracked for a later milestone.
Consequences
- A Korrin codebase can span files, which is the point.
- Module objects (rather than name-splicing) keep every name's origin visible at
the use site:
geometry.area(r)says whereareacame from. Nofrom x import *ambiguity, ever. - The "fresh globals per module" rule means a module cannot accidentally depend
on a name the importer happened to define. It also means every module re-pays
for its own builtin bindings (cheap) and that two modules' top-level
_helpersnever collide. - Cycles being an error is stricter than Python. It rules out a real (if awkward) pattern, in exchange for never handing code a module that isn't finished. Consistent with Korrin's "fail loud" stance.
- Imported-module diagnostics are worse than single-file ones until multi-file rendering lands. Acceptable for M2; noted.
importanywhere (not just the top level) is permissive. It costs nothing and a top-level-only rule would be one more thing to explain.
Alternatives considered
- Name-splicing (
from "./x" import foo). Familiar, but it detaches a name from its source and invites collisions andimport *. The module object gives the same access with the origin always visible. - Cycles allowed, with partial modules. Python's model. Powerful for a few patterns, a source of confusing bugs for many more (a name is sometimes there, sometimes not, depending on load order). Rejected for predictability.
- A package manifest / registry. Way beyond M2. Path-based resolution needs
no configuration and is enough to build with; a package story can come later
without changing
import's surface. - Resolving
./against the process CWD (like a shell). Surprising — moving where you runkorrinfrom would change which files a program sees. Relative to the importing file is what every modern language does. exportkeyword / explicit public list. More ceremony. The_prefix convention (already Korrin's style for "internal") extended to a hard boundary rule covers it with no new syntax.