Files
sx/issues/0132-protocol-return-enum-case-payload-field-unresolved.md
agra d3f5cb20cb fix: visibility-aware type resolution for protocol method signatures
`registerProtocolDecl` resolved each method's param/return type NAME
through the flat, visibility-unaware `type_bridge.resolveAstType`, so a
type name colliding across modules bound to the wrong author. In the
repro the user's `Event` enum collides with the stdlib `event.Event`
struct (pulled in by `modules/std.sx`): the protocol grabbed the stdlib
struct, typed an inferred `g_plat.one_event()` as a fieldless struct,
bound the `case .key_up:(e)` payload to `.unresolved`, and emitted
"enum literal '.escape' has no destination type to resolve against".

Resolve both param and return types through
`resolveTypeInSource(pd.source_file, …)` — the visibility-aware resolver
pinned to the protocol's own declaring module, keeping the `Self → *void`
short-circuit. Brings the non-parameterized path to parity with
`instantiateParamProtocol` and concrete-fn signatures. No silent default:
not-visible / ambiguous names still diagnose and poison with `.unresolved`.

Closes issue 0132 — the protocol-return case left open by f13f4ab (which
fixed the enum/union/inline/error-set registration class). Regression
test: examples/0417-protocols-protocol-return-name-collision.sx.
2026-06-13 15:44:11 +03:00

221 lines
10 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.