When Two Modules Collide: How Luma Learned Namespace Isolation
We hit a wall this week that every growing language eventually hits. Two modules, both perfectly valid on their own, crashed into each other the moment someone imported them into the same program. The fix taught us something important about how Luma should think about module boundaries.
The bug nobody writes on purpose
Here’s the setup. You write a math utility module:
// import2.luma
fn helper() -> int {
return 42
}
pub fn calc2(a: int) -> int {
return helper() + a
}And a separate statistics module:
// import3.luma
fn helper() -> int {
return 99
}
pub fn calc3(a: int) -> int {
return helper() + a
}Both modules work fine independently. Both have a private helper() function – a completely reasonable thing to name an internal utility. Now someone imports both:
m2 = import "import2"
m3 = import "import3"
print(m2.calc2(1))
print(m3.calc3(1))This should print 43 and 100. Instead, it refused to compile: helper redeclared in this block.
Why it happened
Luma compiles to Go. The module loader collects all declarations from every imported module and flattens them into a single Go source file. When import2 contributes fn helper() and import3 also contributes fn helper(), Go sees two functions with the same name in the same scope and rightfully rejects it.
The key insight: even though helper() is private (not pub), Luma still needs it in the output because the public function calc2() calls it. You can’t just drop private functions – they’re part of the module’s internal machinery. But you also can’t dump two modules’ private names into the same namespace and hope for the best.
This is the classic module isolation problem. Most languages solve it during their first year. We just reached ours.
The fix: prefix everything
The solution is straightforward in concept: give every module its own namespace by prefixing all of its declarations with the module name. When import2.luma is loaded, helper becomes import2_helper, calc2 becomes import2_calc2. When import3.luma is loaded, its helper becomes import3_helper, calc3 becomes import3_calc3. No more collisions.
But it’s not enough to rename just the declarations. Every reference inside the module has to follow. When calc2() calls helper(), that call needs to become import2_helper(). When a function returns a struct type defined in the same module, that type reference needs the prefix too. Function calls, variable references, type annotations, struct constructors, trait implementations – everything that refers to a module-local name needs to be rewritten consistently.
We do this at the AST level, before the compiler ever sees the code. Two passes:
Pass 1: Scan all top-level declarations and collect their names into a set.
Pass 2: Walk the entire AST. Every name that appears in the set gets the prefix. Declarations, function calls, variable references, type expressions – all of it.
The namespace struct that Luma builds for import "import2" still uses the original names as field names. So m2.calc2(1) works exactly as before – the struct field is called calc2, it just points to the Go function import2_calc2 under the hood.
The trap we almost fell into
The first version of the prefixer was naive. It walked every identifier in the AST and prefixed anything that matched a top-level name. This worked for the simple test case. Then we tried the CSV module.
The CSV module has a public function called col() that extracts a column from a table. It also has a private helper find_col_idx() with a walk loop:
fn find_col_idx(header: [str], name: str) -> int {
result: int = 0
found: bool = false
header.walk(i, col) -> {
if col == name {
result = i
found = true
}
}
...
}See the problem? The walk parameter col on line 3 shadows the top-level function col. Our naive prefixer saw col inside the walk body, found it in the top-level name set, and renamed it to csv_col. But the walk parameter col is a local variable – it should stay as col. The result was a type mismatch: the compiler tried to compare a function with a string.
The fix was scope tracking. When we enter a function body, we record its parameter names as locals. When we enter a walk block, we record the walk parameters. Same for lambda parameters and local variable declarations. Any name that exists in the local scope is left alone, even if it matches a top-level module name.
This is basic lexical scoping – the kind of thing that seems obvious in retrospect. But when you’re doing AST rewriting rather than traditional compilation, it’s easy to forget that the same identifier can mean different things at different depths.
What the compiler sees now
Before the fix, importing two modules produced a flat soup of declarations:
func helper() int { return 42 } // from import2
func calc2(a int) int { ... } // from import2
func helper() int { return 99 } // from import3 -- BOOM
func calc3(a int) int { ... } // from import3After the fix:
func import2_helper() int { return 42 }
func import2_calc2(a int) int { return import2_helper() + a }
func import3_helper() int { return 99 }
func import3_calc3(a int) int { return import3_helper() + a }Clean separation. Both helper functions exist, both work correctly, and they never interfere with each other. The namespace structs map back to the original names:
// m2 = import "import2"
var m2 = struct{ calc2 func(int) int }{ calc2: import2_calc2 }
// m3 = import "import3"
var m3 = struct{ calc3 func(int) int }{ calc3: import3_calc3 }m2.calc2(1) calls import2_calc2(1), which calls import2_helper(), which returns 42. Result: 43. Exactly right.
What this means for module authors
Nothing changes in how you write modules. You still use pub to mark public functions. You still use import to bring modules in. Private helpers stay private – they’re just internally renamed so they can’t collide with another module’s privates.
The one thing to know: if you name a walk parameter or a local variable the same as a top-level function in your module, the local wins. This is how scoping should work, and it’s how it works now. The prefixer respects your local scope.
Looking back
This was one of those bugs that’s embarrassing in hindsight. Of course two modules can have the same internal function names. Of course a module system needs isolation. But when your compiler started as a “compile one file to one Go file” tool and grew import support later, the flat namespace was always lurking as a time bomb.
The fix is about 200 lines of AST walking code. It touches two files. It doesn’t change the language syntax, doesn’t add new keywords, doesn’t require any existing code to be rewritten. Every existing module, every playground example, every test – they all work exactly as before. The only difference is that two modules with a helper() function can finally coexist.
That’s 200 lines we should have written from day one. But late is better than broken.