fix(resolver): treat type aliases as bare-TYPE authors in both gate directions [stdlib E1 attempt-4]

R4: a type alias is a `const_decl`, not a named-type decl, so the bare-TYPE
visibility gate ignored aliases — a namespaced-only alias leaked bare (silent
empty-struct stub, no diagnostic) and a flat-visible alias was poisoned by an
invisible same-name named type. Unify both type-author kinds (named type AND
alias) behind one per-module predicate `moduleTypeAuthor`, returning the author
KIND so resolution is decoupled from `findByName` timing (a forward/self
reference like `next: *ArenaChunk`, unregistered mid-registration, is still
recognised as an author and falls to the legacy stub instead of a false
"not visible"). The leak detector `nameAuthoredAsTypeAnywhere` now also scans
`type_aliases_by_source`. Single source of truth across named types, top-level
aliases, and parameterized/type-fn aliases — leak side and false-rejection side.

Behavior-preserving for single-author names (full suite byte-identical, paths
normalized). Generic / parameterized-protocol / Vector / type-function heads
stay legacy (0210). Block-local `Name :: <type>` remains a value const under the
reserved-name duality (pre-existing; the gate handles it safely, no leak).

Regressions: 0747 (ns-only alias bare -> not visible), 0748 (flat-visible alias
not poisoned by ns-only same-name struct). Both fail-before on 4bd57c8 /
pass-after here.
This commit is contained in:
agra
2026-06-07 18:41:01 +03:00
parent 4bd57c857e
commit daf4bbc862
11 changed files with 213 additions and 94 deletions

View File

@@ -1788,65 +1788,103 @@ pub const Lowering = struct {
if (name.len > 0 and (name[0] == '[' or name[0] == '*' or name[0] == '?')) {
return .{ .resolved = self.typeResolver().resolveName(name, raw) };
}
// Bare nominal name: select its author over the ONE graph-walk collector
// (`typeBareVisibleAsType` — flat-import reachable TYPE author) plus the
// single-hop collector for the alias path. A bare TYPE name is visible iff
// a flat-import-reachable module authors it AS A TYPE (R1: a same-name
// VALUE/FUNCTION does not count); a namespaced-only TYPE is registered
// GLOBALLY yet reachable only over a namespace edge, so without this gate
// its bare reference leaked through `findByName`'s global first-match.
// Bare nominal name. A bare TYPE name is visible iff a flat-import-
// reachable module authors it AS A TYPE — and a TYPE author is EITHER a
// named type (struct/enum/union/error-set/protocol/foreign class) OR a
// type ALIAS (`Name :: <type>`, a `const_decl` whose value resolved to a
// type, recorded in E0's `type_aliases_by_source`). Both kinds are gated
// identically: `moduleTypeAuthor` is the SINGLE source of truth, so a
// namespaced-only alias leaks no more than a namespaced-only named type,
// and a flat-visible alias is never poisoned by an invisible same-name
// named type (and vice-versa) — R4. A same-name flat VALUE/FUNCTION is
// NOT a type author (R1); a value-const (`N :: 7`) lives in
// `module_consts_by_source`, never in `type_aliases_by_source`, so it is
// correctly excluded too.
//
// The TYPE reachability here is the TRANSITIVE flat-import closure, NOT the
// single-hop `collectVisibleAuthors`/`isNameVisible` set the bare VALUE /
// FUNCTION / CONST leaves use. That asymmetry (types transitive, values
// non-transitive — 0706) is the open model-consistency question (R3, E1):
// the value/function model needs the source pin for a library template's
// INTERNAL type refs (`List.append`'s `alloc: Allocator`, instantiated in
// the caller's source context) before the type gate can go single-hop too
// — see the worker report. Until that lands, the transitive type closure
// is the only byte-identical option; the gate stays type-author-aware and
// local-safe regardless of which reachability E1.x settles on.
// non-transitive — 0706) is the open model-consistency question (R3,
// sequenced as E4 per Agra): the value/function model needs the source pin
// for a library template's INTERNAL type refs (`List.append`'s
// `alloc: Allocator`, instantiated in the caller's source context) before
// the type gate can go single-hop too. Until that lands, the transitive
// type closure is the only byte-identical option; the gate stays
// type-author-aware and local-safe regardless of which reachability E4
// settles on.
const name_id = table.internString(name);
const registered = table.findByName(name_id);
// Registered named type (struct/enum/union/error_set/protocol/foreign
// class) — gated on flat-import TYPE visibility (F1, the type analog of
// Phase B's value/function tightening).
if (registered) |existing| {
// Compiler-synthesized default-Context emission resolves the built-in
// allocator types as infrastructure — fall open (the gate is for USER
// bare references, not compiler internals).
if (self.emitting_default_context) return .{ .resolved = existing };
// A flat-import-reachable TYPE author makes the bare reference visible.
// The author must be a TYPE — a same-name flat VALUE/FUNCTION does NOT
// make a namespaced-only type bare-visible (R1). Single-author (E1): the
// unique `findByName` match IS that author's TypeId, so a bare-visible
// name resolves byte-identically; E2 routes this through the collector-
// selected author's per-source `nominal_id` once same-name type shadows
// register.
if (self.typeBareVisibleAsType(name, from)) return .{ .resolved = existing };
// A block-local type (declared inside a fn / init body) clobbers the
// global entry for its name, so `existing` IS that local type — never a
// namespaced-only leak. Resolve it ungated (R2): a legitimately-scoped
// local must not be rejected just because a namespaced-only import also
// authors a top-level type of the same name.
if (self.local_type_names.contains(name)) return .{ .resolved = existing };
// Registered as a TOP-LEVEL named TYPE in some module, but NOT flat-
// import-reachable from `from` and NOT shadowed by a local → reachable
// only over a namespace edge → leak. Return `.not_visible`;
// `resolveNominalLeaf` surfaces the diagnostic and the `.unresolved`
// sentinel (qualify it `ns.Type`, Phase F).
if (self.nameAuthoredAsTypeAnywhere(name)) return .not_visible;
// Not a top-level type author anywhere — a generic type-param's bound
// or a fabricated empty-struct stub. Not a bare cross-module reference;
// resolve ungated (its own diagnostics still fire in the dedicated pass).
return .{ .resolved = existing };
// Compiler-synthesized default-Context emission resolves the built-in
// allocator types as infrastructure — fall open (the gate is for USER bare
// references, not compiler internals).
if (self.emitting_default_context) {
if (registered) |existing| return .{ .resolved = existing };
}
// Type alias `A :: B`. Select the alias author over the single-hop
// collector and read its target from the source-keyed cache, keyed by the
// author's OWN declaring source (E0's write side) — this is where the
// global-alias-leak (0104-F2) fix begins, replacing the global
// `type_alias_map` first-match for a flat-visible alias.
// Import facts unwired (registration / comptime host with no module_decls
// or flat graph): there is no querying context to gate against — preserve
// the legacy resolution (registered → existing; else forward-alias /
// undeclared).
if (self.program_index.module_decls == null or self.program_index.flat_import_graph == null) {
if (registered) |existing| return .{ .resolved = existing };
return self.forwardAliasOrUndeclared(name, from);
}
// 1. A flat-import-visible TYPE author (named type OR alias) — resolve to
// its declared TypeId. Single-author (E1): at most one author across
// own flat closure, so this is byte-identical to the legacy leaf.
// The author KIND decides resolution, decoupled from `findByName`
// timing: an ALIAS resolves to its `type_aliases_by_source` target; a
// NAMED type resolves to its `findByName` entry, OR — when that entry
// is not registered yet (a forward / self reference like
// `next: *ArenaChunk` resolved mid-registration) — to the legacy
// empty-struct stub, reconciled by `updatePreservingKey` when the type
// finally registers. E2 routes named resolution through the
// collector-selected author's per-source `nominal_id` once same-name
// type shadows register.
if (self.flatVisibleTypeAuthor(name, from)) |author| switch (author) {
.alias => |tid| return .{ .resolved = tid },
.named => {
if (registered) |existing| return .{ .resolved = existing };
return .undeclared;
},
};
// 2. A block-local type (declared inside a fn / init body) clobbers the
// global entry for its name, so `existing` IS that local type — never a
// namespaced-only leak. Resolve it ungated (R2): a legitimately-scoped
// local must not be rejected just because a namespaced-only import also
// authors a top-level type of the same name.
if (self.local_type_names.contains(name)) {
if (registered) |existing| return .{ .resolved = existing };
}
// 3. Authored as a TYPE (named OR alias) in some module, but NOT flat-
// import-reachable from `from` and NOT shadowed by a local → reachable
// only over a namespace edge → leak. Return `.not_visible`;
// `resolveNominalLeaf` surfaces the diagnostic and the `.unresolved`
// sentinel (qualify it `ns.Type`, Phase F).
if (self.nameAuthoredAsTypeAnywhere(name)) return .not_visible;
// 4. Not a cross-module type author. A registered generic type-param bound
// or fabricated empty-struct stub (findByName hit, no module_decls
// author) resolves ungated. Otherwise a forward identifier alias
// (visible const author, target not resolved yet → `.pending`, back to
// the fixpoint) or `.undeclared`.
if (registered) |existing| return .{ .resolved = existing };
return self.forwardAliasOrUndeclared(name, from);
}
/// The forward-alias / undeclared tail of `selectNominalLeaf`: a bare nominal
/// name that is neither a flat-visible type author, a local, nor a leak.
/// Selects the single-hop const author (E1: `collectVisibleAuthors` returns
/// ≤1) and, if its alias target is not yet in `type_aliases_by_source`,
/// returns `.pending` so the forward-alias fixpoint re-resolves it (source-
/// aware in E1.5). A resolved flat-visible alias is already returned by
/// `flatVisibleTypeAuthor` above, so the `inner.get` here only catches a
/// const author reachable via `collectVisibleAuthors` whose target landed
/// between the two reads — the fixpoint path is the common outcome.
fn forwardAliasOrUndeclared(self: *Lowering, name: []const u8, from: []const u8) TypeHeadResolution {
var res = self.resolver();
const set = res.collectVisibleAuthors(name, from, .user_bare_flat);
defer if (set.flat.len > 0) self.alloc.free(set.flat);
@@ -1854,8 +1892,6 @@ pub const Lowering = struct {
if (self.program_index.type_aliases_by_source.get(author.source)) |inner| {
if (inner.get(name)) |alias_ty| return .{ .resolved = alias_ty };
}
// Const author visible but its alias target is not resolved yet —
// a forward identifier alias → `.pending`, back to the fixpoint.
return .pending;
}
return .undeclared;
@@ -1872,9 +1908,10 @@ pub const Lowering = struct {
/// TRUE iff `raw` declares a NAMED TYPE — struct / enum / union / error-set /
/// protocol / foreign class. A `fn_decl`, a value-or-alias `const_decl`, and a
/// `namespace_decl` are NOT named types (a type alias is a `const_decl` whose
/// `findByName` lookup fails, so it never reaches the named-type gate; it
/// resolves through the alias path keyed by the const author instead).
/// `namespace_decl` are NOT named types. A type ALIAS is a `const_decl`; it is
/// recognised as a type author NOT here but via `type_aliases_by_source`
/// (E0's source-keyed cache) in `moduleTypeAuthor`, so the two type-author
/// kinds — named type and alias — gate identically (R4).
fn isNamedTypeKind(raw: resolver_mod.RawDeclRef) bool {
return switch (raw) {
.struct_decl, .enum_decl, .union_decl, .error_set_decl, .protocol_decl, .foreign_class_decl => true,
@@ -1882,39 +1919,64 @@ pub const Lowering = struct {
};
}
/// TRUE iff module `path` authors a top-level decl named `name` AS A TYPE
/// (struct/enum/union/error-set/protocol/foreign class). A same-name
/// VALUE/FUNCTION author returns false (R1: the gate is type-author-aware, not
/// name-only) — the per-module leaf of `typeBareVisibleAsType`'s closure walk.
fn moduleAuthorsType(decls: *imports_mod.ModuleDecls, path: []const u8, name: []const u8) bool {
const m = decls.get(path) orelse return false;
const ref = m.names.get(name) orelse return false;
return isNamedTypeKind(ref);
/// A module's authorship of a bare type `name`: an ALIAS (carrying the
/// resolved target `TypeId` from `type_aliases_by_source`) or a NAMED type
/// (struct/enum/union/error-set/protocol/foreign class — its TypeId is
/// resolved at the use site from `findByName`, decoupled from this predicate
/// so a not-yet-registered forward / self reference is still recognised as an
/// author).
const FlatTypeAuthor = union(enum) {
alias: TypeId,
named,
};
/// How module `path` authors `name` AS A TYPE, or null if it does not. A type
/// author is EITHER a type ALIAS (`Name :: <type>`, recorded in E0's
/// `type_aliases_by_source` — checked first via the source-keyed cache) OR a
/// NAMED type (recognised by its `module_decls` decl KIND, NOT by `findByName`
/// — so a forward / self reference resolved before the type registers is still
/// an author). A same-name VALUE/FUNCTION is NOT a type author (R1); a
/// value-const (`N :: 7`) lives in `module_consts_by_source`, never
/// `type_aliases_by_source`, so it returns null too. THE per-module "is `name`
/// a type author here?" predicate — the single source of truth for the
/// visibility walk (R4).
fn moduleTypeAuthor(self: *Lowering, path: []const u8, name: []const u8) ?FlatTypeAuthor {
if (self.program_index.type_aliases_by_source.get(path)) |inner| {
if (inner.get(name)) |tid| return .{ .alias = tid };
}
const decls = self.program_index.module_decls orelse return null;
const m = decls.get(path) orelse return null;
const ref = m.names.get(name) orelse return null;
if (!isNamedTypeKind(ref)) return null;
return .named;
}
/// TRUE iff bare `name` is reachable from `from` AS A TYPE over the flat-import
/// closure (own decls every transitively flat-imported module's own decls).
/// This is the type-author-aware analog of the value/function visibility, but
/// TRANSITIVE rather than single-hop — the open R3 asymmetry (see the gate
/// comment in `selectNominalLeaf`): a library template's INTERNAL type ref
/// (`List.append`'s `alloc: Allocator`, declared in `std.sx` but instantiated
/// in the caller's source context) is two flat hops from the caller, and the
/// value/function source-pin that would let the type gate go single-hop too is
/// not yet wired for generic instantiation. A same-name flat VALUE/FUNCTION does
/// NOT make the name type-visible (R1). The closure walk lives in `lower.zig`,
/// NOT `resolver.zig`, so the single-graph-walk invariant (one
/// `flat_import_graph` iterator in `resolver.zig`) is untouched. Falls open
/// (visible) when the scoping facts are unwired (comptime / registration).
fn typeBareVisibleAsType(self: *Lowering, name: []const u8, from: []const u8) bool {
const decls = self.program_index.module_decls orelse return true;
const graph = self.program_index.flat_import_graph orelse return true;
if (moduleAuthorsType(decls, from, name)) return true;
/// The flat-import-visible TYPE author for bare `name` from `from`, or null
/// when no flat-reachable module authors `name` as a type. Walks own decls
/// the TRANSITIVE flat-import closure (every transitively flat-imported
/// module), returning the first author found via `moduleTypeAuthor` (named
/// type OR alias — the single source of truth, R4). Single-author (E1): at
/// most one author across the closure, so the first hit IS the unique author.
///
/// TRANSITIVE rather than single-hop is the open R3 asymmetry (types
/// transitive, values non-transitive — 0706; sequenced as E4 per Agra): a
/// library template's INTERNAL type ref (`List.append`'s `alloc: Allocator`,
/// declared in `std.sx` but instantiated in the caller's source context) is
/// two flat hops from the caller, and the value/function source-pin that would
/// let the type gate go single-hop too is not yet wired for generic
/// instantiation. The closure walk lives in `lower.zig`, NOT `resolver.zig`,
/// so the single-graph-walk invariant (one `flat_import_graph` iterator in
/// `resolver.zig`) is untouched. Returns null when the flat graph is unwired
/// (the caller has already special-cased that to the legacy resolution).
fn flatVisibleTypeAuthor(self: *Lowering, name: []const u8, from: []const u8) ?FlatTypeAuthor {
const graph = self.program_index.flat_import_graph orelse return null;
if (self.moduleTypeAuthor(from, name)) |a| return a;
var visited = std.StringHashMap(void).init(self.alloc);
defer visited.deinit();
var queue = std.ArrayList([]const u8).empty;
defer queue.deinit(self.alloc);
visited.put(from, {}) catch return true;
queue.append(self.alloc, from) catch return true;
visited.put(from, {}) catch return null;
queue.append(self.alloc, from) catch return null;
var i: usize = 0;
while (i < queue.items.len) : (i += 1) {
const deps = graph.get(queue.items[i]) orelse continue;
@@ -1923,24 +1985,32 @@ pub const Lowering = struct {
const dep = kv.key_ptr.*;
if (visited.contains(dep)) continue;
visited.put(dep, {}) catch continue;
if (moduleAuthorsType(decls, dep, name)) return true;
if (self.moduleTypeAuthor(dep, name)) |a| return a;
queue.append(self.alloc, dep) catch continue;
}
}
return false;
return null;
}
/// TRUE iff `name` is authored as a TOP-LEVEL NAMED TYPE in ANY module's raw
/// facts. Distinguishes a real cross-module TYPE author (the only thing the
/// bare-flat visibility gate polices) from a LOCAL type / generic-param /
/// TRUE iff `name` is authored as a TYPE — a NAMED type OR a type ALIAS — in
/// ANY module's raw facts. The leak detector: a name that is a type author
/// somewhere but not flat-visible from the querying module is reachable only
/// over a namespace edge. Both kinds are checked (R4): named types via
/// `module_decls`, aliases via E0's `type_aliases_by_source`. Distinguishes a
/// real cross-module TYPE author from a LOCAL type / generic-param /
/// fabricated empty-struct stub (findByName-registered but authored in no
/// `module_decls`) and from a same-name VALUE/FUNCTION author (not a type).
/// Unwired facts → false (nothing to gate; resolve ungated).
/// module) and from a same-name VALUE/FUNCTION author (not a type). Unwired
/// facts → false (nothing to gate; resolve ungated).
fn nameAuthoredAsTypeAnywhere(self: *Lowering, name: []const u8) bool {
const decls = self.program_index.module_decls orelse return false;
var it = decls.valueIterator();
while (it.next()) |m| {
if (m.names.get(name)) |ref| if (isNamedTypeKind(ref)) return true;
if (self.program_index.module_decls) |decls| {
var it = decls.valueIterator();
while (it.next()) |m| {
if (m.names.get(name)) |ref| if (isNamedTypeKind(ref)) return true;
}
}
var ait = self.program_index.type_aliases_by_source.valueIterator();
while (ait.next()) |inner| {
if (inner.contains(name)) return true;
}
return false;
}