fix(resolver): type-author-aware + local-safe bare-TYPE gate; R3 model escalated [stdlib E1 attempt-3]

R1 (type-author-aware gate): the bare-TYPE visibility gate now requires a
flat-import-reachable TYPE author (struct/enum/union/error-set/protocol/foreign
class). A same-name flat VALUE/FUNCTION no longer makes a namespaced-only TYPE
bare-visible — the name-only `m.names.contains` check (attempt-2) is replaced by
`moduleAuthorsType` (kind-checked via `RawDeclRef`). Regression 0745.

R2 (no local false-positive): a block-local type clobbers the global type-table
entry for its name (`registerStructDecl`'s findByName-orelse-intern +
updatePreservingKey), so it IS the resolved type — never a namespaced-only leak.
A new `local_type_names` set, populated at both block-local type-decl paths,
exempts such names from the gate. Regression 0746.

readme.md: drop the false "transitively" claim — flat-import bare visibility for
functions and constants is NON-transitive (0706).

R3 (foundational model consistency) is ESCALATED, not resolved here — see the
attempt-3 worker report. Ground truth: making the TYPE gate single-hop (to match
the value/function model) breaks ~19 tests, ~13 of them library-INTERNAL generic
refs (e.g. `List.append`'s `alloc: Allocator`, lowered in the caller's source
context). That needs source-pinning generic instantiation to the template's
defining module — a separate architectural piece beyond E1's leaf-cut scope, and
proven risky (a `monomorphizeFunction` pin broke 4 FFI objc-block tests and did
not even take, since template method bodies lack a reliable `source_file`). The
TYPE gate therefore stays on the (type-author-aware) transitive flat closure for
E1; the non-transitive reconciliation is a routed follow-up.
This commit is contained in:
agra
2026-06-07 17:51:09 +03:00
parent 7188481761
commit 4bd57c857e
13 changed files with 179 additions and 72 deletions

View File

@@ -0,0 +1,15 @@
// Type-author-aware bare-TYPE visibility gate (Phase E1, R1). `flatval.sx` is
// flat-imported and authors a VALUE/FUNCTION `Secret`; `nstype.sx` is namespaced
// (`nst :: #import`) and authors a TYPE `Secret`. A bare `Secret` in a type
// position must NOT resolve: the only flat-visible `Secret` author is a FUNCTION,
// and a same-name flat value does NOT make the namespaced-only TYPE bare-visible.
// The leak this closes: a name-only gate would see the flat function and let the
// global `findByName` first-match return the namespaced-only struct. The type is
// reachable only as `nst.Secret`.
#import "0745-modules-flat-value-shadows-ns-only-type/flatval.sx";
nst :: #import "0745-modules-flat-value-shadows-ns-only-type/nstype.sx";
main :: () -> s32 {
s : Secret = .{ x = 5, y = 6 };
s.x
}

View File

@@ -0,0 +1,2 @@
// A flat-visible VALUE/FUNCTION named `Secret` (not a type).
Secret :: () -> s32 { 0 }

View File

@@ -0,0 +1,4 @@
Secret :: struct {
x: s32;
y: s32;
}

View File

@@ -0,0 +1,13 @@
// A block-LOCAL type resolves even when a namespaced-only import authors a
// top-level type of the same name (Phase E1, R2). `dep.sx` is namespaced
// (`dep :: #import`) and authors a top-level `Secret`; `main` declares its OWN
// block-local `Secret`. The local must resolve to ITS fields (a legitimately-
// scoped local is never a namespaced-only leak), not be rejected by the bare-TYPE
// visibility gate just because the namespaced import shares the name.
dep :: #import "0746-modules-local-type-shadows-ns-only-type/dep.sx";
main :: () -> s32 {
Secret :: struct { z: s32; }
s : Secret = .{ z = 7 };
s.z
}

View File

@@ -0,0 +1,4 @@
Secret :: struct {
x: s32;
y: s32;
}

View File

@@ -0,0 +1,5 @@
error: type 'Secret' is not visible; #import the module that declares it
--> examples/0745-modules-flat-value-shadows-ns-only-type.sx:13:9
|
13 | s : Secret = .{ x = 5, y = 6 };
| ^^^^^^

View File

@@ -400,12 +400,13 @@ A bare call to a name that two or more flat imports both provide is ambiguous an
is rejected; qualify it with a namespaced import (`m :: #import …; m.fn()`). is rejected; qualify it with a namespaced import (`m :: #import …; m.fn()`).
A **namespaced** import only binds its alias: reach the module's members as A **namespaced** import only binds its alias: reach the module's members as
`m.name`. Bare-name visibility joins over flat (`#import "…"`) imports only — `m.name`. Bare-name visibility joins over flat (`#import "…"`) imports, never over
transitively (a flat import of a flat import is visible) — never over a a namespaced alias. For **functions and constants** that join is non-transitive: a
namespaced alias. A bare reference to a namespaced-only import's member — flat import of a flat import is NOT bare-visible (when `A` imports `B` and `B`
function, module constant, or **type** — is not visible and is rejected (`type imports `C`, `A` does not see `C`'s top-level names — qualify them). A bare
'X' is not visible; #import the module that declares it`); qualify it as reference to a namespaced-only import's member — function, module constant, or
`m.name`. **type** — is not visible and is rejected (`type 'X' is not visible; #import the
module that declares it`); qualify it as `m.name`.
### Implicit Context ### Implicit Context

View File

@@ -208,6 +208,15 @@ pub const Lowering = struct {
/// `CAllocator` behind a namespace edge from `main`, so the user-visibility /// `CAllocator` behind a namespace edge from `main`, so the user-visibility
/// gate would reject it) — so the bare TYPE leaf falls open here (F1). /// gate would reject it) — so the bare TYPE leaf falls open here (F1).
emitting_default_context: bool = false, emitting_default_context: bool = false,
/// Names declared as a BLOCK-LOCAL type (a `Foo :: struct/enum/union/error_set`
/// or bare type-decl statement inside a fn / init body). A local type registers
/// into the global type table and CLOBBERS a same-name top-level entry
/// (`registerStructDecl`'s `findByName … orelse intern` + `updatePreservingKey`),
/// so after it lowers the name IS the local type program-wide (single-author,
/// pre-E2). The source-aware bare-TYPE gate consults this so a legitimately
/// block-local type is never mistaken for a namespaced-only leak — even when a
/// namespaced-only import happens to author a top-level type of the same name.
local_type_names: std.StringHashMap(void) = std.StringHashMap(void).init(std.heap.page_allocator),
struct_defaults_map: std.StringHashMap([]const ?*const Node) = std.StringHashMap([]const ?*const Node).init(std.heap.page_allocator), // struct name → field defaults struct_defaults_map: std.StringHashMap([]const ?*const Node) = std.StringHashMap([]const ?*const Node).init(std.heap.page_allocator), // struct name → field defaults
struct_instance_bindings: std.StringHashMap(std.StringHashMap(TypeId)) = std.StringHashMap(std.StringHashMap(TypeId)).init(std.heap.page_allocator), // mangled struct name → type param bindings struct_instance_bindings: std.StringHashMap(std.StringHashMap(TypeId)) = std.StringHashMap(std.StringHashMap(TypeId)).init(std.heap.page_allocator), // mangled struct name → type param bindings
struct_instance_template: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // mangled struct name → template name struct_instance_template: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // mangled struct name → template name
@@ -1779,46 +1788,63 @@ pub const Lowering = struct {
if (name.len > 0 and (name[0] == '[' or name[0] == '*' or name[0] == '?')) { if (name.len > 0 and (name[0] == '[' or name[0] == '*' or name[0] == '?')) {
return .{ .resolved = self.typeResolver().resolveName(name, raw) }; return .{ .resolved = self.typeResolver().resolveName(name, raw) };
} }
// Registered named type — gated on BARE-FLAT visibility (F1, the type // Bare nominal name: select its author over the ONE graph-walk collector
// analog of Phase B's value/function tightening). A namespaced-only type // (`typeBareVisibleAsType` — flat-import reachable TYPE author) plus the
// is registered GLOBALLY yet is reachable from the querying module only // single-hop collector for the alias path. A bare TYPE name is visible iff
// over a namespace edge, so without this gate its bare reference leaked // a flat-import-reachable module authors it AS A TYPE (R1: a same-name
// through the global `findByName` first-match. The gate is the TRANSITIVE // VALUE/FUNCTION does not count); a namespaced-only TYPE is registered
// flat-import reachability `typeBareVisible` — NOT `collectVisibleAuthors`, // GLOBALLY yet reachable only over a namespace edge, so without this gate
// which walks each module's OWN decls single-hop and would false-negative // its bare reference leaked through `findByName`'s global first-match.
// a type two flat hops away (e.g. `CAllocator`, reached `main → std.sx → //
// allocators.sx` over two flat edges). Single-author (E1): the unique // The TYPE reachability here is the TRANSITIVE flat-import closure, NOT the
// `findByName` match IS the one bare-visible author's TypeId, so a // single-hop `collectVisibleAuthors`/`isNameVisible` set the bare VALUE /
// bare-visible name resolves byte-identically; E2 routes this through the // FUNCTION / CONST leaves use. That asymmetry (types transitive, values
// collector-selected author's per-source `nominal_id` once same-name type // non-transitive — 0706) is the open model-consistency question (R3, E1):
// shadows register. // 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.
const name_id = table.internString(name); const name_id = table.internString(name);
if (table.findByName(name_id)) |existing| { 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 // Compiler-synthesized default-Context emission resolves the built-in
// allocator types as infrastructure — fall open (the gate is for // allocator types as infrastructure — fall open (the gate is for USER
// USER bare references, not compiler internals). // bare references, not compiler internals).
if (self.emitting_default_context) return .{ .resolved = existing }; if (self.emitting_default_context) return .{ .resolved = existing };
// The gate applies ONLY to a TOP-LEVEL type author — a `name` declared // A flat-import-reachable TYPE author makes the bare reference visible.
// in some module's raw facts (`module_decls`). A LOCAL type (declared // The author must be a TYPE — a same-name flat VALUE/FUNCTION does NOT
// inside a fn / init block), a generic type-param, and a fabricated // make a namespaced-only type bare-visible (R1). Single-author (E1): the
// empty-struct stub are all findByName-registered yet authored in NO // unique `findByName` match IS that author's TypeId, so a bare-visible
// `module_decls`; they are not bare cross-module references, so they // name resolves byte-identically; E2 routes this through the collector-
// resolve ungated and byte-identically (their own diagnostics — // selected author's per-source `nominal_id` once same-name type shadows
// unknown-type / value-param — still fire in the dedicated pass). // register.
if (self.nameAuthoredAnywhere(name)) { if (self.typeBareVisibleAsType(name, from)) return .{ .resolved = existing };
if (self.typeBareVisible(name, from)) return .{ .resolved = existing }; // A block-local type (declared inside a fn / init body) clobbers the
// Registered top-level type reachable ONLY through a namespaced // global entry for its name, so `existing` IS that local type — never a
// import: a named type is never a `const`, so the alias path // namespaced-only leak. Resolve it ungated (R2): a legitimately-scoped
// cannot apply — return `.not_visible` so the leaf does not leak // local must not be rejected just because a namespaced-only import also
// the global match; `resolveNominalLeaf` surfaces the diagnostic // authors a top-level type of the same name.
// and the `.unresolved` sentinel (qualify it `ns.Type`, Phase F). if (self.local_type_names.contains(name)) return .{ .resolved = existing };
return .not_visible; // 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 }; return .{ .resolved = existing };
} }
// Type alias `A :: B`. Select the alias author over the ONE graph-walk // Type alias `A :: B`. Select the alias author over the single-hop
// collector and read its target from the source-keyed cache, keyed by // collector and read its target from the source-keyed cache, keyed by the
// the author's OWN declaring source (E0's write side) — this is where the // author's OWN declaring source (E0's write side) — this is where the
// global-alias-leak (0104-F2) fix begins, replacing the global // global-alias-leak (0104-F2) fix begins, replacing the global
// `type_alias_map` first-match for a flat-visible alias. // `type_alias_map` first-match for a flat-visible alias.
var res = self.resolver(); var res = self.resolver();
@@ -1844,23 +1870,45 @@ pub const Lowering = struct {
return null; return null;
} }
/// TRUE iff bare `name` is reachable from `from` over the TRANSITIVE /// TRUE iff `raw` declares a NAMED TYPE — struct / enum / union / error-set /
/// flat-import closure (own decls every transitively flat-imported module's /// protocol / foreign class. A `fn_decl`, a value-or-alias `const_decl`, and a
/// own decls). The correct `.user_bare_flat` reachability for the TYPE leaf /// `namespace_decl` are NOT named types (a type alias is a `const_decl` whose
/// (F1): a flat import is transitive for resolution — the global decl list a /// `findByName` lookup fails, so it never reaches the named-type gate; it
/// module lowers against is the FULL transitive flat list — so a type two flat /// resolves through the alias path keyed by the const author instead).
/// hops away (`CAllocator`, reached `main → std.sx → allocators.sx`) IS fn isNamedTypeKind(raw: resolver_mod.RawDeclRef) bool {
/// bare-visible, while a namespaced-only type (reached solely over a namespace return switch (raw) {
/// edge, never recorded in `flat_import_graph`) is NOT. The single-hop .struct_decl, .enum_decl, .union_decl, .error_set_decl, .protocol_decl, .foreign_class_decl => true,
/// predicates (`isNameVisible` / `collectVisibleAuthors`, own DIRECT flat .fn_decl, .const_decl, .namespace_decl => false,
/// deps) would false-negate the transitive case. This closure walk lives in };
/// `lower.zig`, NOT `resolver.zig`, so the single-graph-walk invariant (one }
/// 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);
}
/// 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 /// `flat_import_graph` iterator in `resolver.zig`) is untouched. Falls open
/// (visible) when the scoping facts are unwired (comptime / registration). /// (visible) when the scoping facts are unwired (comptime / registration).
fn typeBareVisible(self: *Lowering, name: []const u8, from: []const u8) bool { fn typeBareVisibleAsType(self: *Lowering, name: []const u8, from: []const u8) bool {
const decls = self.program_index.module_decls orelse return true; const decls = self.program_index.module_decls orelse return true;
const graph = self.program_index.flat_import_graph orelse return true; const graph = self.program_index.flat_import_graph orelse return true;
if (moduleAuthorsName(decls, from, name)) return true; if (moduleAuthorsType(decls, from, name)) return true;
var visited = std.StringHashMap(void).init(self.alloc); var visited = std.StringHashMap(void).init(self.alloc);
defer visited.deinit(); defer visited.deinit();
var queue = std.ArrayList([]const u8).empty; var queue = std.ArrayList([]const u8).empty;
@@ -1875,33 +1923,34 @@ pub const Lowering = struct {
const dep = kv.key_ptr.*; const dep = kv.key_ptr.*;
if (visited.contains(dep)) continue; if (visited.contains(dep)) continue;
visited.put(dep, {}) catch continue; visited.put(dep, {}) catch continue;
if (moduleAuthorsName(decls, dep, name)) return true; if (moduleAuthorsType(decls, dep, name)) return true;
queue.append(self.alloc, dep) catch continue; queue.append(self.alloc, dep) catch continue;
} }
} }
return false; return false;
} }
/// TRUE iff module `path` authors a top-level decl named `name` (the Phase A /// TRUE iff `name` is authored as a TOP-LEVEL NAMED TYPE in ANY module's raw
/// raw-fact membership — own decls only, the per-module leaf of the closure /// facts. Distinguishes a real cross-module TYPE author (the only thing the
/// walk in `typeBareVisible`). /// bare-flat visibility gate polices) from a LOCAL type / generic-param /
fn moduleAuthorsName(decls: *imports_mod.ModuleDecls, path: []const u8, name: []const u8) bool { /// fabricated empty-struct stub (findByName-registered but authored in no
const m = decls.get(path) orelse return false; /// `module_decls`) and from a same-name VALUE/FUNCTION author (not a type).
return m.names.contains(name); /// Unwired facts → false (nothing to gate; resolve ungated).
} fn nameAuthoredAsTypeAnywhere(self: *Lowering, name: []const u8) bool {
/// TRUE iff `name` is authored as a TOP-LEVEL decl 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 / fabricated
/// empty-struct stub, which are findByName-registered but authored in no
/// `module_decls`. Unwired facts → false (nothing to gate; resolve ungated).
fn nameAuthoredAnywhere(self: *Lowering, name: []const u8) bool {
const decls = self.program_index.module_decls orelse return false; const decls = self.program_index.module_decls orelse return false;
var it = decls.valueIterator(); var it = decls.valueIterator();
while (it.next()) |m| if (m.names.contains(name)) return true; while (it.next()) |m| {
if (m.names.get(name)) |ref| if (isNamedTypeKind(ref)) return true;
}
return false; return false;
} }
/// Record a name declared as a BLOCK-LOCAL type so the bare-TYPE gate never
/// mistakes it for a namespaced-only leak (see `local_type_names`).
fn recordLocalTypeName(self: *Lowering, name: []const u8) void {
self.local_type_names.put(name, {}) catch {};
}
/// Resolve the bare TYPE leaf to a `TypeId` for `resolveTypeWithBindings`. /// Resolve the bare TYPE leaf to a `TypeId` for `resolveTypeWithBindings`.
/// Routes through the source-aware `selectNominalLeaf`; `.pending` / /// Routes through the source-aware `selectNominalLeaf`; `.pending` /
/// `.undeclared` keep the legacy empty-struct stub (E3 turns these into the /// `.undeclared` keep the legacy empty-struct stub (E3 turns these into the
@@ -2697,11 +2746,18 @@ pub const Lowering = struct {
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void); _ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
}, },
// Block-local type declarations // Block-local type declarations
.struct_decl => |sd| self.registerStructDecl(&sd), .struct_decl => |sd| {
self.recordLocalTypeName(sd.name);
self.registerStructDecl(&sd);
},
.enum_decl, .union_decl => { .enum_decl, .union_decl => {
if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
_ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map);
}, },
.error_set_decl => self.registerErrorSetDecl(node), .error_set_decl => {
if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
self.registerErrorSetDecl(node);
},
.ufcs_alias => |ua| { .ufcs_alias => |ua| {
self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {}; self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {};
}, },
@@ -2865,10 +2921,12 @@ pub const Lowering = struct {
// Handle local type declarations: MyType :: struct/union/enum { ... } // Handle local type declarations: MyType :: struct/union/enum { ... }
if (cd.value.data == .struct_decl) { if (cd.value.data == .struct_decl) {
self.recordLocalTypeName(cd.name);
self.registerStructDecl(&cd.value.data.struct_decl); self.registerStructDecl(&cd.value.data.struct_decl);
return; return;
} }
if (cd.value.data == .enum_decl or cd.value.data == .union_decl) { if (cd.value.data == .enum_decl or cd.value.data == .union_decl) {
self.recordLocalTypeName(cd.name);
_ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map);
return; return;
} }