Files
sx/issues/0100-cross-module-same-name-fn-lowering-collision.md
agra b9cfe2554f refactor(ffi-linkage): Phase 9.3/9.4 — purge 'foreign' from issues/*.md; GATE PASS
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).
2026-06-15 11:18:35 +03:00

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):

  1. Module-qualified identity. A namespaced import's OWN plain functions are now registered under their qualified name (ns.fn) in fn_ast_map, giving cli.parse / json.parse independent identities. The qualified resolution paths in CallResolver.plan and lowerCall already prefer ns.fn — they just had nothing to find. NamespaceDecl carries the module's own_decls (populated in imports.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 plain resolveFuncByName path, so a qualified alias would strand their per-call type bindings. The qualified function is declared + lowered on demand by lazyLowerFunction's null-FuncId path (no eager declareFunction, which would resolve types before the forward-alias fixpoint).
  2. First-wins bare registration. scanDecls no longer lets a later namespace recursion clobber an existing bare fn_ast_map entry, aligning it with mergeFlat / resolveFuncByName. A bare parse with 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 in registerQualifiedFn from the decl's own source.
  • lazyLowerFunction's null-FuncId branch restores that source via setCurrentSourceFile before calling lowerFunction, so ns.fn's body lowers in its own module's context and its own-import callees resolve.
  • lowerFunction now records Function.source_file = current_source_file on the freshly-begun function (matching declareFunction), so the lowered alias carries its own module for diagnostics/emit.

Regression: examples/0720-modules-qualified-own-import.sxcalc.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 (via setCurrentSourceFile, which also resyncs diagnostics.current_source_file) — F1
  • scope
  • func_defer_base
  • block_terminatedF2 (was missing on the null path)
  • force_block_value
  • builder.func
  • builder.current_block
  • builder.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.sxm.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 code at src/ir/lower.zig:1606 (lazyLowerFunction) via lowerCall.
  • 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 → 0
  • zig build test → 0 (incl. LSP corpus sweep, 473 examples; 397/397 tests)
  • bash tests/run_examples.sh → 456 passed, 0 failed
  • examples/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 value on 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).