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

179 lines
8.4 KiB
Markdown

# 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.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` (via `setCurrentSourceFile`, which also resyncs
`diagnostics.current_source_file`) — F1
- `scope`
- `func_defer_base`
- `block_terminated`**F2** (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 `defer`s 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 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
```sx
#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).