Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern, foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl, findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→ dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed issues/0043-…-foreign-class-…→…-runtime-class-…. PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/ issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party). Suite green (644 corpus / 443 unit, 0 failed).
8.4 KiB
0100 — cross-module same-name function lowering collision
RESOLVED. Two modules each exporting a top-level function with the same
short name (std.cli.parse, 3 params; std.json.parse, 2 params) collided
in IR lowering's bare-name function table. fn_ast_map (short name → AST)
was last-wins, while module.functions / resolveFuncByName are
first-wins, so importing both modules and calling one bound the AST of
one function against the FuncId of the other and tripped
lazyLowerFunction's param-count assert (src/ir/lower.zig:1606,
func.params.len == fd.params.len + ctx_slots) — panic: reached unreachable code. Qualified imports (j :: #import) did not help: lowering
keyed everything by short name, so j.parse and a bare parse resolved to
the same colliding entry.
Fix (src/ir/lower.zig, src/ast.zig, src/imports.zig):
- Module-qualified identity. A namespaced import's OWN plain functions
are now registered under their qualified name (
ns.fn) infn_ast_map, givingcli.parse/json.parseindependent identities. The qualified resolution paths inCallResolver.planandlowerCallalready preferns.fn— they just had nothing to find.NamespaceDeclcarries the module'sown_decls(populated inimports.addNamespace) so the registration covers authored decls, not transitive flat imports. Generic / comptime / pack / extern functions are excluded — they dispatch by monomorphization off the bare template name, not the plainresolveFuncByNamepath, so a qualified alias would strand their per-call type bindings. The qualified function is declared + lowered on demand bylazyLowerFunction's null-FuncId path (no eagerdeclareFunction, which would resolve types before the forward-alias fixpoint). - First-wins bare registration.
scanDeclsno longer lets a later namespace recursion clobber an existing barefn_ast_mapentry, aligning it withmergeFlat/resolveFuncByName. A bareparsewith one module flat-imported now consistently resolves to the first (unqualified-scope) function instead of splitting AST/FuncId across modules.
Regression: examples/0719-modules-cli-and-json.sx imports BOTH std.cli
and std.json under distinct namespaces and calls both cli.parse
(dispatch) and json.parse (document read), asserting correct results.
Panics on pre-fix code; passes after.
F1 follow-up — qualified alias must lower in its own source context
The identity fix above registered ns.fn in fn_ast_map WITHOUT an eager
declareFunction, so the qualified alias is lowered through
lazyLowerFunction's null-FuncId lowerFunction path — which had no
Function.source_file to restore (the non-null path does
setCurrentSourceFile(func.source_file)). The alias therefore lowered in the
caller's visibility context, and a qualified function calling a helper
from its own module's flat import was rejected:
m :: #import "m.sx"; // m.sx: `#import "helper.sx"; foo :: () { helper() }`
main :: () -> i32 { print("{}\n", m.foo()); 0 } // → 'helper' is not visible
Fix (src/ir/program_index.zig, src/ir/lower.zig):
- New
ProgramIndex.qualified_fn_source(qualified name → declaring source file), populated inregisterQualifiedFnfrom the decl's own source. lazyLowerFunction's null-FuncId branch restores that source viasetCurrentSourceFilebefore callinglowerFunction, sons.fn's body lowers in its own module's context and its own-import callees resolve.lowerFunctionnow recordsFunction.source_file = current_source_fileon the freshly-begun function (matchingdeclareFunction), so the lowered alias carries its own module for diagnostics/emit.
Regression: examples/0720-modules-qualified-own-import.sx — calc.compute
(a qualified alias) calls triple / base from calc.sx's own flat import.
Reports 'triple' is not visible on the attempt-1 code; passes after. 0719's
cross-module dual-parse assertion stays green.
F2 follow-up — null-FuncId path must restore the FULL caller lowering state
The F1 fix patched the source file in lazyLowerFunction's null-FuncId
branch, but that branch still restored only a SUBSET of the caller state the
non-null branch restores — it omitted self.block_terminated. A qualified
alias whose body terminates (e.g. a constant-folded if true { return … })
leaves block_terminated = true after lowerFunction; the null branch then
returned without resetting it, so the flag leaked into the caller's body
lowering and the caller's own trailing statements / return were treated as
dead-after-terminator:
m :: #import "m.sx"; // m.sx: `#import "helper.sx"; foo :: () -> i64 { if true { return helper(); } return 0; }`
main :: () -> i32 {
x := m.foo();
print("after\n"); // dropped
return 0; // → error: body produces no value
}
Fix (src/ir/lower.zig): the three exit paths of lazyLowerFunction (the
null-FuncId branch, the already-promoted early return, and the bottom of the
non-null branch) duplicated the restore, and the null branch's copy drifted.
They are now collapsed into a single defer registered right after the
state is saved, so every exit path restores the identical full set and the
class can't diverge again. The fields the defer now restores on all paths:
current_source_file(viasetCurrentSourceFile, which also resyncsdiagnostics.current_source_file) — F1scopefunc_defer_baseblock_terminated— F2 (was missing on the null path)force_block_valuebuilder.funcbuilder.current_blockbuilder.inst_counter
(The current_runtime_class, jni_env_stack_base, and pack-mono /
inline_return_target fields already had their own defers and apply on all
paths; they are unchanged.)
Regression: examples/0721-modules-qualified-terminating-callee.sx — m.foo
(a qualified alias) folds if true { return helper(); } and is followed by
caller statements + the caller's own return 0. Reports body produces no value on the attempt-2 code; prints terminating-callee: ok / after and
exits 0 after. 0719 and 0720 stay green.
Symptom
- Observed: a program that imports two modules each exporting a
same-named top-level function AND calls one crashes IR lowering:
panic: reached unreachable codeatsrc/ir/lower.zig:1606(lazyLowerFunction) vialowerCall. - Expected: each
pkg.fn(...)resolves to its own module's function; the program compiles and runs.
Reproduction
#import "modules/std.sx";
cli :: #import "modules/std/cli.sx";
json :: #import "modules/std/json.sx";
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 8192);
defer arena.deinit();
cmds : []Command = .[ Command.{ group = "ci", command = "publish", flags = .[] } ];
argv : []string = .["ci", "publish"];
d : Diag = .{};
p, e := cli.parse(argv, cmds, @d); // 3-param cli.parse
if e { return 64; }
v, je := json.parse("[1,2,3]", xx arena); // 2-param json.parse
if je { return 65; }
return 0;
}
Pre-fix: panic: reached unreachable code at src/ir/lower.zig:1606.
Post-fix: compiles and runs (exit 0).
Root cause
fn_ast_map, module.functions (matched by interned name), and
lowered_functions were all keyed by a function's SHORT name. Two functions
sharing a short name across modules occupied the same key; the put-order
mismatch (AST last-wins vs FuncId first-wins) drove lazyLowerFunction to
lower one signature against the other's body. The qualified-call resolution
machinery already existed but was never fed module-qualified entries.
Fix verification
zig build→ 0zig build test→ 0 (incl. LSP corpus sweep, 473 examples; 397/397 tests)bash tests/run_examples.sh→ 456 passed, 0 failedexamples/0719-modules-cli-and-json.sx: panics pre-fix, passes post-fix.examples/0720-modules-qualified-own-import.sx:'… is not visible'on the attempt-1 code, passes after the F1 fix.examples/0721-modules-qualified-terminating-callee.sx:body produces no valueon the attempt-2 code, passes after the F2 fix.
Regression tests: examples/0719-modules-cli-and-json.sx (collision),
examples/0720-modules-qualified-own-import.sx (F1 own-import visibility),
examples/0721-modules-qualified-terminating-callee.sx (F2 terminating
qualified callee — caller state transparency).