lang: generic struct head aliases bind the template (fix 0120) — alias-follow from each author's source in head selection; loud unknown-type on the .call type tail

BoxAlias :: Box; / Box :: r.Box; now resolve instantiation, methods,
annotations, and chains through the aliased template, and re-export one
flat-import level as ordinary own decls (the facade shape the std.sx
restructure needs). selectGenericStructHead consults aliasedStructTemplate
(nominal.zig) before the global template map — own-wins/single-flat alias
author, each hop pinned to the alias author's source, ns.X RHS through
namespaceAliasVerdictFrom, depth-capped. resolveTypeCallWithBindings'
silent .unresolved tail (panicked in LLVM emission) now diagnoses
"unknown type". Also aligns the stale pre-existing calls.test.zig UFCS
plan test with the opt-in model (a47ea14). Regression: examples/0211
(+rich/+facade). Gates: zig build test 426/426, suite 587/587.
This commit is contained in:
agra
2026-06-11 18:09:01 +03:00
parent 51194a26d8
commit f2db8ecc53
13 changed files with 382 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
// Companion of 0211: the re-export facade — own alias decls over another
// module's members. Flat importers of THIS file see the aliases bare.
#import "modules/std.sx";
r :: #import "0211-generics-struct-alias-head-rich.sx";
helper :: r.helper;
Thing :: r.Thing;
Box :: r.Box;

View File

@@ -0,0 +1,15 @@
// Companion of 0211: the authoring module — a plain fn, a plain struct,
// and a generic struct, all re-exported by -facade.sx via alias decls.
#import "modules/std.sx";
helper :: () -> s64 { 7 }
Thing :: struct {
v: s64;
init :: () -> Thing { Thing.{ v = 42 } }
}
Box :: struct ($T: Type) {
item: T;
get :: (b: *Box(T)) -> T { b.item }
}

View File

@@ -0,0 +1,43 @@
// Generic-struct head aliases: `BoxAlias :: Box;` binds the alias to the
// SAME template — instantiation, methods, annotations, and alias chains all
// resolve through it. Cross-module, a facade's `Box :: r.Box;` re-export is
// the facade's OWN declaration, so it carries one flat-import level exactly
// like a plain-struct alias (companion files: -rich.sx authors the decls,
// -facade.sx re-exports them through a namespace alias).
// Regression (issue 0120): the alias head used to lower silently to an
// unresolved type and panic in the LLVM backend at instantiation.
#import "modules/std.sx";
#import "0211-generics-struct-alias-head-facade.sx";
LocalBox :: struct ($T: Type) {
item: T;
get :: (b: *LocalBox(T)) -> T { b.item }
}
LocalAlias :: LocalBox;
ChainAlias :: LocalAlias;
main :: () {
// Same-file alias: instantiation + field + method.
b := LocalAlias(s64).{ item = 3 };
print("field: {}\n", b.item);
print("method: {}\n", b.get());
// Alias chain terminates at the template.
c := ChainAlias(s64).{ item = 11 };
print("chain: {}\n", c.item);
// Alias as a type annotation head.
a : LocalAlias(string) = .{ item = "ann" };
print("annot: {}\n", a.item);
// Cross-module re-exports carried one flat hop from the facade:
// plain fn, plain struct (static method), and the generic head.
print("helper: {}\n", helper());
t := Thing.init();
print("thing: {}\n", t.v);
f := Box(s64).{ item = 7 };
print("facade: {}\n", f.get());
x : Box(string) = .{ item = "qq" };
print("facade-annot: {}\n", x.item);
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@
field: 3
method: 3
chain: 11
annot: ann
helper: 7
thing: 42
facade: 7
facade-annot: qq

View File

@@ -0,0 +1,161 @@
# 0120 — aliasing a GENERIC struct head: silent `.unresolved`, backend panic
> **RESOLVED** (2026-06-11, same session — Agra-directed fix). Root
> cause: a const alias of a generic struct head was registered nowhere
> (`type_alias_map` holds TypeIds, `struct_template_map` only direct
> struct decls), and the head selector's miss fell through as
> `.not_generic`; the `.call`-node type resolver then returned
> `.unresolved` SILENTLY (its parameterized sibling diagnosed; it
> didn't). Fix, option 1 (support): `selectGenericStructHead` now
> follows const-alias decls (`aliasedStructTemplate` in
> `src/ir/lower/nominal.zig`) — own-wins / single-flat author, each hop
> resolved from the ALIAS AUTHOR's source (`namespaceAliasVerdictFrom`
> for `ns.X` RHS), depth-capped against cycles, checked BEFORE the
> template map so a facade's same-name re-export beats an invisible
> global template. Plus the missing diagnostic: an unknown `.call` type
> head now errors "unknown type 'X'" instead of silently poisoning
> (`resolveTypeCallWithBindings`). Alias-vs-alias flat collisions stay
> loud (not-visible diagnostic). Still unsupported, by scope:
> `ns.AliasName(..)` qualified heads (namespace member that is itself
> an alias). Regression test:
> `examples/0211-generics-struct-alias-head.sx` (+ `-rich.sx` /
> `-facade.sx` companions; pins same-file alias, method, chain,
> annotation, and the cross-module facade re-export). Gates: zig build
> test 426/426 (incl. fixing the PRE-EXISTING stale
> `calls.test.zig` UFCS plan test that predated 0119's opt-in model),
> suite 587/587.
## Symptom
`Alias :: Box;` where `Box` is a generic struct (`struct ($T: Type)`)
lowers without any diagnostic, and instantiating through the alias
(`Alias(s64).{ ... }`) reaches LLVM emission with an `.unresolved`
type — the backend tripwire panics:
```
panic: unresolved type reached LLVM emission — a type resolution
failure was not diagnosed/aborted
src/backend/llvm/types.zig:175 toLLVMTypeInfo
src/backend/llvm/ops.zig:1204 emitStructInit
```
Observed (one probe family, three manifestations of the same root):
- field access through the aliased instantiation → **backend panic**
(no front-end diagnostic at all);
- method call through the aliased instantiation (`b.get()`) →
misleading `unresolved 'get'` (the receiver's type never resolved);
- cross-module re-export (`facade.sx`: `Box :: r.Box;`, consumer
flat-imports facade) → consumer gets `type 'Box' is not visible;
#import the module that declares it` even though the alias is the
facade's OWN declaration.
Expected: one of the two, decided explicitly —
1. **Support it** (desirable): a const decl whose RHS names a generic
struct head (bare `Box` or qualified `r.Box`) binds the alias to the
SAME template; instantiation, methods, and one-level flat-import
carry behave exactly as the non-generic struct alias already does.
2. **Reject it loudly**: a decl-site diagnostic ("cannot alias a
generic struct head" or similar) at `Alias :: Box;`.
Silently lowering and panicking in the backend is neither — it is the
REJECTED-PATTERNS "silent unresolved" shape.
For contrast, both of these alias re-exports already WORK across one
flat-import hop (own-decl visibility): `helper :: r.helper;` (plain
fn) and `Thing :: r.Thing;` (non-generic struct, including its static
`init`). Only the generic head breaks. A fix must not regress these.
## Reproduction
Backend panic (primary):
```sx
#import "modules/std.sx";
Box :: struct ($T: Type) {
item: T;
}
BoxAlias :: Box;
main :: () {
b := BoxAlias(s64).{ item = 3 };
print("{}\n", b.item);
}
```
Method-call variant (front-end `unresolved 'get'`, same root):
```sx
#import "modules/std.sx";
Box :: struct ($T: Type) {
item: T;
get :: (b: *Box(T)) -> T { b.item }
}
BoxAlias :: Box;
main :: () {
b := BoxAlias(s64).{ item = 3 };
print("{}\n", b.get());
}
```
Cross-module variant (`rich.sx` declares `Box`; `facade.sx` has
`r :: #import "rich.sx"; Box :: r.Box;`; a consumer flat-importing
facade.sx gets `type 'Box' is not visible` at `Box(s64).{ ... }`).
## Investigation prompt
Generic structs live as TEMPLATES in
`src/ir/program_index.zig``struct_template_map`
(`StringHashMap(StructTemplate)`, registered by `registerStructDecl`;
a parallel `struct_template_by_decl` exists but isn't read for
selection yet). Instantiation resolves the head name against that map
in `src/ir/lower/nominal.zig` (see the qualified-head comments around
nominal.zig:357382) and monomorphizes via
`lower_generic.instantiateGenericStruct` (re-exported at
`src/ir/lower.zig:1820`).
`BoxAlias :: Box;` is a const decl whose RHS identifier names a
template, not a value or a concrete Type — const-decl lowering neither
registers `BoxAlias` as a template alias nor rejects the decl. The
instantiation head lookup for `BoxAlias` then misses, and the
`Name(args).{ ... }` path continues with an `.unresolved` struct type
instead of diagnosing the miss — that silent continuation is the bug
underneath all three manifestations, and fixing it is step one
regardless of the language decision: a struct_init whose head fails to
resolve must produce a hard diagnostic, never reach emission.
Then the language decision (confirm with Agra if option 2 is ever
preferred; the motivating use case wants option 1): when a const
decl's RHS resolves to a generic struct head — bare identifier or
`ns.X` through a namespace alias — register the alias name in the
template registry bound to the same `StructTemplate`, scoped to the
declaring module with ordinary own-decl visibility so one-level
flat-import carry works (mirror whatever makes `Thing :: r.Thing;`
re-export correctly today). Mind collision semantics (own-wins /
ambiguity) and that the alias must also work as a plain type head in
annotations (`x: BoxAlias(s64)`), nested generics
(`List(BoxAlias(s64))` if applicable), and method/UFCS dispatch on
instantiations through the alias.
Motivating context: the std.sx-as-pure-re-exports restructure wants
`List :: list.List;` in `modules/std.sx` (with `list :: #import
"modules/std/list.sx";`) so `List` stays bare-visible to std.sx's flat
importers. Plain fns and plain structs already re-export this way;
generic heads are the missing piece.
Verification:
1. Primary repro: prints `3`, exit 0 (option 1) — or a clean decl-site
diagnostic, no panic (option 2).
2. Matrix: method-call variant runs (`b.get()` → 3); cross-module
variant runs through the facade; `helper :: r.helper;` and
`Thing :: r.Thing;` re-exports unchanged; two facades carrying the
same alias name still diagnose ambiguity.
3. `bash tests/run_examples.sh` — full suite ok, zero failures.
4. Pin the repro as a regression example per CLAUDE.md.

View File

@@ -483,6 +483,25 @@ Carried aliases follow declaration rules: an own declaration shadows a carried
alias, two flat imports carrying the same alias make its use ambiguous, and
carry does not chain through a second flat hop.
**Re-exporting through alias declarations.** Since visibility never chains,
a facade re-exports another module's members as its OWN declarations —
ordinary aliases, which its direct flat importers then see bare. This works
for functions, plain types, and generic struct heads alike (the generic
alias binds the same template, so instantiation and methods resolve
through it):
```sx
// facade.sx
r :: #import "rich.sx";
helper :: r.helper; // fn re-export
Thing :: r.Thing; // struct re-export
Box :: r.Box; // generic head re-export — same template
// consumer.sx
#import "facade.sx";
b := Box(s64).{ item = 3 }; // rich.sx's Box, via the facade
```
### Implicit Context
Every program gets an implicit `context` with a default allocator:

View File

@@ -1251,6 +1251,25 @@ A name bound to an existing type.
SOME_TYPE :: f64;
```
A generic struct HEAD can be aliased too — the alias binds to the same
template, so instantiation, methods, annotations, and alias chains resolve
through it:
```sx
Box :: struct ($T: Type) { item: T; }
BoxAlias :: Box; // same template
b := BoxAlias(s64).{ item = 3 };
b2 : BoxAlias(string) = .{ item = "x" }; // annotation head too
```
The RHS may be a namespace member (`Box :: r.Box;`) — the alias is an
ordinary OWN declaration of the aliasing file, so it is visible to that
file's direct flat importers like any other declaration (this is how a
facade re-exports another module's generic struct). Each hop of an alias
chain resolves with the visibility of the file that declares THAT hop,
not the use site's. Not yet supported: a qualified head whose namespace
member is itself an alias (`ns.BoxAlias(..)`).
### Generic Functions (Monomorphization)
Functions can be parameterized over types using `$T` syntax. The `$` prefix introduces a type parameter; subsequent uses of the name reference it.
```sx

View File

@@ -429,14 +429,17 @@ test "plan: free-function UFCS prepends receiver, distinct from namespace_fn" {
var l = Lowering.init(&module);
const cr = CallResolver{ .l = &l };
// struct Counter, and a FREE function `bump :: (c: Counter) -> s32` — NOT
// registered as `Counter.bump`, so it can only be reached via UFCS.
// struct Counter, and a FREE ufcs function `bump :: ufcs (c: Counter) ->
// s32` — NOT registered as `Counter.bump`, so it can only be reached via
// UFCS. Dot-dispatch is OPT-IN: the fn carries `is_ufcs` and is
// registered in `fn_ast_map`, where the plan's opt-in gate reads it.
const counter = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Counter"), .fields = &.{} } });
const c_param = ast.Param{ .name = "c", .name_span = .{ .start = 0, .end = 0 }, .type_expr = typeExpr(alloc, "Counter") };
const params = [_]ast.Param{c_param};
const ret_stmt = mk(alloc, .{ .return_stmt = .{ .value = intLit(alloc, 7) } });
const body = mk(alloc, .{ .block = .{ .stmts = &[_]*Node{ret_stmt} } });
const fd = ast.FnDecl{ .name = "bump", .params = &params, .return_type = typeExpr(alloc, "s32"), .body = body };
const fd = ast.FnDecl{ .name = "bump", .params = &params, .return_type = typeExpr(alloc, "s32"), .body = body, .is_ufcs = true };
l.program_index.fn_ast_map.put("bump", &fd) catch unreachable;
l.lowerFunction(&fd, "bump", false);
const fid = l.resolveFuncByName("bump").?;
module.functions.items[@intFromEnum(fid)].has_implicit_ctx = true;

View File

@@ -1186,8 +1186,16 @@ pub const Lowering = struct {
/// edges of flat edges do not chain). Two distinct carried targets for
/// the same alias are ambiguous.
pub fn namespaceAliasVerdict(self: *Lowering, alias: []const u8) AliasVerdict {
const edges = self.program_index.namespace_edges orelse return .none;
const from = self.current_source_file orelse return .none;
return self.namespaceAliasVerdictFrom(alias, from);
}
/// `namespaceAliasVerdict` with an explicit querying source — for callers
/// resolving an alias on behalf of ANOTHER module (e.g. following a const
/// alias decl whose RHS is `ns.X`: `ns` binds in the alias author's file,
/// not the use site's).
pub fn namespaceAliasVerdictFrom(self: *Lowering, alias: []const u8, from: []const u8) AliasVerdict {
const edges = self.program_index.namespace_edges orelse return .none;
if (edges.getPtr(from)) |own| {
if (own.get(alias)) |t| return .{ .target = t };
}
@@ -1679,6 +1687,7 @@ pub const Lowering = struct {
pub const rawNamedTypePtr = lower_nominal.rawNamedTypePtr;
pub const buildGenericStructTemplate = lower_nominal.buildGenericStructTemplate;
pub const qualifiedStructTemplate = lower_nominal.qualifiedStructTemplate;
pub const aliasedStructTemplate = lower_nominal.aliasedStructTemplate;
pub const qualifiedMemberMissing = lower_nominal.qualifiedMemberMissing;
pub const bareVisibleStructDecl = lower_nominal.bareVisibleStructDecl;
pub const bareVisibleStructTemplate = lower_nominal.bareVisibleStructTemplate;

View File

@@ -974,6 +974,17 @@ pub fn selectGenericStructHead(self: *Lowering, name: []const u8, alias: ?[]cons
if (self.program_index.struct_template_map.getPtr(name)) |tmpl| return .{ .template = tmpl.* };
return .not_generic;
}
// Const-alias head (`BoxAlias :: Box;` / `Box :: r.Box;`, issue 0120):
// follow the alias decl hop-by-hop to its authoring template, each hop
// resolved from that alias author's own source. Checked BEFORE the map:
// the alias may share its name with a same-name template that is NOT
// visible from here (a facade's `Box :: r.Box;` re-export of rich's
// `Box`), and the map branch would poison on that invisible author.
// Only fires when the single visible author (own-wins / single-flat)
// IS an alias-shaped const decl, so real template heads are untouched.
if (self.current_source_file) |from| {
if (self.aliasedStructTemplate(name, from)) |t| return .{ .template = t };
}
if (self.program_index.struct_template_map.getPtr(name)) |tmpl| {
if (self.headTypeLeak(name, span)) return .poisoned;
if (self.bareVisibleStructTemplate(name)) |vt| return .{ .template = vt };
@@ -1230,7 +1241,14 @@ pub fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId
}
// Try as a named type
const name_id = self.module.types.internString(callee_name);
return self.module.types.findByName(name_id) orelse .unresolved;
if (self.module.types.findByName(name_id)) |t| return t;
// The callee names no known type constructor — not Vector, not a generic
// struct template (or alias), not a type-returning function, not a named
// type. A silent `.unresolved` here reaches LLVM emission as a panic;
// diagnose and poison (the parameterized sibling below already does).
if (self.diagnostics) |d|
d.addFmt(.err, cl.callee.span, "unknown type '{s}'", .{callee_name});
return .unresolved;
}
/// Resolve a parameterized type expr, substituting bindings for type/value params.

View File

@@ -394,6 +394,77 @@ pub fn qualifiedMemberMissing(self: *Lowering, alias: []const u8, member: []cons
return true;
}
/// The `*ConstDecl` a raw author wraps when it is a const ALIAS of another
/// name — `BoxAlias :: Box;` (identifier RHS) or `Box :: r.Box;` (namespace-
/// member RHS). Null for every other shape, including const-wrapped struct /
/// fn DEFINITIONS, which are authors in their own right.
fn constAliasOfRaw(ref: resolver_mod.RawDeclRef) ?*const ast.ConstDecl {
return switch (ref) {
.const_decl => |cd| switch (cd.value.data) {
.identifier, .field_access => cd,
else => null,
},
else => null,
};
}
/// The single author of `name` as seen from `from` — own wins, else exactly
/// one flat-import author. Null when absent or when ≥2 flat authors compete
/// (the use site then diagnoses the unresolved head; no silent pick).
fn singleVisibleAuthor(self: *Lowering, name: []const u8, from: []const u8) ?resolver_mod.RawAuthor {
var res = self.resolver();
const set = res.collectVisibleAuthors(name, from, .user_bare_flat);
defer if (set.flat.len > 0) self.alloc.free(set.flat);
if (set.own) |o| return o;
if (set.flat.len == 1) return set.flat[0];
return null;
}
/// Resolve `name`, as seen from `from`, to a generic-struct template by
/// following const ALIAS declarations (issue 0120). Entry for the head
/// selector's bare tail: the FIRST hop must be alias-shaped — a direct
/// struct author is the template map's business, never this path's. Each
/// hop resolves from the ALIAS AUTHOR's source, so visibility is the
/// author's, not the use site's (a consumer one flat hop from a facade
/// reaches the facade's `Box :: r.Box;` without seeing `r` itself).
pub fn aliasedStructTemplate(self: *Lowering, name: []const u8, from: []const u8) ?StructTemplate {
const author = singleVisibleAuthor(self, name, from) orelse return null;
if (constAliasOfRaw(author.raw) == null) return null;
return followToTemplate(self, author, 8);
}
/// One alias hop: a generic-struct author terminates the chain with its
/// rebuilt source-pinned template; an alias author recurses on its RHS —
/// bare identifier from the author's own source, `ns.X` through the
/// author's namespace edge into the target module's own member. The depth
/// cap breaks alias cycles (`A :: B; B :: A;`).
fn followToTemplate(self: *Lowering, author: resolver_mod.RawAuthor, depth: u8) ?StructTemplate {
if (depth == 0) return null;
if (structDeclOfRaw(author.raw)) |sd| {
if (sd.type_params.len == 0) return null;
return self.buildGenericStructTemplate(sd, author.source);
}
const cd = constAliasOfRaw(author.raw) orelse return null;
switch (cd.value.data) {
.identifier => |id| {
const next = singleVisibleAuthor(self, id.name, author.source) orelse return null;
return followToTemplate(self, next, depth - 1);
},
.field_access => |fa| {
if (fa.object.data != .identifier) return null;
const target = switch (self.namespaceAliasVerdictFrom(fa.object.data.identifier.name, author.source)) {
.target => |t| t,
.none, .ambiguous => return null,
};
var res = self.resolver();
const member_set = res.collectNamespaceAuthors(target, fa.field);
const member = member_set.own orelse return null;
return followToTemplate(self, member, depth - 1);
},
else => return null,
}
}
/// The bare-VISIBLE single generic-struct author of `name` (its `StructDecl` +
/// defining source) when that author is NOT the one the global last-wins
/// `struct_template_map` already holds — the E4 non-transitive selection for a