Assigning a struct literal to a named-struct member of a plain union
(`u.b = .{ ... }`) lowered the RHS as .unresolved and tripped the
LLVM-emission tripwire: lowerAssignment's .field_access target-type
path used getStructFields, which returns nothing for a union, so the
literal never received its target type.
Unify the lvalue field matcher into a pure fieldLvalueResolve consumed
by both fieldLvaluePtr (GEP builder) and the target-type path, so the
store slot and the RHS target type can't diverge (covers union direct +
promoted members, tuple/vector lanes, and structs).
Resolves issue 0133 (depended on 0135). Regression test: examples/0184.
Notes the now end-to-end union path in issue 0132.
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# 0132 — protocol method return/param type resolves to the WRONG same-name type (visibility-unaware registration)
|
||
|
||
> **RESOLVED (2026-06-13).** Root cause: `registerProtocolDecl`
|
||
> (`src/ir/protocols.zig`) resolved each method's param/return type NAME
|
||
> through the flat, visibility-UNAWARE `type_bridge.resolveAstType`, so a
|
||
> name colliding across modules (the user's `Event` enum vs the stdlib
|
||
> `event.Event` struct) bound to the wrong author. Fix: resolve both
|
||
> through `self.l.resolveTypeInSource(pd.source_file, …)` — the
|
||
> visibility-aware stateful resolver pinned to the protocol's OWN
|
||
> declaring module — keeping the `Self → *void` short-circuit. This
|
||
> brings the non-parameterized path to parity with the parameterized
|
||
> path (`instantiateParamProtocol`) and concrete-fn signatures, which
|
||
> already pin to the defining module. The broader enum/union/inline/
|
||
> error-set registration class was already fixed in `f13f4ab`; this
|
||
> commit closes the protocol-return case it left open. Regression test:
|
||
> `examples/0417-protocols-protocol-return-name-collision.sx` (prints
|
||
> `escape!`, exit 0). The error-set reference path remains dormant
|
||
> pending error-set per-decl nominal identity (issue 0134).
|
||
|
||
> **ROOT CAUSE CORRECTED (2026-06-13).** The original write-up (kept in
|
||
> "Original hypothesis" below) guessed this was about an inferred
|
||
> protocol-return enum TypeId "not carrying payload struct field types".
|
||
> That is **not** the cause. A ground-truth trace (instrumented build)
|
||
> shows the real bug: **`registerProtocolDecl` resolves method
|
||
> parameter/return type NAMES through a flat, visibility-UNAWARE lookup**
|
||
> (`type_bridge.resolveAstType` → `resolveNamed` → global `findByName`),
|
||
> so when the named type has a same-name shadow (another module also
|
||
> declares that name), it picks the WRONG one. In the repro the user's
|
||
> `Event` enum collides with the stdlib `library/modules/std/event.sx`
|
||
> `Event :: struct` (pulled in by `#import "modules/std.sx"`, std.sx:101,
|
||
> namespaced as `event`). The protocol grabs the stdlib struct; the
|
||
> annotation path grabs the user enum. Hence inferred fails, annotated
|
||
> works.
|
||
|
||
## Symptom
|
||
|
||
One-line: a protocol (dynamic-dispatch) method whose declared parameter
|
||
or return type NAME also exists in another module resolves to the wrong
|
||
type, because protocol signature registration is not visibility-aware.
|
||
|
||
- **Observed (repro):** `error: enum literal '.escape' has no destination
|
||
type to resolve against` on `if e.key == .escape { ... }`, where `e` is
|
||
the payload bound by `case .key_up: (e)` on a value whose type was
|
||
inferred from a protocol method returning `Event`.
|
||
- **Why that error:** the protocol method's cached `ret_type` is the
|
||
stdlib `Event` **struct** (empty of the user's variants), not the
|
||
user's `Event` **tagged_union**. So `ev := g_plat.one_event()` types
|
||
`ev` as a plain struct; the `case .key_up:(e)` match finds no
|
||
tagged-union variant, binds `e` to `.unresolved`; `e.key` on an
|
||
`.unresolved` object silently returns a placeholder (the cascade guard
|
||
in `lower.zig:emitFieldError` suppresses the field error on
|
||
`.unresolved`); so `.escape` then has an `.unresolved` destination and
|
||
emits the reported diagnostic.
|
||
- **Expected:** bare `Event` inside the protocol resolves to the user's
|
||
own `Event` (the visibility-correct author), exactly as an explicit
|
||
`ev : Event = …` annotation already does. The repro then prints
|
||
`escape!`, exit 0.
|
||
|
||
Surfaced building the downstream `m3te` app at `main.sx:222` —
|
||
`for g_plat.poll_events() (*ev) { … case .key_up: (e) { if e.key == .escape … } }`,
|
||
where `g_plat : Platform` is a `modules/platform/api.sx` protocol and
|
||
`poll_events :: () -> []Event` returns `ui.Event`. m3te imports `std`
|
||
(which carries the namespaced `event.Event` struct) AND has its own
|
||
`ui.Event`, so the protocol's flat lookup picks the wrong `Event` — the
|
||
same collision as the minimal repro.
|
||
|
||
## Reproduction
|
||
|
||
Minimal, standalone (only depends on `modules/std.sx`). The trigger is
|
||
the type NAME `Event` colliding with `std/event.sx`'s `Event` struct:
|
||
|
||
```sx
|
||
#import "modules/std.sx";
|
||
|
||
Keycode :: enum { unknown; escape; enter; }
|
||
KeyData :: struct { key: Keycode; }
|
||
Event :: enum { none; key_up: KeyData; } // <-- name collides with std/event.sx `Event :: struct`
|
||
|
||
Plat :: protocol { one_event :: () -> Event; }
|
||
|
||
Impl :: struct { dummy: i64; }
|
||
impl Plat for Impl {
|
||
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
|
||
}
|
||
|
||
main :: () {
|
||
impl : Impl = .{ dummy = 0 };
|
||
g_plat : Plat = xx @impl;
|
||
ev := g_plat.one_event(); // type INFERRED from protocol return
|
||
if ev == {
|
||
case .key_up: (e) {
|
||
if e.key == .escape { print("escape!\n"); } // <-- errors here
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `./zig-out/bin/sx run issues/0132-protocol-return-enum-case-payload-field-unresolved.sx`
|
||
|
||
Actual:
|
||
```
|
||
error: enum literal '.escape' has no destination type to resolve against
|
||
--> ...:NN:NN
|
||
|
|
||
| if e.key == .escape { print("escape!\n"); }
|
||
| ^^^^^^^
|
||
```
|
||
|
||
### Decisive bisection (verified)
|
||
|
||
| Variant | Result | Why |
|
||
|---|---|---|
|
||
| Repro as above (name `Event`, inferred) | **FAILS** | protocol flat-resolves `Event` → stdlib `event.Event` struct (104) |
|
||
| Rename the user type `Event` → `Evt` everywhere | **OK** (`escape!`) | no same-name shadow → flat lookup gets the only `Evt` |
|
||
| Keep `Event` but annotate `ev : Event = g_plat.one_event()` | **OK** | annotation uses the visibility-aware `resolveNominalLeaf` → user enum (152) |
|
||
| Concrete fn (non-protocol) returns `Event`, same body | **OK** | concrete fn signatures already resolve via `self.resolveType` (visibility-aware) |
|
||
| Protocol returns a plain struct / a plain enum named `Event` | varies | same root cause: flat lookup picks the colliding author |
|
||
|
||
Ground-truth TypeIds (from an instrumented build): the protocol method's
|
||
`ret_type` = **104** (`tag=struct name=Event`, the stdlib placeholder);
|
||
the annotation resolves `Event` = **152** (`tag=tagged_union name=Event`,
|
||
the user type with the `key_up → KeyData` payload). Two distinct authors
|
||
of the name `Event`; the flat path picks 104, the visibility-aware path
|
||
picks 152.
|
||
|
||
## Fix
|
||
|
||
Make protocol method signature registration visibility-aware, mirroring
|
||
what concrete functions and `registerStructDecl` already do.
|
||
|
||
In `src/ir/protocols.zig` `registerProtocolDecl` (~lines 289–316), pin
|
||
the visibility context to the protocol's declaring module and resolve
|
||
through the source-aware helpers instead of the flat resolver:
|
||
|
||
- param types: `self.l.resolveParamTypeInSource(pd.source_file, p)`
|
||
(keep the `Self → *void` special-case)
|
||
- return type: `self.l.resolveTypeInSource(pd.source_file, rt)`
|
||
(keep the `Self → *void` special-case)
|
||
|
||
Both helpers already exist (`src/ir/lower.zig:670` / `:684`) and are the
|
||
exact tool for "resolve a type in its DEFINING module's visibility
|
||
context". `ProtocolDecl.source_file` is already stamped by
|
||
`resolveImports` for this purpose (`src/ast.zig:817`). The
|
||
**parameterized**-protocol path (`instantiateParamProtocol`,
|
||
`src/ir/lower/protocol.zig:119`) ALREADY does exactly this (pins
|
||
`current_source_file = pd.source_file` and resolves via
|
||
`resolveTypeWithBindings`); this change brings the NON-parameterized path
|
||
to parity.
|
||
|
||
No silent default is introduced: the visibility-aware path emits real
|
||
diagnostics for genuinely not-visible / ambiguous names and poisons with
|
||
`.unresolved` (per CLAUDE.md "Silent fallback defaults" rules).
|
||
|
||
## Broader latent risk (same class — track separately)
|
||
|
||
The same visibility-unaware flat resolution at REGISTRATION time also
|
||
affects **enum payloads** and **union field types** (CONFIRMED failing),
|
||
because `registerEnumDecl` / `registerUnionDecl` build their bodies via
|
||
the stateless `type_bridge.buildEnumInfo` / `buildUnionInfo`, which
|
||
flat-resolve type names. Repro shape (confirmed):
|
||
|
||
```sx
|
||
#import "modules/std.sx";
|
||
Event :: struct { code: i64; } // collides with std/event.sx Event
|
||
Wrap :: enum { none; got: Event; } // payload type Event → flat-resolves to the WRONG Event
|
||
main :: () {
|
||
w : Wrap = .{}; w = .got(.{ code = 7 });
|
||
if w == { case .got: (e) { print("{}\n", e.code); } } // error: field 'code' not found on type 'Event'
|
||
}
|
||
```
|
||
|
||
(Structs are already SAFE — `registerStructDecl` resolves fields via the
|
||
visibility-aware `self.resolveType`, `src/ir/lower/nominal.zig:637`.)
|
||
|
||
Suggested broader fix: inject a resolver into `buildEnumInfo` /
|
||
`buildUnionInfo` (an `anytype` adapter with a `resolve(node) → TypeId`
|
||
method) so the stateless inline callers keep the flat resolver while the
|
||
stateful `registerEnumDecl` / `registerUnionDecl` pass a
|
||
`self.resolveType`-backed (visibility-aware) one — single source of truth
|
||
for the body shape, two resolution strategies. Also switch the struct-
|
||
constant annotation resolve (`src/ir/lower/nominal.zig:706`) to
|
||
`self.resolveType`. See the session notes for the full design.
|
||
|
||
## Verification
|
||
|
||
`./zig-out/bin/sx run issues/0132-…sx` prints `escape!` exit 0; then
|
||
`zig build && zig build test` and `bash tests/run_examples.sh` all green.
|
||
When resolved, promote the repro to
|
||
`examples/04xx-protocols-protocol-return-name-collision.sx` per the
|
||
"Resolving an open issue" procedure.
|
||
|
||
## Notes
|
||
|
||
- Diagnostic site (where the symptom surfaces, NOT the root cause):
|
||
`src/ir/lower/expr.zig:920` (`lowerEnumLiteral`, `target == .unresolved`
|
||
branch).
|
||
- Root cause site: `src/ir/protocols.zig:299,309`
|
||
(`registerProtocolDecl`, flat `type_bridge.resolveAstType` for
|
||
param/return types).
|
||
- The minimal repro previously used a legacy `Impl_methods :: { … }`
|
||
block; that compiles but crashes at runtime independently. The repro
|
||
here uses the canonical `impl Plat for Impl { … }` so that, post-fix,
|
||
it actually runs and prints `escape!`.
|
||
- Workaround in downstream code (annotate the binding, or rename the
|
||
type to avoid the std collision) is NOT applied in m3te per the
|
||
IMPASSABLE RULES — the fix belongs in the compiler.
|
||
|
||
---
|
||
|
||
## Original hypothesis (SUPERSEDED — kept for provenance)
|
||
|
||
The first write-up framed this as: "when an enum value's type is inferred
|
||
from a protocol method's declared return, a `case`-payload binding loses
|
||
its struct-field types", and pointed the fix at the call-result TypeId in
|
||
`src/ir/calls.zig` / `src/ir/conversions.zig` "not carrying the variant
|
||
payload struct's field types". The instrumented trace disproved this: the
|
||
inferred and annotated `Event` are two DIFFERENT registered types (a
|
||
same-name shadow), and the divergence is purely that protocol signature
|
||
registration uses a flat, visibility-unaware lookup. The payload-field
|
||
machinery is fine once the correct `Event` reaches the binding.
|
||
|
||
## Follow-up (2026-06-13)
|
||
|
||
The broader-latent union case this fix enabled — a colliding-name union
|
||
member now resolving to the correct type — was further blocked at codegen by
|
||
a separate bug: assigning a struct literal to a union member lowered as
|
||
`.unresolved` ([issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
|
||
now RESOLVED, which in turn required
|
||
[issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md)).
|
||
With both fixed, the union-member-via-struct-literal path is demonstrable
|
||
end-to-end (`examples/0184-types-union-member-struct-literal-assign.sx`).
|