fix(stdlib/E4): own-wins at non-leaf bare-type sites + type-fn head ambiguity

attempt-6: address Adi's two in-scope findings (#3 deferred to E6).

#1 E4-own-author-type-arg (silent-wrong): the bare-TYPE gate returned
`.proceed` for the querying source's OWN author, so the non-leaf sites
(reflection / type-arg / array-literal / type-value / match arm) dropped it
and re-resolved a same-name flat import via global `findByName`. headTypeGate
now resolves the own author to ITS per-source TypeId (mirroring
selectNominalLeaf's own-wins, 0754); the type-as-value and type-match sites,
which only consumed the poison bit and re-resolved globally, now route through
the gate and use the `.resolved` author. size_of(Widget) with an own + imported
Widget now yields main's own size, not the import's.

#2 E4-type-fn-head-ambiguity (silent-wrong): headFnLeak only checked
isNameVisible, so two flat same-name type-returning functions both reported
"visible" and one was silently instantiated. It now diagnoses >=2 distinct
direct flat type-fn authors (no own author) as ambiguous before the
isNameVisible short-circuit, consistent with the parameterized struct /
protocol heads and the leaf (0755/0767). Own / single / diamond-collapse
type-fn heads still resolve.

Regressions: 0768 (own-wins at every non-leaf bare-type site, fail-before
reflection=16 -> pass-after 8) and 0769 (two flat Make type-fns -> ambiguity
diagnostic exit 1). README: own-wins + type-fn-head ambiguity at every bare-type
site.
This commit is contained in:
agra
2026-06-08 15:22:10 +03:00
parent 382f78f49b
commit cb9ef381b5
13 changed files with 203 additions and 31 deletions

View File

@@ -0,0 +1,44 @@
// Own-wins (0754 / issue 0105 case 3) holds at EVERY non-leaf bare-type site,
// not just the nominal leaf annotation. `main` flat-imports `dep.sx` (which
// authors a same-name `Widget { a, b }` = 16 bytes and `Nums :: [2]s32` = 8
// bytes) AND authors its OWN `Widget { a }` = 8 bytes and `Nums :: [2]s64` = 16
// bytes. Each bare reference below must resolve to MAIN's own author — the gate's
// source-keyed `.resolved` author — NOT whichever same-name flat import a global
// `findByName` / `struct_template_map` pick would return:
//
// - reflection / type-arg slot `size_of(Widget)` → 8 (own)
// - typed array-literal head `Nums.[…]` / size_of → 16 (own [2]s64)
// - type-as-value `t : Type = Widget`
// - type-category match arm `case Widget:` → own identity
//
// Regression (Phase E4 attempt-6, finding #1): before the bare-type gate carried
// the OWN author's source-keyed TypeId into the non-leaf sites, an own author
// produced `.proceed` and these sites fell through to a global `findByName` — so
// `size_of(Widget)` printed the IMPORTED type's 16 and `case Widget:` matched the
// imported nominal identity (describe → 222). `dep_sizes` proves dep's distinct
// types still resolve to THEIR own 16 + 8 = 24 inside dep.
#import "modules/std.sx";
#import "0768-modules-own-wins-nonleaf-bare-type/dep.sx";
Widget :: struct { a: s64; }
Nums :: [2]s64;
describe :: ($T: Type) -> s32 {
r := if T == {
case Widget: 111;
else: 222;
}
r
}
main :: () -> s32 {
print("reflection={}\n", size_of(Widget));
xs := Nums.[1, 2];
print("array={}\n", size_of(Nums));
t : Type = Widget;
print("typeval_eq={}\n", t == Widget);
print("match={}\n", describe(Widget));
print("dep={}\n", dep_sizes());
0
}

View File

@@ -0,0 +1,8 @@
// A flat-imported module authors its OWN `Widget { a, b }` (16 bytes) and
// `Nums :: [2]s32` (8 bytes). The importing file (`main`) ALSO authors a
// same-name `Widget { a }` (8 bytes) and `Nums :: [2]s64` (16 bytes) — its own
// authors must win at EVERY bare-type site there (own-wins, 0754), while this
// module's distinct types stay live via `dep_sizes`.
Widget :: struct { a: s64; b: s64; }
Nums :: [2]s32;
dep_sizes :: () -> s64 { return size_of(Widget) + size_of(Nums); }

View File

@@ -0,0 +1,21 @@
// A type-returning FUNCTION head (`Make(s64)` where `Make :: ($T) -> Type`) is
// NON-transitive AND ambiguity-checked, exactly like the parameterized generic-
// struct / protocol heads (0767) and the nominal leaf (0755). `main` flat-imports
// two modules that each author a same-name `Make` type-fn with a different body
// and authors none itself, so the bare `Make(s64)` head is a genuine collision —
// it must emit the LOUD "type 'Make' is ambiguous" diagnostic and poison, NEVER
// silently instantiate whichever single author `fn_ast_map` happens to hold.
//
// Regression (Phase E4 attempt-6, finding #2): before `headFnLeak` did bare-call
// ambiguity selection it only checked `isNameVisible` (both authors ARE visible),
// so two flat `Make` type-fns silently instantiated one author — `size_of(Make(s64))`
// printed 16 (a.sx's two-field body) at exit 0 instead of the ambiguity diagnostic.
#import "modules/std.sx";
#import "0769-modules-ambiguous-type-fn-head/a.sx";
#import "0769-modules-ambiguous-type-fn-head/b.sx";
main :: () -> s32 {
print("size={}\n", size_of(Make(s64)));
0
}

View File

@@ -0,0 +1,7 @@
// One of two flat-imported authors of a same-name type-returning function
// `Make :: ($T) -> Type`. Its body returns a two-field struct; b.sx's returns a
// one-field struct, so a bare `Make(s64)` head is a genuine collision the
// importing source cannot disambiguate.
Make :: ($T: Type) -> Type {
return struct { x: T; y: T; };
}

View File

@@ -0,0 +1,5 @@
// The second flat-imported author of same-name type-fn `Make`. Its distinct
// one-field body makes a bare `Make(s64)` head ambiguous against a.sx's.
Make :: ($T: Type) -> Type {
return struct { x: T; };
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,5 @@
reflection=8
array=16
typeval_eq=true
match=111
dep=24

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,5 @@
error: type 'Make' is ambiguous: it is declared in multiple flat-imported modules; qualify the reference or remove the duplicate import
--> examples/0769-modules-ambiguous-type-fn-head.sx:19:32
|
19 | print("size={}\n", size_of(Make(s64)));
| ^^^^

View File

@@ -0,0 +1 @@

View File

@@ -415,11 +415,14 @@ generic head) — is likewise not visible and is rejected (`type 'X' is not visi
wherever a bare type name is named — a value/field annotation, a reflection / wherever a bare type name is named — a value/field annotation, a reflection /
type-arg slot (`size_of(T)`, `size_of(*T)`), a typed array-literal head (`T.[…]`), type-arg slot (`size_of(T)`, `size_of(*T)`), a typed array-literal head (`T.[…]`),
a parameterized head (`Box(s64)`), or a type-as-value / type-match arm — not just a parameterized head (`Box(s64)`), or a type-as-value / type-match arm — not just
plain annotations. Ambiguity is enforced at every one of those sites too, exactly plain annotations. **Own-wins** holds at every one of those sites too, exactly like
like a bare call: a bare type that two or more flat imports each declare is a bare call: when the querying module declares its OWN same-name type, that bare
**ambiguous and rejected** (`type 'X' is ambiguous: it is declared in multiple reference resolves to ITS author — never a same-name flat import. Ambiguity is
flat-imported modules; qualify the reference or remove the duplicate import`) — never enforced at every one of those sites as well: a bare type (including a type-returning
a silent pick of one author. (A library's own *internal* type references still resolve: a generic function head) that two or more flat imports each declare — with no own author to
win — is **ambiguous and rejected** (`type 'X' is ambiguous: it is declared in
multiple flat-imported modules; qualify the reference or remove the duplicate
import`) — never a silent pick of one author. (A library's own *internal* type references still resolve: a generic
struct / pack fn / protocol body is instantiated in the module that defines it, so struct / pack fn / protocol body is instantiated in the module that defines it, so
e.g. `List(T).append`'s `alloc: Allocator` is visible there regardless of the call e.g. `List(T).append`'s `alloc: Allocator` is visible there regardless of the call
site.) site.)

View File

@@ -4115,13 +4115,21 @@ pub const Lowering = struct {
// (`x: Type = Vec4`), comparison (`x == Vec4`), and // (`x: Type = Vec4`), comparison (`x == Vec4`), and
// pack-arg / Any context (boxing happens at the // pack-arg / Any context (boxing happens at the
// consumer). // consumer).
// E4 single-hop visibility gate: a bare type name used as a VALUE // E4 single-hop visibility + ambiguity gate: a bare type name used
// (`x: Type = COnly`, `x == COnly`) reachable only over 2+ flat hops // as a VALUE (`x: Type = COnly`, `x == COnly`) reachable only over
// is not bare-visible either (consistent with annotations / 0763). // 2+ flat hops is not bare-visible (consistent with annotations /
// `headTypeLeak` fires only for a real type author unreachable from // 0763); ≥2 direct flat same-name authors are ambiguous (loud
// here; a value name / generic param / undeclared name falls through. // diagnostic, 0755/0767). A single source-keyed author — including
if (self.headTypeLeak(id.name, node.span)) break :blk self.emitPlaceholder(id.name); // the querying source's OWN author over a same-name flat import
// (own-wins, 0754) — resolves to ITS TypeId, NOT whichever same-name
// author a global `findByName` would pick. A value name / generic
// param / undeclared name → `.proceed`, falling through below.
const ty = blk_ty: { const ty = blk_ty: {
switch (self.headTypeGate(id.name, node.span)) {
.ambiguous, .not_visible => break :blk self.emitPlaceholder(id.name),
.resolved => |tid| break :blk_ty tid,
.proceed => {},
}
if (self.type_bindings) |tb| { if (self.type_bindings) |tb| {
if (tb.get(id.name)) |t| break :blk_ty t; if (tb.get(id.name)) |t| break :blk_ty t;
} }
@@ -5449,15 +5457,28 @@ pub const Lowering = struct {
.type_expr => |te| te.name, .type_expr => |te| te.name,
else => "", else => "",
}; };
// E4 single-hop visibility gate: a SPECIFIC 2-flat-hop type name in // E4 single-hop visibility + ambiguity gate: a SPECIFIC 2-flat-hop
// a type-match arm (`case COnly:`) is not bare-visible (consistent // type name in a type-match arm (`case COnly:`) is not bare-visible
// with annotations / 0763). A category keyword (`int`, `struct`, …) // (consistent with annotations / 0763); ≥2 direct flat same-name
// is not a type author anywhere, so the gate is a no-op for those. // authors are ambiguous (loud diagnostic, 0755/0767). A category
if (self.headTypeLeak(name, pat.span)) { // keyword (`int`, `struct`, …) is not a type author anywhere → the
arm_tag_values.append(self.alloc, &.{}) catch unreachable; // gate is a no-op (`.proceed`) and `resolveTypeCategoryTags` expands
continue; // it. A source-keyed specific TYPE author — including the querying
} // source's OWN author over a same-name flat import (own-wins, 0754) —
const tag_values = self.resolveTypeCategoryTags(name); // matches on ITS TypeId, NOT whichever same-name author a global
// `findByName` (inside `resolveTypeCategoryTags`) would pick.
const tag_values = switch (self.headTypeGate(name, pat.span)) {
.ambiguous, .not_visible => {
arm_tag_values.append(self.alloc, &.{}) catch unreachable;
continue;
},
.resolved => |tid| blk_tv: {
const tv = self.alloc.alloc(u64, 1) catch unreachable;
tv[0] = tid.index();
break :blk_tv tv;
},
.proceed => self.resolveTypeCategoryTags(name),
};
arm_tag_values.append(self.alloc, tag_values) catch unreachable; arm_tag_values.append(self.alloc, tag_values) catch unreachable;
for (tag_values) |tag| { for (tag_values) |tag| {
cases.append(self.alloc, .{ cases.append(self.alloc, .{
@@ -13969,8 +13990,22 @@ pub const Lowering = struct {
if (self.emitting_default_context) return .proceed; if (self.emitting_default_context) return .proceed;
if (self.program_index.module_decls == null or self.program_index.flat_import_graph == null) return .proceed; if (self.program_index.module_decls == null or self.program_index.flat_import_graph == null) return .proceed;
const from = self.current_source_file orelse return .proceed; const from = self.current_source_file orelse return .proceed;
// The querying source's OWN author binds through the existing path. // The querying source's OWN author wins outright (own-wins, 0754) — even
if (self.moduleTypeAuthor(from, name) != null) return .proceed; // against a same-name flat import. Resolve to ITS per-source TypeId so a
// NON-leaf bare-type site (reflection / type-arg / array-literal /
// type-value / match arm) uses the own author, NOT whichever same-name
// flat author a global `findByName` would pick. Mirrors
// `selectNominalLeaf`'s own-author arm. A not-yet-registered own author (a
// forward / self reference resolved mid-registration, or a generic
// template the head path instantiates) carries no concrete TypeId yet →
// `.proceed`, so the existing instantiation / stub path handles it.
if (self.moduleTypeAuthor(from, name)) |author| switch (author) {
.alias => |tid| return .{ .resolved = tid },
.named => |ref| {
if (self.namedRefTid(ref, name)) |tid| return .{ .resolved = tid };
return .proceed;
},
};
switch (self.flatTypeAuthorCount(name, from)) { switch (self.flatTypeAuthorCount(name, from)) {
.none => {}, .none => {},
.one => |tid| return .{ .resolved = tid }, .one => |tid| return .{ .resolved = tid },
@@ -13993,24 +14028,59 @@ pub const Lowering = struct {
return .not_visible; return .not_visible;
} }
/// Single-hop non-transitive visibility gate for an UNQUALIFIED type-returning /// Single-hop non-transitive visibility + ambiguity gate for an UNQUALIFIED
/// FUNCTION head used as a type (`Make(N, T)` where `Make :: ($K, $T) -> Type`). /// type-returning FUNCTION head used as a type (`Make(N, T)` where
/// A type-fn is a `fn_decl`, so its visibility is FUNCTION visibility /// `Make :: ($K, $T) -> Type`). A type-fn is a `fn_decl`, so its visibility is
/// (`isNameVisible`, the single-hop flat name set) — NOT the type-author model. /// FUNCTION visibility (`isNameVisible`, the single-hop flat name set) — NOT the
/// Emits + returns TRUE when the fn is authored somewhere but not reachable /// type-author model. Returns TRUE (loud diagnostic already emitted) when the
/// from the use site (a 2-flat-hop leak). A scope-local (mangled) type-fn is /// head is AMBIGUOUS (≥2 distinct direct flat same-name type-fn authors, no own
/// visible in its own scope and exempt; falls open when unwired / default- /// author — consistent with the parameterized struct / protocol heads and the
/// context. Diagnostic mirrors the type form (the head IS used as a type here). /// leaf, 0755/0767, never a silent `fn_ast_map` first-/last-wins pick) or
/// NOT-VISIBLE (authored somewhere but unreachable from the use site, a
/// 2-flat-hop leak). A scope-local (mangled) type-fn or the querying source's
/// OWN author wins outright (own-wins) and is exempt; falls open when unwired /
/// default-context. Diagnostic mirrors the type form (the head IS used as a type
/// here).
fn headFnLeak(self: *Lowering, name: []const u8, span: ?ast.Span) bool { fn headFnLeak(self: *Lowering, name: []const u8, span: ?ast.Span) bool {
if (self.emitting_default_context) return false; if (self.emitting_default_context) return false;
if (self.current_source_file == null) return false; const from = self.current_source_file orelse return false;
if (self.scope) |s| if (s.lookupFn(name) != null) return false; if (self.scope) |s| if (s.lookupFn(name) != null) return false;
// ≥2 distinct direct flat type-fn authors with no own author — a genuine
// collision the source cannot disambiguate. Diagnose loudly BEFORE the
// `isNameVisible` short-circuit, which would otherwise report "visible" and
// let the single `fn_ast_map[name]` author silently win.
if (self.flatFnAuthorAmbiguous(name, from)) {
if (self.diagnostics) |d|
d.addFmt(.err, span, "type '{s}' is ambiguous: it is declared in multiple flat-imported modules; qualify the reference or remove the duplicate import", .{name});
return true;
}
if (self.isNameVisible(name)) return false; if (self.isNameVisible(name)) return false;
if (self.diagnostics) |d| if (self.diagnostics) |d|
d.addFmt(.err, span, "type '{s}' is not visible; #import the module that declares it", .{name}); d.addFmt(.err, span, "type '{s}' is not visible; #import the module that declares it", .{name});
return true; return true;
} }
/// TRUE iff bare `name` has ≥2 DISTINCT direct flat-import authors that are
/// type-returning FUNCTIONS (`fn_decl`s — `fnDeclOfRaw` unwraps a const-wrapped
/// fn) and the querying source authors NONE itself. The querying source's OWN
/// author wins outright (own-wins), so an own author short-circuits to "not
/// ambiguous" — the existing single-author path instantiates it. Diamond
/// imports of the SAME author collapse in `collectVisibleAuthors`'s
/// author-identity de-dup, so two edges onto one type-fn are NOT ambiguous. The
/// type-fn ambiguity analogue of `flatTypeAuthorCount`'s `.ambiguous` for named
/// type / template heads.
fn flatFnAuthorAmbiguous(self: *Lowering, name: []const u8, from: []const u8) bool {
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 != null) return false; // own-wins
var fn_authors: usize = 0;
for (set.flat) |fa| {
if (fnDeclOfRaw(fa.raw) != null) fn_authors += 1;
}
return fn_authors >= 2;
}
/// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)). /// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)).
fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId { fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId {
// A namespaced callee (`ns.Box(..)`) is an explicit qualified reach and is // A namespaced callee (`ns.Box(..)`) is an explicit qualified reach and is