Files
sx/issues/0100-cross-module-same-name-fn-lowering-collision.md
agra 9274d47adf fix(lower): qualified alias lowers in its own source context [0100 F1]
The 0100 identity fix registers a namespaced import's own functions under a
module-qualified name (ns.fn) in fn_ast_map WITHOUT an eager declareFunction,
so the alias is lowered through lazyLowerFunction's null-FuncId lowerFunction
path. That path had no Function.source_file to restore (the non-null path does
setCurrentSourceFile(func.source_file)), so the alias lowered in the CALLER's
visibility context. A qualified function that called a helper from its OWN
module's flat import was then rejected "not visible".

Fix:
- ProgramIndex.qualified_fn_source maps each ns.fn alias to its declaring
  source file, populated in registerQualifiedFn (current_source_file is
  pinned to the decl's source by registerNamespaceQualifiedFns).
- lazyLowerFunction's null-FuncId branch restores that source before
  lowerFunction, so ns.fn's body lowers in its own module's context and its
  intra-module / own-import callees resolve.
- lowerFunction 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.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. issues/0100 RESOLVED banner
extended with the F1 follow-up.
2026-06-06 02:51:09 +03:00

6.0 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.

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 :: () -> s32 { 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.

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, 472 examples)
  • bash tests/run_examples.sh → 455 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.

Regression tests: examples/0719-modules-cli-and-json.sx (collision), examples/0720-modules-qualified-own-import.sx (F1 own-import visibility).