# 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).
