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.
11 KiB
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-UNAWAREtype_bridge.resolveAstType, so a name colliding across modules (the user'sEventenum vs the stdlibevent.Eventstruct) bound to the wrong author. Fix: resolve both throughself.l.resolveTypeInSource(pd.source_file, …)— the visibility-aware stateful resolver pinned to the protocol's OWN declaring module — keeping theSelf → *voidshort-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 inf13f4ab; this commit closes the protocol-return case it left open. Regression test:examples/0417-protocols-protocol-return-name-collision.sx(printsescape!, 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:
registerProtocolDeclresolves method parameter/return type NAMES through a flat, visibility-UNAWARE lookup (type_bridge.resolveAstType→resolveNamed→ globalfindByName), 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'sEventenum collides with the stdliblibrary/modules/std/event.sxEvent :: struct(pulled in by#import "modules/std.sx", std.sx:101, namespaced asevent). 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 againstonif e.key == .escape { ... }, whereeis the payload bound bycase .key_up: (e)on a value whose type was inferred from a protocol method returningEvent. - Why that error: the protocol method's cached
ret_typeis the stdlibEventstruct (empty of the user's variants), not the user'sEventtagged_union. Soev := g_plat.one_event()typesevas a plain struct; thecase .key_up:(e)match finds no tagged-union variant, bindseto.unresolved;e.keyon an.unresolvedobject silently returns a placeholder (the cascade guard inlower.zig:emitFieldErrorsuppresses the field error on.unresolved); so.escapethen has an.unresolveddestination and emits the reported diagnostic. - Expected: bare
Eventinside the protocol resolves to the user's ownEvent(the visibility-correct author), exactly as an explicitev : Event = …annotation already does. The repro then printsescape!, 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:
#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 theSelf → *voidspecial-case) - return type:
self.l.resolveTypeInSource(pd.source_file, rt)(keep theSelf → *voidspecial-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):
#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 == .unresolvedbranch). - Root cause site:
src/ir/protocols.zig:299,309(registerProtocolDecl, flattype_bridge.resolveAstTypefor 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 canonicalimpl Plat for Impl { … }so that, post-fix, it actually runs and printsescape!. - 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,
now RESOLVED, which in turn required
issue 0135).
With both fixed, the union-member-via-struct-literal path is demonstrable
end-to-end (examples/0184-types-union-member-struct-literal-assign.sx).