Files
sx/issues/0100-cross-module-same-name-fn-lowering-collision.md
agra 3edc67521b fix(lower): resolve cross-module same-name functions by identity [0100]
Two modules each exporting a top-level function with the same short name
(std.cli.parse 3-param, std.json.parse 2-param) collided in IR lowering's
bare-name function table. fn_ast_map (name -> AST) was last-wins while
module.functions / resolveFuncByName are first-wins, so importing both and
calling one bound one function's AST against the other's FuncId and tripped
lazyLowerFunction's param-count assert (lower.zig:1606) — reached
unreachable code.

Fix:
- Register a namespaced import's OWN plain functions under their qualified
  name (ns.fn) in fn_ast_map, giving cli.parse / json.parse independent
  identities. The qualified resolution paths in CallResolver.plan /
  lowerCall already prefer ns.fn. NamespaceDecl now carries own_decls
  (populated in imports.addNamespace). Generic/comptime/pack/foreign
  functions are excluded (they dispatch by monomorphization off the bare
  template name); no eager declareFunction (it would resolve types before
  the forward-alias fixpoint).
- Make scanDecls' bare fn_ast_map registration first-wins so a later
  namespace recursion cannot clobber an earlier (flat) entry, aligning it
  with mergeFlat / resolveFuncByName.

Regression: examples/0719-modules-cli-and-json.sx imports both std.cli and
std.json under distinct namespaces and calls both parses; panics pre-fix,
passes after. issues/0100 marked RESOLVED.
2026-06-06 02:30:19 +03:00

4.1 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 / foreign 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.

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 :: () -> s32 {
    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, 471 examples)
  • bash tests/run_examples.sh → 454 passed, 0 failed
  • examples/0719-modules-cli-and-json.sx: panics pre-fix, passes post-fix.

Regression test: examples/0719-modules-cli-and-json.sx.