Files
sx/issues/0132-protocol-return-enum-case-payload-field-unresolved.md
agra 4d32a4d4fb fix: propagate union-member type to a struct-literal RHS
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.
2026-06-13 18:55:41 +03:00

232 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 289316), 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`).