ADR 0119: Class-Name↔Module-Name Resolution Registry
Status
Accepted (2026-09-05)
Context
"Given a Beamtalk class name, what compiled Erlang module does it live in?" has no single source of truth in the compiler. It is independently re-derived — or guessed — in at least seven places, each added ad hoc as a new codegen need arose:
class_module_index(crates/beamtalk-cli/src/commands/build.rs:2149build_class_module_index) — aHashMap<String, String>built from parsed ASTs during package-mode compilation's "Pass 1". The closest thing to a real source of truth today, but it is only consulted first, not exclusively, and only exists for package mode.is_known_stdlib_type(crates/beamtalk-codegen/src/core_erlang/value_type_codegen.rs:3638) overSTDLIB_CLASS_NAMES(crates/beamtalk-codegen/build.rs:44) — a Rust-build-time constant generated by a raw recursive file-stem directory scan ofstdlib/src/, not a parse. It includes protocol-only files that declare no class (Printable.bt), and it is the exact bug shape that broke BT-3432 (a pure rename silently changing resolution). Notably, a correct, already-parsed answer to "is this a known stdlib class" already exists elsewhere in the tree and is not consulted here:is_generated_builtin_class(beamtalk-core/src/semantic_analysis/class_hierarchy/generated_builtins.rs:19) is generated by thebeamtalk build-stdlibCLI command from real parsed class names (build_stdlib.rs'smeta.class_name, not file stems), has an Erlang twin (beamtalk_generated_builtins.hrl, BT-3085) generated from the same pass, and is kept fresh byjust check-generated-builtinsin CI.STDLIB_CLASS_NAMESis not that generation's output — it is an independent, cruder scan added later that duplicates a question the codebase already answers correctly.user_package_prefix(crates/beamtalk-codegen/src/core_erlang/util.rs:1278) — reverse-parses an already-computed module-name string to guess a package prefix. Its own doc comment admits it discards subdirectory segments (bt@sicp@scheme@eval→bt@sicp@, droppingscheme@) —class_module_indexexists largely to patch over this documented bug, not to replace it.module_matches_class(util.rs:1293) — the reverse direction (module → class), re-deriving all four candidate module-name shapes structurally. It never consultsclass_module_index, so it can disagree with the forward direction (compiled_module_name) for exactly the subdirectory caseuser_package_prefixgets wrong. Five call sites, all in gen_server self-dispatch/state routing (gen_server/state.rs:34,96;gen_server/callbacks.rs:102,851,1527).compiled_module_name_qualified(value_type_codegen.rs:3697, ADR 0070's explicitjson@Parsersyntax) — when a package qualifier is present it delegates toresolve_qualified_module_name(crates/beamtalk-core/src/ast/mod.rs:438), which composesbt@{pkg}@{snake}directly and, as its own doc comment states, bypassesclass_module_indexentirely. That is deliberate and documented, but it means an explicitly-qualified reference to a class in a package subdirectory resolves differently from the same class referenced unqualified — the index knowsbt@sicp@scheme@env, the qualified path composesbt@sicp@scheme_env. The unified registry has to make an explicit choice here rather than inherit two answers. (resolve_qualified_module_name'sNonearm returning a bare unprefixed name is not a defect: it is documented and tested contract — the caller adds the context-appropriate prefix — and its only production caller passesSome.)module_name_from_path(crates/beamtalk-cli/src/commands/build_stdlib.rs:465) — a fourth independent closed-form derivation of thebt@stdlib@{snake}rule, alongsidecompiled_module_name's stdlib branch (value_type_codegen.rs:3680),PrimitiveBindingTable::runtime_module_for_class(primitive_bindings.rs:180), andbeamtalk-compiler-port/src/main.rs:1504— synced with the other three only by a prose comment, plus a fifth structural copy of the same shape insidemodule_matches_class(util.rs:1297).- REPL / compiler-port duplication —
beamtalk-repl/src/codegen.rsandbeamtalk-compiler-port/src/main.rs(derive_class_module_name) each assemble their own version of this map, because the construction logic (build_class_module_index) lives inbeamtalk-cli, which neither crate may depend on (dependencies flow down only — seedocs/development/architecture-principles.md§1).
The one genuinely shared primitive, to_module_name (beamtalk-core/src/ast/mod.rs:400, CamelCase → snake_case), is a building block, not a resolver — every one of the above re-composes it differently around prefixes, packages, and subdirectories.
Evidence this is not hypothetical — three real, shipped bugs in this exact pattern:
- BT-3081 (Done): the Erlang runtime side of this same problem —
camel_to_snakeduplicated 4×, unified behindbeamtalk_module_name.erl. Already fixed; not touched by this ADR. - BT-3431 (Done): file-path-derived and class-name-derived module names disagreed silently, breaking
module_matches_class's self-dispatch/state-threading codegen with no diagnostic. Fixed with a validator (check_class_file_name_agreement) that prevents the input mismatch, not the dual-derivation that made it possible. - BT-3432 (Done): renaming
stdlib/src/TestCase.bt→test_case.bt(a pure filename change, no class-name change) silently brokeTestCaseresolution viaSTDLIB_CLASS_NAMES's file-stem-equals-class-name assumption.
Constraints on the solution:
- Dependency direction (
docs/ADR/0117-beamtalk-core-crate-split.html):beamtalk-core(Compilation context) must never importbeamtalk-codegenorbeamtalk-cli; both of those depend downward onbeamtalk-core. Any shared authority must live at or belowbeamtalk-coreto be reachable from CLI, REPL, LSP, and MCP alike. - Human-readable atoms: compiled module atoms must stay readable and typeable in a plain Erlang shell (ADR 0016) — this rules out hashed/mangled names as a collision strategy.
- No behavior change: this is a refactor. Compiled output for the existing stdlib/example/test corpus must be byte-identical. (An earlier draft of this ADR anticipated
Future's resolution as a likely exception; verified it is not —Futureis never actually resolved throughcompiled_module_nameon any live path today, see below — so no exception is expected. The corpus check remains how we'd find out if that assumption is wrong.) - Only the
camel_to_snakealgorithm itself (BT-3081) is out of scope, and it is already correct —beamtalk_module_name.erl'scamel_to_snake/1mirrorsto_module_namebyte-for-byte via a Rust↔Erlang conformance fixture pair (ast/mod.rs:446-471↔beamtalk_module_name_tests.erl:31-53). Perdocs/development/architecture-principles.md§7, that pair is a genuine cross-language boundary test and should be kept, not touched. The convention that stdlib classes compile tobt@stdlib@{snake}, however, is a separate rule from the algorithm, and it is not unified on the Erlang side:runtime/apps/beamtalk_runtime/src/beamtalk_primitive.erlalone contains 25 hardcoded'bt@stdlib@...'atom literals (33 across the runtime app) plus its own closed-form deriving functions (stdlib_module_for_tagged_class/1,beamtalk_module_name:to_stdlib_module_atom/1). This is a hard constraint on the Decision, not a pre-solved problem: because the runtime computesbt@stdlib@{snake}purely from a class name with no registry to consult, the compiler's stdlib naming can never become a free lookup — it must stay the same closed-form function forever, or the two sides silently diverge. Separately and more seriously,runtime/apps/beamtalk_workspace/src/beamtalk_repl_ops_load.erl:1561(build_source_class_module_index/1) is a full independent reimplementation of "parse.btsources and build a class→module index" — using a regex (re:run(Bin, <<"(\\w+)\\s+subclass:\\s+(\\w+)">>, ...),:1772) rather than a real parser. This is the actual authority feeding the wireclass_module_indexfor workspace/REPL sessions today, it is strictly weaker than the Rust parse (misses indented declarations, non-subclass:forms), and it reproduces the exact BT-3431/BT-3432 bug shape on a path this ADR does not reach by construction. See the open question in Implementation. - ADR 0100's open-world diagnostic policy constrains error handling. An unresolved class reference is a
Diagnostic::warning, not a compile error (beamtalk-core/src/semantic_analysis/validators/structural_validators.rs:140-149,check_unresolved_classes) — the class may be defined at runtime (hot reload, a REPL session building up state incrementally) and a static miss is not proof of a bug.NoSuchClass newtherefore reaches codegen today, by design, and any registry-based redesign must preserve that escape hatch rather than turn a warning into a hard failure. - Per CLAUDE.md's Duplication rule: "Module X sits below Y in the dependency graph" is not a reason to duplicate — extract a shared leaf module below both instead.
Decision
Introduce a single ClassModuleRegistry in beamtalk-core::semantic_analysis, next to ClassHierarchy, as the only answer to "what module is class X in." It wraps resolved names in a ModuleName newtype, extending ADR 0089's typed-leaf discipline to this leaf kind — with an honest scope for what that buys, laid out below.
// beamtalk-core/src/semantic_analysis/class_module_registry.rs
/// A validated, compiled module name — either a generated `bt@...` module or a
/// hand-written native backing module (ADR 0056), e.g. `beamtalk_future`.
/// Constructed by resolving a class through a `ClassModuleRegistry`, or
/// reconstructed at a trust boundary (the Pass-1 cache, the compiler-port
/// wire format) that is itself backed by a `ClassModuleRegistry` build.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModuleName {
Generated(String), // e.g. "bt@stdlib@ordered_collection"
Native(String), // e.g. "beamtalk_future"
}
/// Identifies which package a class was declared in, so two classes with the
/// same bare name (a user package's `Set` shadowing stdlib's) do not collide
/// in the registry. See Context: ADR 0026 currently forbids that case outright
/// (a compile error), but the key still carries package identity today so the
/// registry does not have to be re-keyed when that restriction is lifted.
pub enum PackageId {
Stdlib,
Package(String),
SingleFile,
}
/// How this compilation unit assigns module names to source files (ADR 0016/0026).
pub enum ModuleNamingScheme {
SingleFile, // bt@{stem}
Package { name: String }, // bt@{package}@{relative@path}
Stdlib, // bt@stdlib@{stem} — closed-form, validated not computed freely
}
pub struct ClassModuleRegistry {
class_to_module: HashMap<(PackageId, String), ModuleName>,
module_to_class: HashMap<ModuleName, (PackageId, String)>,
}
impl ClassModuleRegistry {
/// Resolves a class declared in `pkg` to its compiled module, given the
/// merge-precedence-ordered search (own package, then dependencies, then
/// stdlib — see Consequences/Negative on shadowing).
pub fn module_for_class(&self, pkg: &PackageId, class_name: &str) -> Option<&ModuleName> { .. }
pub fn class_for_module(&self, module: &ModuleName) -> Option<(&PackageId, &str)> { .. }
/// Assigns a fresh module name for a class the caller is defining right
/// now with no `.bt` source file — a REPL `subclass:` sent interactively,
/// or a hot-reloaded inline class definition arriving over the
/// compiler-port. Not a lookup: this is how a class enters the registry
/// in the first place outside batch compilation.
pub fn assign(&mut self, pkg: &PackageId, class_name: &str, naming: &ModuleNamingScheme) -> ModuleName { .. }
}
Miss handling must preserve the open-world escape hatch, not tighten it. module_for_class returns Option, and a None is not automatically treated as a compiler bug: per the ADR 0100 constraint above, a class that today's check_unresolved_classes only warns about (rather than rejects) can legitimately reach codegen, and that must keep working. The registry does not change what compiles; it changes what a successful resolution is based on. Concretely:
- A class present in the registry (own package, a dependency, or stdlib) resolves through the registry — no guessing, no fallback tiers, and (per the
PackageIdkey) no more silent subdirectory or shadowing mistakes. - A class absent from the registry falls back to the existing best-effort convention (
bt@{snake}orbt@{pkg}@{snake}) exactly as today's tier-4 fallback does — this is not new leniency, it is preserving the one legitimate reason the current fallback chain exists. What changes is that this path is now reached only for genuinely-unregistered references (the open-world case), never as a stand-in for a registered class the old heuristics simply computed wrong (the bug pattern this ADR targets).
ModuleName can represent a runtime-only builtin, but nothing seeds one today — and that's deliberate. Future is registered in ClassHierarchy (class_hierarchy/builtins.rs:84) with no stdlib/src/Future.bt source, and its real implementation is hand-written Erlang (runtime/apps/beamtalk_runtime/src/beamtalk_future.erl) in the beamtalk_* underscore namespace ADR 0016 reserves for native runtime code — not any generated bt@… module. That is exactly why ModuleName carries a Native variant rather than assuming every entry is generated. However, tracing every call site shows Future is not actually reachable through compiled_module_name today: compute_direct_call_eligible (core_erlang/mod.rs:3500-3529), the only place that iterates every hierarchy class unconditionally, excludes it at its class-methods-empty gate — Future's hardcoded ClassInfo always has class_methods: vec![] — and every other call site only resolves a class actually referenced, subclassed, or spawned by name, which nothing in stdlib does for Future. So it correctly falls through to the fallback-convention path today with zero observable effect, exactly as it does before this ADR. Seeding it now, before that path is live, would be speculative code for a scenario that can't currently happen. The registry therefore seeds nothing for Future; a comment at the compute_direct_call_eligible gate notes that if BT-507 ever gives a no-.bt-source builtin real class methods, its registry resolution needs revisiting then.
beamtalk-codegen's resolution collapses from a four-tier guess to one lookup plus the single documented fallback above — never a panic. CLAUDE.md forbids panicking on user input, and NoSuchClass new reaching codegen under the open-world policy is exactly that case.
Deleted outright, with no replacement (their job is now the registry's, or — for #2 — already done correctly elsewhere and merely uncalled): STDLIB_CLASS_NAMES and crates/beamtalk-codegen/build.rs's file-stem scan (delegate is_known_stdlib_type to the existing is_generated_builtin_class, widened to pub(crate) visibility — no new build-dependency required, since generated_builtins.rs is already generated ahead of time by the beamtalk build-stdlib CLI command, not a build.rs script), user_package_prefix, and the four independent bt@stdlib@ derivations, unified behind the registry's stdlib arm (which, per the runtime constraint above, still computes bt@stdlib@{snake} — it does not become a free lookup — but now validates that computation against the real parsed class name instead of trusting the file stem, closing the BT-3432 shape).
module_matches_class is replaced with something smaller than a full registry round-trip: all five of its call sites (gen_server/state.rs:34,96; gen_server/callbacks.rs:102,851,1527) ask the same question — "is this AST class the one I am generating right now for self.module_name?" — which, under ADR 0040's one-class-per-file rule, doesn't need module-name comparison at all. It becomes a direct check against the class the generator is already emitting (a self.current_class reference, populated once per module), not a registry lookup. This is deliberately not a drop-in replacement: the deleted function's last arm matched any package whose final @ segment agreed (so bt@other_pkg@util@math spuriously matched class Math) and its unprefixed arm exists only for hand-built test fixtures (util.rs:1288-1291) that never go through registry construction — both of those need updating at their call sites, not preserving.
resolve_qualified_module_name is kept — its None-arm contract is deliberate and tested — but compiled_module_name_qualified is routed through the registry so that pkg@Class and a bare Class resolve identically for a class in a package subdirectory, closing the divergence noted in Context item 5.
REPL/debugging illustration — no user-facing syntax changes; the observable effect is a class of dispatch bug becoming impossible rather than a new feature:
» "Before: a class in a package subdirectory couldn't be dispatched to by name"
» "bt@sicp_example@scheme@env" module for class SchemeEnv
-- user_package_prefix stripped `scheme@`, silently trying bt@sicp_example@env
» "After: the registry recorded the real path-derived module at Pass 1"
SchemeEnv new.
an instance of SchemeEnv "dispatches correctly regardless of nesting depth"
Misuse illustration — this ADR does not change what happens for a genuinely undeclared class; ADR 0100's open-world policy means that stays a warning, not an error, on purpose (the class may be defined at runtime). What changes is that the warning path and the resolved path can no longer be confused with each other:
» NoSuchClass new.
warning: Unresolved class `NoSuchClass`
(unchanged by this ADR — ADR 0100: this is not proof of a bug, so
compilation proceeds; the module name used if this reaches codegen is
still a best-effort `bt@no_such_class` guess, exactly as today)
» "But: a REAL class in a package subdirectory, which used to silently
resolve to the WRONG module (user_package_prefix's bug) now resolves
correctly instead of failing at runtime with a cryptic undef:"
SchemeEnv new.
an instance of SchemeEnv
Prior Art
Three distinct strategies exist for this class of problem (full research in the issue's linked session; summarized here):
- Convention as total function (Elixir's
Module.concat/split, Gleam'sErlangModuleNamenewtype over/→@). Zero lookup cost, but only works when the user-facing name is already globally unique — Elixir's dottedFoo.Barand Gleam's flat module path both are. A bare Smalltalk class name (OrderedCollection) is not: the same short name can legitimately exist in a user package and in stdlib, which is exactly why a pure function isn't sufficient and something has to disambiguate by where the class was declared, not by its name alone. - Resolve once, render via one cached query (rustc's
DefId→symbol_namequery, computed once perInstanceand never re-derived downstream). This is the shape we adopt, scoped narrowly to the module-name question rather than full identifier interning: resolve each class to its module once, during a dedicated pass, and give every consumer one typed accessor. - Runtime dictionary as authority (Pharo/Squeak's
Smalltalk globals, a liveSystemDictionary). Genuinely single-authority and reflective, but it's a runtime answer to what must be a compile-time question for a BEAM-targeting compiler emitting separately-compiled.beamfiles — and it degenerates to a flat namespace requiring manual prefixes (RBParser,ZnClient) for exactly the collision problem this ADR is trying to solve structurally. Newspeak's response is to reject global names entirely in favor of pure lexical scoping over module-object nesting — the most principled position, but it doesn't answer what atom to write in-module(...); something still has to flatten a nested reference to one global atom for BEAM's flat module table, which is the compiler's business either way.
We adopt rustc's "resolve once" shape (a registry built during a dedicated phase, not a runtime dictionary, not a distributed heuristic), wearing Gleam's newtype pattern for the rendering half (ModuleName, not String) — but, unlike Gleam, apply it uniformly to every production site (codegen, stdlib build, CLI, REPL, compiler-port), not just the primary codegen path. Gleam's own module_erlang_name (compiler-core/src/build.rs) and two raw .replace("/", "@") calls in its filesystem/cache layer are a live example of exactly this ADR's failure mode, still unfixed upstream — a caution against declaring victory once the "main" call site is typed.
We explicitly reject full rustc-style opaque-ID interning (a ClassId threaded through the AST, replacing string class names everywhere) as disproportionate to this problem — see Alternatives Considered.
User Impact
- Newcomer: no visible change. Referencing an undeclared class still warns rather than errors (ADR 0100's open-world policy is untouched); the difference is invisible to them — a class that is correctly declared, including in a package subdirectory, now reliably resolves instead of occasionally dispatching to the wrong module.
- Smalltalk developer: preserves and actually improves message-passing purity by fixing a real, silent dispatch bug — classes declared in package subdirectories currently cannot reliably be dispatched to by name at all (the
user_package_prefixsubdirectory-stripping bug); this ADR makes that case correct. - Erlang/BEAM developer: zero change to the module-atom naming scheme itself (
bt@,bt@stdlib@,bt@{pkg}@are unchanged) — this ADR is entirely about where that decision is computed, not what it produces. Existing interop code, hand-written.erlFFI, and compiled artifacts are unaffected; the corpus must compile byte-identical. - Production operator: no runtime behavior change (module-name derivation is compile-time only; hot code reloading is untouched). Reduces the risk of the exact silent-codegen-bug class that has already shipped three times (BT-3081/3431/3432) reaching production dispatch code.
- Tooling developer (LSP/MCP): LSP's go-to-definition/hover navigation (
find_definition_cross_fileand friends) is source-level already — it resolves directly against parsed(file_path, module)pairs and never touches Erlang module names, so it is unaffected either way, not "fixed" by this ADR. The concrete beneficiaries are REPL and the compiler-port (hot code reload), which currently duplicate class→module assembly because neither may depend onbeamtalk-cli; both switch to the sharedbeamtalk-coreregistry. LSP/MCP gain one correct API to call if a future feature needs to report a class's compiled module name (e.g. a hover tooltip), rather than re-implementing the heuristics — a future-proofing benefit, not a present bug fix.
Steelman Analysis
| Cohort | Option A (registry in beamtalk-core) | Option B (minimal consolidation in place) |
|---|---|---|
| 🧑💻 Newcomer | Indifferent either way — both are invisible below the surface language. | Same. |
| 🎩 Smalltalk purist | "One authority you can query for 'what module is this class in' is exactly the Smalltalk globals shape, minus the runtime-mutability hazard — this is the compile-time analogue done right." | "Still one authority in practice, just not moved — the purist cares that dispatch is correct, not which crate the map lives in." |
| ⚙️ BEAM veteran | "Centralizing this in beamtalk-core means REPL, hot-reload (compiler-port), and CLI all get identical resolution — no more risk of REPL and beamtalk build disagreeing about a class's module." | "Smaller diff, less risk of a botched crate-boundary move breaking hot-reload during the transition." |
| 🏭 Operator | "Fixes the root cause once, permanently, ahead of the next three variants of this bug — worth the one-time XL migration cost." | "Ships the acceptance criteria faster with less blast radius — an XL architectural move is itself a risk to a stable release cadence." |
| 🎨 Language designer | "A typed ModuleName gives every in-process caller one function to trust instead of five to independently get right — and cheap wins like delegating to is_generated_builtin_class fall out of doing this properly at all, for less cost than expected." | "Discipline plus a completeness check gets you most of the correctness for a fraction of the cost — and that's not a caricature: is_generated_builtin_class shows this codebase already has at least one place where the completeness check is real, CI-enforced, and untyped." |
Tension point: operators and BEAM veterans are genuinely split — the veteran cares about eliminating a class of latent cross-surface bugs (REPL vs CLI vs hot-reload disagreeing), which only Option A fixes, while the operator's instinct is to minimize risk to a single release. We resolve this in favor of the veteran's argument because the issue itself frames this as the third recurrence of the same root cause — an operator who has already shipped three point-fixes for the same bug should weigh the compounding cost of a fourth recurrence, not just this migration's size. We also concede the language-designer steelman for Option B more ground than the first draft of this ADR did: ModuleName is not a hard type-system wall. leaf::atom (beamtalk-cerl-doc/src/leaf.rs:57) accepts any impl Into<String>, beamtalk-cerl-doc sits below beamtalk-core with zero dependencies of its own, and two real boundaries — the Pass-1 build cache and the compiler-port's JSON wire format — necessarily reconstruct a ModuleName from a plain string at a trust boundary. The newtype's actual benefit is narrower than "structurally impossible to bypass": it means the handful of functions that resolve a class to a module return one typed value instead of five ad hoc Strings, which is real but is an in-process discipline win, not a compiler-enforced wall against every future call site. Option A remains the right call because its real justification — one authority reachable from CLI, REPL, LSP, and the compiler-port alike — doesn't depend on that overstated claim.
Option C (full opaque ClassId interning, rustc-style) gets a genuine steelman only from the language designer: "this is the only version that also fixes adjacent bugs like BEAMError/Beamerror case-folding collisions, not just the module-name question." No other cohort's argument survives contact with its cost — see Alternatives Considered.
Alternatives Considered
Option B: Minimal consolidation in place
Keep class_module_index where it is (beamtalk-cli), mandate that it always be fully populated (merge in stdlib and all dependencies unconditionally), and delete only the fallback tiers in compiled_module_name — no crate move, no ModuleName newtype.
Rejected primarily because it doesn't fix the REPL/compiler-port duplication — neither may depend on beamtalk-cli, so they would keep independently assembling an equivalent map (and, per this ADR's research, the REPL/workspace side's actual index-builder today is beamtalk_repl_ops_load.erl's regex-based scan, which Option B leaves fully untouched). The newtype is a secondary reason, not the primary one: as the Steelman above concedes, ModuleName is a real but partial discipline win, not an unbypassable wall, so it alone would not be sufficient grounds to prefer Option A. What tips the balance is the cross-surface authority Option B cannot provide without also relocating the construction logic — at which point it stops being "minimal."
Option C: Full opaque ClassId interning (rustc-style)
Introduce an interned ClassId resolved once during semantic analysis; thread it through the AST/IR in place of string class names everywhere; render module names via one memoized query keyed on ClassId.
Rejected as disproportionate: the blast radius extends well past the issue's own file list into the parser, AST representation, every codegen function currently pattern-matching on &str class names, and language-service queries (hover/completion) that key off strings today. This solves a more general problem (identifier interning) than the one at hand (module-name derivation), at a cost closer to a multi-quarter rewrite than the "genuine architectural change... not a quick fix" the issue itself scoped.
Status quo: continue point-fixing as new instances appear
Explicitly rejected by the issue itself — this is the third real bug from the same root cause (BT-3081, BT-3431, BT-3432), each independently patched without addressing the pattern that produces them.
Consequences
Positive
- One authority for class→module resolution on the Rust side; deletes
STDLIB_CLASS_NAMES's file-stem-scanningbuild.rs(delegating to the already-correctis_generated_builtin_class),user_package_prefix,module_matches_class's structural guessing, and the four independentbt@stdlib@derivations outright. - Fixes the three shipped bugs this pattern has already produced (BT-3081's fallout, BT-3431, BT-3432) plus two latent gaps surfaced by this research: unreliable dispatch to classes in package subdirectories (
user_package_prefix's documented limitation), and the divergence between qualified and unqualified references to that same class (compiled_module_name_qualifiedbypassing the index) — without narrowing ADR 0100's open-world escape hatch. ModuleNamegives every in-process resolver call site one typed value to agree on, and thePackageId-qualified key makes same-named classes in different packages representable rather than colliding — see Negative on what this does not enforce.- Eliminates the REPL/compiler-port's independent map assembly by relocating the shared authority to
beamtalk-core, which both already depend on — and surfaces (rather than silently accepts) the fact that the REPL/workspace path's actual index today is an Erlang regex scan, not Rust logic at all. - Adopting
is_generated_builtin_classturns out to cost nothing extra: no new build-dependency, no new parse step — it already exists, is already parsed correctly, and is already CI-checked.
Negative
- XL-sized migration touching all three compilation modes (single-file, package, stdlib) at once — cannot land incrementally class-by-class, only phase-by-phase (see Implementation).
ModuleNameis a real but partial safety net, not a compiler-enforced wall:leaf::atomstill accepts any string, and the Pass-1 cache and compiler-port wire format both reconstructModuleNamevalues from plain JSON strings at trust boundaries outside the registry's control. A future call site can still, in principle, hand-build a module string; the newtype raises the bar, it does not remove the possibility.- This ADR does not fix
beamtalk_repl_ops_load.erl's regex-based class-index scanner, which remains the real authority for workspace/REPL-mode module resolution and reproduces the BT-3431/BT-3432 bug shape on a path outside this ADR's Rust-side fix — see the open question in Implementation. Framing this ADR as eliminating the pattern industry-wide would overstate its reach. ClassModuleRegistryas specified is built once per compilation unit from a file list; it has no stated invalidation story for ADR 0114's liverenameTo:/moveClass:to:, which changes a class's derived module name at runtime in an active workspace — see the open question in Implementation.- Any code outside the crate relying on the deleted functions' current (buggy) behavior breaks; per this research all are
pub(super)/private already, so exposure is limited to the call sites enumerated above.
Neutral
- The module-atom naming scheme itself (
bt@,bt@stdlib@,bt@{pkg}@) is unchanged — this ADR consolidates where the decision is computed on the Rust side, not what it produces. The Erlang runtime'sbt@stdlib@{snake}derivation stays a closed-form function by necessity (it has no registry to consult), not by oversight. - The Rust↔Erlang
to_module_name/camel_to_snakeconformance fixture pair is untouched — correctly a permanent cross-language boundary test per architecture-principles §7, not part of this consolidation.
Implementation
- Independent quick win, shippable ahead of the rest: widen
is_generated_builtin_classtopub(crate)/pub, makeis_known_stdlib_typedelegate to it, and deletecrates/beamtalk-codegen/build.rs's file-stem scan andSTDLIB_CLASS_NAMESentirely. No new dependency, no registry required. This alone closes the BT-3432 bug shape and thePrintable.btfalse positive for the one call site that had them. - Add
ClassModuleRegistry+ModuleName(with itsPackageId-qualified key) tobeamtalk-core::semantic_analysis, generalizing today'sbuild_class_module_index("Pass 1") to cover single-file and package mode uniformly. The build API must support the existing per-file incremental cache (.beamtalk-pass1-cache.json,CACHE_VERSION) — a per-fileRegistryEntryplus a merge function, not a monolithic re-parse-everything call — or this ADR silently regresses incremental build times; bumpCACHE_VERSIONwhen the cache's serialized shape changes. - Route the stdlib's registry construction through
build_stdlib.rs's existing parse pass (it already produces real class metadata forgenerated_builtins.rs/beamtalk_generated_builtins.hrl) rather than adding any new parsing step. The stdlib arm of the registry validates the closed-formbt@stdlib@{snake}name against the real parsed class name (catching a BT-3432-shaped mismatch) rather than freely computing a path-based name — the Erlang runtime's closed-form derivation is a hard constraint (see Context), not a stepping stone to a lookup. Merge stdlib into every project's registry through the same dependency-merge path used for path dependencies, with explicit, documented precedence (own package wins over a dependency, which wins over stdlib) so shadowing a protected builtin (is_runtime_protected_class, a warning today, not an error) has one defined outcome instead of an accident of tier ordering. - Point
beamtalk-codegen'scompiled_module_name/superclass_module_nameat the registry, falling back to the existing best-effort convention only on a genuine registry miss (see Decision); replacemodule_matches_class's five call sites with a direct check against the class currently being generated rather than a module-name comparison. - Route
compiled_module_name_qualified(ADR 0070'spkg@Class) through the registry so it agrees with unqualified resolution for subdirectory classes, covered by a regression test.resolve_qualified_module_nameitself stays as-is. - Give the registry an
assignoperation (see Decision) and relocate the compiler-port'sderive_class_module_name(beamtalk-compiler-port/src/main.rs:1494-1508, which mints a name for an inline class definition with no source file — not a lookup) and REPL's independent map assembly onto it. - Regression tests reproducing each of BT-3081/BT-3431/BT-3432's specific scenarios against the unified registry (per the issue's acceptance criteria), plus a subdirectory-dispatch test. (No
Future-specific regression test — nothing resolves its module on a live path today; see Decision.) - Verify byte-identical compiled output for the existing corpus via the
test-package-compilerinsta snapshot suite (test-package-compiler/tests/snapshots/*.snap) — notjust build-corpus, which regenerates the unrelated MCP search-tool corpus (ADR 0062).
Open questions for the author, not resolved by this ADR:
- Should
beamtalk_repl_ops_load.erl's regex-basedbuild_source_class_module_index/1be replaced in this same epic (routing workspace/REPL index construction through the compiler-port into the new Rust registry), or tracked as an explicit follow-up issue? Left as-is, this ADR's "single source of truth" claim does not hold for workspace-mode sessions. Note thatclass_module_indexis itself a stable, versioned compiler-port wire field predating this ADR (ADR 0050, "existing, kept for compat" alongside the newerclass_hierarchyfield added by BT-993) — replacing its Erlang-side producer must preserve or deliberately version that wire contract, not silently reshape it. - How should the registry handle ADR 0114's live
renameTo:/moveClass:to:, which changes a class's derived module name in a running workspace after the registry was built? A rebuild-on-mutation hook, or does workspace mode keep a separate mutable path indefinitely?
Affected components: beamtalk-core (new registry in semantic_analysis), beamtalk-codegen (build.rs deleted, value_type_codegen.rs/util.rs/primitive_bindings.rs consolidated), beamtalk-cli (build.rs/build_cache.rs Pass 1 relocated and generalized), beamtalk-repl (codegen.rs simplified), beamtalk-compiler-port (derive_class_module_name replaced with assign).
Estimated size: XL, matching the issue's own label — likely a floor rather than a ceiling if the workspace-mode open question above is pulled into scope.
Implementation Tracking
Epic: BT-3434
Issues: BT-3435 (foundation — ClassModuleRegistry), BT-3436 (consumers — codegen/REPL/compiler-port wiring), BT-3437 (validation — regression tests + corpus verification)
Status: Complete — all three issues are Done. BT-3437 added regression tests reproducing BT-3081/BT-3431/BT-3432's specific scenarios against the unified registry, a package-compiler/e2e subdirectory-dispatch test, and a Future/native-backing-module resolution test, and confirmed test-package-compiler's insta snapshot suite is byte-identical (zero diffs) — the "no behavior change" constraint holds. The two open questions in Implementation (the Erlang-side beamtalk_repl_ops_load.erl regex scanner, and ADR 0114 live-rename invalidation) were both explicitly deferred, not resolved, at the time.
BT-3441 (follow-up, Done): Resolved the first open question above. beamtalk_repl_ops_load:build_source_class_module_index/1's regex-based extract_all_bt_classes/1 and hand-rolled source_module_name/3 (snake-casing via beamtalk_repl_loader:to_snake_case/1) are gone; the compiler port gained a build_class_module_index_in_source command (pinned to the shared compiler_port_command_vocabulary_corpus.json conformance fixture, ADR-cross-referenced BT-3095) that parses each src/**/*.bt file with the real grammar and derives its module name via the same relative_module_segments leaf compute_relative_module/ClassModuleRegistry use — so the workspace/REPL cold-load index can no longer diverge from a beamtalk build of the same project. class_module_index's wire shape (ADR 0050) is unchanged — an internal-producer swap only. The ADR 0114 live-rename invalidation open question remains open.
References
- Related issues: BT-3434, BT-3081, BT-3431, BT-3432
- Related ADRs: ADR 0016 (unified stdlib module naming), ADR 0026 (package module naming), ADR 0050 (incremental compiler ClassHierarchy — establishes
class_module_indexas a versioned compiler-port wire field), ADR 0056 (native backing modules), ADR 0070 (package-qualified class references), ADR 0089 (typed Document leaves), ADR 0100 (open-world diagnostic policy), ADR 0111 (ThreadedIr verifier — structured-error house pattern), ADR 0114 (class/method rename in the live workspace), ADR 0117 (beamtalk-core crate split) - Documentation:
docs/development/architecture-principles.md§6 (Duplication & the Shared-Leaf-Module Pattern), §7 (Consistency-Test Disposition Rule)