feat(resolver): complete source-aware nominal-TYPE leaf — bare ns-only types not visible [stdlib E1 attempt-2]

Completes the F1 deliverable the reviewer flagged: the bare TYPE leaf still
returned the global `findByName` match BEFORE any visibility check, so a type
declared only behind a namespaced import leaked bare. Now the registered-type
branch of `selectNominalLeaf` is gated on bare-flat visibility (the type analog
of Phase B's value/function tightening): a bare reference to a namespaced-only
import's TYPE errors ("type 'X' is not visible; #import the module that declares
it") and poisons to `.unresolved` — never the leaked global match, never a
silent empty-struct stub.

Visibility gate is the TRANSITIVE flat-import closure (`typeBareVisible`), not
the single-hop `collectVisibleAuthors`/`isNameVisible`: a flat import is
transitive for resolution, so a type two flat hops away (`CAllocator`, via
`main → std.sx → allocators.sx`) stays bare-visible while a namespaced-only type
(reached solely over a namespace edge) does not. The gate applies ONLY to a
TOP-LEVEL author (`module_decls`) — a LOCAL type / generic-param / fabricated
empty-struct stub is findByName-registered but authored in no module, so it
resolves ungated and byte-identically (its own diagnostics still fire). The
compiler-synthesized default-Context emission falls open (`CAllocator` is
infrastructure, independent of the program's import style). The closure walk
lives in lower.zig, so resolver.zig keeps its single graph-walk.

A namespaced callee's declared return type now resolves in the callee's own
module context (`resolveTypeInSource` over `qualified_fn_source`) — a `Value`
returned by `json.parse` is visible inside `json.sx`, not at the call site
(issue-0100-F1 source-pin analog).

Migrates 0719 (flat-imports `cli` for its types, keeps `cli` namespaced for the
same-name `cli.parse`). Adds 0743 (bare ns-only struct → not visible) and 0744
(bare ns-only enum → not visible) regressions. 0742 (ns-only const) + 0210
(generics stay legacy) unchanged. readme updated.

Gate: zig build / zig build test (LSP sweep, no crash) / run_examples 481/0;
m3te ios-sim exit 0; 0743/0744 fail-before on 7cd12b0 (compiled, no diagnostic)
/ pass-after (clean "not visible").
This commit is contained in:
agra
2026-06-07 16:49:15 +03:00
parent 7cd12b0ed5
commit 7188481761
14 changed files with 218 additions and 20 deletions

View File

@@ -12,6 +12,14 @@
// independent identities.
#import "modules/std.sx";
// `cli` is imported BOTH flat (so its types — `FlagSpec` / `Command` / `Diag` —
// are bare-visible) AND namespaced (so the same-name `cli.parse` stays a
// distinct qualified identity from `json.parse`). Post-E1 a bare reference to a
// namespaced-ONLY type is a "not visible" error, so the flat import is what makes
// the bare type names below resolve; `json` stays namespaced-only (its `Value`
// reaches `main` only as `json.parse`'s return type, resolved in `json.sx`'s own
// context).
#import "modules/std/cli.sx";
cli :: #import "modules/std/cli.sx";
json :: #import "modules/std/json.sx";

View File

@@ -0,0 +1,16 @@
// Bare TYPE visibility under a NAMESPACED-only import — the struct sibling of
// 0736 (bare call) and 0742 (bare const), and the core of the source-aware
// nominal leaf (Phase E1). `dep.sx` is imported only as `dep :: #import`, so its
// top-level `Secret` struct is reachable ONLY as `dep.Secret`. A BARE `Secret`
// in a type position must NOT resolve: bare-TYPE visibility joins over the FLAT
// import edges (`flat_import_graph`, transitively), and a namespaced alias is not
// a flat edge. Before the fix the bare type leaked through the global
// `findByName` first-match (the leaf returned it ahead of the visibility check),
// so `s.x` compiled and ran. The qualified form `dep.Secret` stays the supported
// spelling (its member resolution lands fully in Phase F).
dep :: #import "0743-modules-namespaced-only-bare-type-not-visible/dep.sx";
main :: () -> s32 {
s : Secret = .{ x = 5, y = 6 };
s.x
}

View File

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

View File

@@ -0,0 +1,13 @@
// Bare TYPE visibility under a NAMESPACED-only import — the ENUM sibling of 0743
// (struct), covering the second registered nominal kind for the source-aware
// nominal leaf (Phase E1). `dep.sx` is imported only as `dep :: #import`, so its
// top-level `Color` enum is reachable ONLY as `dep.Color`. A BARE `Color` in a
// type position must NOT resolve — bare-TYPE visibility joins over the FLAT
// import edges, and a namespaced alias is not a flat edge. Before the fix the
// bare enum leaked through the global `findByName` first-match.
dep :: #import "0744-modules-namespaced-only-bare-enum-not-visible/dep.sx";
main :: () -> s32 {
c : Color = .green;
if c == .green { 0 } else { 9 }
}

View File

@@ -0,0 +1,5 @@
Color :: enum {
red;
green;
blue;
}

View File

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

View File

@@ -0,0 +1,5 @@
error: type 'Color' is not visible; #import the module that declares it
--> examples/0744-modules-namespaced-only-bare-enum-not-visible.sx:11:9
|
11 | c : Color = .green;
| ^^^^^

View File

@@ -400,11 +400,12 @@ 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()`).
A **namespaced** import only binds its alias: reach the module's members as
`m.name`. Bare-name visibility joins over flat (`#import "…"`) imports only,
never over a namespaced alias. Bare references to a namespaced-only import's
members are being phased out as the resolver migration lands and do not yet
resolve uniformly across name kinds — qualify them as `m.name` to stay correct
across releases.
`m.name`. Bare-name visibility joins over flat (`#import "…"`) imports only
transitively (a flat import of a flat import is visible) — never over a
namespaced alias. A bare reference to a namespaced-only import's member —
function, module constant, or **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

View File

@@ -409,7 +409,7 @@ pub const CallResolver = struct {
if (self.l.program_index.fn_ast_map.get(qualified)) |qfd| {
return .{
.kind = .namespace_fn,
.return_type = if (qfd.return_type) |rt| self.l.resolveType(rt) else .void,
.return_type = if (qfd.return_type) |rt| self.l.resolveTypeInSource(self.l.program_index.qualified_fn_source.get(qualified), rt) else .void,
.target = .{ .named = qualified },
.expands_defaults = defaultsFor(qfd, c.args.len),
};
@@ -419,7 +419,7 @@ pub const CallResolver = struct {
if (self.l.program_index.fn_ast_map.get(cfa.field)) |bfd| {
return .{
.kind = .namespace_fn,
.return_type = if (bfd.return_type) |rt| self.l.resolveType(rt) else .void,
.return_type = if (bfd.return_type) |rt| self.l.resolveTypeInSource(self.l.program_index.qualified_fn_source.get(qualified), rt) else .void,
.target = .{ .named = cfa.field },
.expands_defaults = defaultsFor(bfd, c.args.len),
};

View File

@@ -13,6 +13,7 @@ const errors = @import("../errors.zig");
const jni_descriptor = @import("jni_descriptor.zig");
const program_index_mod = @import("program_index.zig");
const resolver_mod = @import("resolver.zig");
const imports_mod = @import("../imports.zig");
const ProgramIndex = program_index_mod.ProgramIndex;
const GlobalInfo = program_index_mod.GlobalInfo;
const StructTemplate = program_index_mod.StructTemplate;
@@ -200,6 +201,13 @@ pub const Lowering = struct {
func_defer_base: usize = 0, // defer stack base for current function (lowerReturn drains to this)
deferred_type_fns: std.ArrayList([]const u8) = std.ArrayList([]const u8).empty, // functions deferred until all types registered
processing_deferred: bool = false, // true when processing deferred functions (prevents re-deferral)
/// True while emitting the compiler-synthesized default-Context global
/// (`emitDefaultContextGlobal`). The built-in allocator infrastructure
/// (`CAllocator`/`Allocator`/`Context`) is resolved as compiler internals,
/// independent of the user program's import STYLE (a `std :: #import` puts
/// `CAllocator` behind a namespace edge from `main`, so the user-visibility
/// gate would reject it) — so the bare TYPE leaf falls open here (F1).
emitting_default_context: bool = false,
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_template: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // mangled struct name → template name
@@ -1651,6 +1659,15 @@ pub const Lowering = struct {
/// E1 keeps the existing empty-struct stub; E3 turns this into the
/// `.unresolved` sentinel + a diagnostic.
undeclared,
/// `name` IS a registered named type, but it is reachable from the
/// querying module ONLY through a namespaced import — not bare-visible
/// over the transitive flat-import closure (the type analog of Phase B's
/// bare-call tightening, F1). The user must qualify it (`ns.Type`).
/// `resolveNominalLeaf` surfaces the "not visible" diagnostic and returns
/// the `.unresolved` poison sentinel — NEVER the global `findByName` match
/// (which would leak the type) and NEVER a silent empty-struct stub (which
/// would mis-size it).
not_visible,
};
/// THE plain bare-name call selector (fix-0102c, R5 §C). `resolveBareCallee`'s
@@ -1762,14 +1779,43 @@ pub const Lowering = struct {
if (name.len > 0 and (name[0] == '[' or name[0] == '*' or name[0] == '?')) {
return .{ .resolved = self.typeResolver().resolveName(name, raw) };
}
// Registered named type. Single-author (E1): its unique registered
// TypeId. `findByName` stays the byte-identical resolver here — it also
// reaches a namespaced-only type referenced bare (the global leak 0719
// relies on); E2 routes this through the collector-selected author's
// per-source `nominal_id` once same-name type shadows register, and E3
// turns a true miss into the `.unresolved` sentinel + a diagnostic.
// Registered named type — gated on BARE-FLAT visibility (F1, the type
// analog of Phase B's value/function tightening). A namespaced-only type
// is registered GLOBALLY yet is reachable from the querying module only
// over a namespace edge, so without this gate its bare reference leaked
// through the global `findByName` first-match. The gate is the TRANSITIVE
// flat-import reachability `typeBareVisible` — NOT `collectVisibleAuthors`,
// which walks each module's OWN decls single-hop and would false-negative
// a type two flat hops away (e.g. `CAllocator`, reached `main → std.sx →
// allocators.sx` over two flat edges). Single-author (E1): the unique
// `findByName` match IS the one bare-visible 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.
const name_id = table.internString(name);
if (table.findByName(name_id)) |existing| return .{ .resolved = existing };
if (table.findByName(name_id)) |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 };
// The gate applies ONLY to a TOP-LEVEL type author — a `name` declared
// in some module's raw facts (`module_decls`). A LOCAL type (declared
// inside a fn / init block), a generic type-param, and a fabricated
// empty-struct stub are all findByName-registered yet authored in NO
// `module_decls`; they are not bare cross-module references, so they
// resolve ungated and byte-identically (their own diagnostics —
// unknown-type / value-param — still fire in the dedicated pass).
if (self.nameAuthoredAnywhere(name)) {
if (self.typeBareVisible(name, from)) return .{ .resolved = existing };
// Registered top-level type reachable ONLY through a namespaced
// import: a named type is never a `const`, so the alias path
// cannot apply — return `.not_visible` so the leaf does not leak
// the global match; `resolveNominalLeaf` surfaces the diagnostic
// and the `.unresolved` sentinel (qualify it `ns.Type`, Phase F).
return .not_visible;
}
return .{ .resolved = existing };
}
// Type alias `A :: B`. Select the alias author over the ONE graph-walk
// 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
@@ -1798,13 +1844,74 @@ pub const Lowering = struct {
return null;
}
/// TRUE iff bare `name` is reachable from `from` over the TRANSITIVE
/// flat-import closure (own decls every transitively flat-imported module's
/// own decls). The correct `.user_bare_flat` reachability for the TYPE leaf
/// (F1): a flat import is transitive for resolution — the global decl list a
/// module lowers against is the FULL transitive flat list — so a type two flat
/// hops away (`CAllocator`, reached `main → std.sx → allocators.sx`) IS
/// bare-visible, while a namespaced-only type (reached solely over a namespace
/// edge, never recorded in `flat_import_graph`) is NOT. The single-hop
/// predicates (`isNameVisible` / `collectVisibleAuthors`, own DIRECT flat
/// deps) would false-negate the transitive case. This 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 typeBareVisible(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 (moduleAuthorsName(decls, from, name)) return true;
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;
var i: usize = 0;
while (i < queue.items.len) : (i += 1) {
const deps = graph.get(queue.items[i]) orelse continue;
var it = deps.iterator();
while (it.next()) |kv| {
const dep = kv.key_ptr.*;
if (visited.contains(dep)) continue;
visited.put(dep, {}) catch continue;
if (moduleAuthorsName(decls, dep, name)) return true;
queue.append(self.alloc, dep) catch continue;
}
}
return false;
}
/// TRUE iff module `path` authors a top-level decl named `name` (the Phase A
/// raw-fact membership — own decls only, the per-module leaf of the closure
/// walk in `typeBareVisible`).
fn moduleAuthorsName(decls: *imports_mod.ModuleDecls, path: []const u8, name: []const u8) bool {
const m = decls.get(path) orelse return false;
return m.names.contains(name);
}
/// 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;
var it = decls.valueIterator();
while (it.next()) |m| if (m.names.contains(name)) return true;
return false;
}
/// Resolve the bare TYPE leaf to a `TypeId` for `resolveTypeWithBindings`.
/// Routes through the source-aware `selectNominalLeaf`; `.pending` /
/// `.undeclared` keep the legacy empty-struct stub (E3 turns these into the
/// `.unresolved` sentinel + a diagnostic). When the source context is unwired
/// (`current_source_file` null — comptime / registration callers), there is
/// no querying module to collect from, so fall open to the legacy namer.
fn resolveNominalLeaf(self: *Lowering, name: []const u8, raw: bool) TypeId {
/// `.unresolved` sentinel + a diagnostic). `.not_visible` (a registered type
/// reachable only through a namespaced import) surfaces the "not visible"
/// diagnostic and the `.unresolved` poison sentinel — a real error, never a
/// silent stub (F1). When the source context is unwired (`current_source_file`
/// null — comptime / registration callers), there is no querying module to
/// collect from, so fall open to the legacy namer.
fn resolveNominalLeaf(self: *Lowering, name: []const u8, raw: bool, span: ?ast.Span) TypeId {
const from = self.current_source_file orelse
return self.typeResolver().resolveName(name, raw);
return switch (self.selectNominalLeaf(name, from, raw)) {
@@ -1816,6 +1923,16 @@ pub const Lowering = struct {
.name = self.module.types.internString(name),
.fields = &.{},
} }),
// Registered, but reachable only through a namespaced import: emit the
// diagnostic at the reference and poison the result so no downstream
// check (field access, size) trusts a leaked / mis-sized type.
// `.unresolved` is poison-suppressed, so there is no secondary
// "field not found" cascade.
.not_visible => {
if (self.diagnostics) |d|
d.addFmt(.err, span, "type '{s}' is not visible; #import the module that declares it", .{name});
return .unresolved;
},
};
}
@@ -12873,6 +12990,23 @@ pub const Lowering = struct {
return self.resolveTypeWithBindings(type_ann);
}
/// Resolve a type node with the visibility context pinned to `src`, the
/// DEFINING module of a namespaced callee, restoring the caller's context
/// after. A namespaced callee's declared return type may name a type that is
/// bare-visible only inside the callee's own module — namespaced-only from the
/// call site's view. Post-E1 the bare leaf is source-aware, so resolving that
/// return type in the CALL SITE's context would wrongly reject it (the type
/// analog of the issue-0100-F1 source pin that lowers a namespaced fn body in
/// its own module's context). `src == null` falls back to the call site's
/// context unchanged.
pub fn resolveTypeInSource(self: *Lowering, src: ?[]const u8, type_ann: *const Node) TypeId {
const pinned = src orelse return self.resolveType(type_ann);
const saved = self.current_source_file;
defer self.setCurrentSourceFile(saved);
self.setCurrentSourceFile(pinned);
return self.resolveType(type_ann);
}
/// Construct a `TypeResolver` view over the current lowering state (borrows
/// only; cheap by-value, reflects current `diagnostics` / `program_index`).
fn typeResolver(self: *Lowering) TypeResolver {
@@ -13120,8 +13254,8 @@ pub const Lowering = struct {
// type decls, error types) still route through type_bridge, which reads
// the global compat maps (cut over in a later phase).
switch (node.data) {
.type_expr => |te| return self.resolveNominalLeaf(te.name, te.is_raw),
.identifier => |id| return self.resolveNominalLeaf(id.name, id.is_raw),
.type_expr => |te| return self.resolveNominalLeaf(te.name, te.is_raw, node.span),
.identifier => |id| return self.resolveNominalLeaf(id.name, id.is_raw, node.span),
// A non-spread tuple literal in a type position is a tuple-type
// literal (`(s32, s32)`); validate its elements are types and reject
// non-type elements loudly (issue 0067).
@@ -14434,6 +14568,9 @@ pub const Lowering = struct {
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
fn emitDefaultContextGlobal(self: *Lowering) void {
const saved_edc = self.emitting_default_context;
self.emitting_default_context = true;
defer self.emitting_default_context = saved_edc;
const tbl = &self.module.types;
const ctx_name_id = tbl.internString("Context");
const ctx_ty = tbl.findByName(ctx_name_id) orelse return;