diff --git a/examples/0719-modules-cli-and-json.sx b/examples/0719-modules-cli-and-json.sx index a640d87..5810b2b 100644 --- a/examples/0719-modules-cli-and-json.sx +++ b/examples/0719-modules-cli-and-json.sx @@ -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"; diff --git a/examples/0743-modules-namespaced-only-bare-type-not-visible.sx b/examples/0743-modules-namespaced-only-bare-type-not-visible.sx new file mode 100644 index 0000000..cee0f1c --- /dev/null +++ b/examples/0743-modules-namespaced-only-bare-type-not-visible.sx @@ -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 +} diff --git a/examples/0743-modules-namespaced-only-bare-type-not-visible/dep.sx b/examples/0743-modules-namespaced-only-bare-type-not-visible/dep.sx new file mode 100644 index 0000000..6835705 --- /dev/null +++ b/examples/0743-modules-namespaced-only-bare-type-not-visible/dep.sx @@ -0,0 +1,4 @@ +Secret :: struct { + x: s32; + y: s32; +} diff --git a/examples/0744-modules-namespaced-only-bare-enum-not-visible.sx b/examples/0744-modules-namespaced-only-bare-enum-not-visible.sx new file mode 100644 index 0000000..86b6071 --- /dev/null +++ b/examples/0744-modules-namespaced-only-bare-enum-not-visible.sx @@ -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 } +} diff --git a/examples/0744-modules-namespaced-only-bare-enum-not-visible/dep.sx b/examples/0744-modules-namespaced-only-bare-enum-not-visible/dep.sx new file mode 100644 index 0000000..4417ed1 --- /dev/null +++ b/examples/0744-modules-namespaced-only-bare-enum-not-visible/dep.sx @@ -0,0 +1,5 @@ +Color :: enum { + red; + green; + blue; +} diff --git a/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.exit b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stderr b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stderr new file mode 100644 index 0000000..2b053e7 --- /dev/null +++ b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stderr @@ -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 }; + | ^^^^^^ diff --git a/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stdout b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0743-modules-namespaced-only-bare-type-not-visible.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.exit b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stderr b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stderr new file mode 100644 index 0000000..d7fc483 --- /dev/null +++ b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stderr @@ -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; + | ^^^^^ diff --git a/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stdout b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0744-modules-namespaced-only-bare-enum-not-visible.stdout @@ -0,0 +1 @@ + diff --git a/readme.md b/readme.md index 2ec540a..0078b64 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/src/ir/calls.zig b/src/ir/calls.zig index bf7f8f4..29c6e89 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -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), }; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index dffeeab..f28c586 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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;