diff --git a/examples/0764-modules-import-generic-head-non-transitive.sx b/examples/0764-modules-import-generic-head-non-transitive.sx new file mode 100644 index 0000000..ca31cc7 --- /dev/null +++ b/examples/0764-modules-import-generic-head-non-transitive.sx @@ -0,0 +1,24 @@ +// `#import` is non-transitive for a PARAMETERIZED TYPE HEAD (a generic-struct +// constructor like `Box(s64)`), exactly like a bare leaf type (0763) and like +// values/functions (0706): when A imports B and B imports C, A must NOT see C's +// top-level generic type `Box`. This file imports `b.sx` (which imports `c.sx`) +// and instantiates C's generic `Box(s64)` directly — the compiler rejects the +// head with a "type ... is not visible; #import the module that declares it" +// diagnostic, BEFORE instantiating the template. +// +// `b.sx` ↔ `c.sx` together still compile: `b_make`'s `Box(s64)` resolves because +// b.sx directly imports c.sx (the head is one flat hop away there, two from a +// file that imports b.sx). +// +// Regression (Phase E4): before the bare-head gate went single-hop this 2-flat- +// hop generic head was wrongly visible — the head lookup hit the global +// `struct_template_map` before any source-aware visibility check. + +#import "modules/std.sx"; +#import "0764-modules-import-generic-head-non-transitive/b.sx"; + +main :: () -> s32 { + x : Box(s64) = .{ v = 3 }; + print("{}\n", x.v); + 0 +} diff --git a/examples/0764-modules-import-generic-head-non-transitive/b.sx b/examples/0764-modules-import-generic-head-non-transitive/b.sx new file mode 100644 index 0000000..0830392 --- /dev/null +++ b/examples/0764-modules-import-generic-head-non-transitive/b.sx @@ -0,0 +1,8 @@ +#import "c.sx"; + +// b.sx directly imports c.sx, so it CAN instantiate `Box(s64)` — proving the +// generic head is only one flat hop away here, two hops from a file that +// imports b.sx. +b_make :: () -> Box(s64) { + .{ v = 99 } +} diff --git a/examples/0764-modules-import-generic-head-non-transitive/c.sx b/examples/0764-modules-import-generic-head-non-transitive/c.sx new file mode 100644 index 0000000..fcb6d58 --- /dev/null +++ b/examples/0764-modules-import-generic-head-non-transitive/c.sx @@ -0,0 +1 @@ +Box :: struct($T: Type) { v: T; } diff --git a/examples/expected/0764-modules-import-generic-head-non-transitive.exit b/examples/expected/0764-modules-import-generic-head-non-transitive.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0764-modules-import-generic-head-non-transitive.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0764-modules-import-generic-head-non-transitive.stderr b/examples/expected/0764-modules-import-generic-head-non-transitive.stderr new file mode 100644 index 0000000..d345115 --- /dev/null +++ b/examples/expected/0764-modules-import-generic-head-non-transitive.stderr @@ -0,0 +1,5 @@ +error: type 'Box' is not visible; #import the module that declares it + --> /Users/agra/projects/sx/examples/0764-modules-import-generic-head-non-transitive.sx:21:9 + | +21 | x : Box(s64) = .{ v = 3 }; + | ^^^^^^^^ diff --git a/examples/expected/0764-modules-import-generic-head-non-transitive.stdout b/examples/expected/0764-modules-import-generic-head-non-transitive.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0764-modules-import-generic-head-non-transitive.stdout @@ -0,0 +1 @@ + diff --git a/readme.md b/readme.md index e6fb5bd..e7edb7e 100644 --- a/readme.md +++ b/readme.md @@ -405,10 +405,13 @@ a namespaced alias. That join is **non-transitive for every bare member kind — functions, constants, AND types alike**: a flat import of a flat import is NOT bare-visible (when `A` imports `B` and `B` imports `C`, `A` does not see `C`'s top-level names — including its types — so qualify them, or `#import "C"` directly -if you reference them). A bare reference to a namespaced-only import's member — -function, module constant, or **type** — is likewise not visible and is rejected -(`type 'X' is not visible; #import the module that declares it`); qualify it as -`m.name`. (A library's own *internal* type references still resolve: a generic +if you reference them). This holds for a *parameterized* type head too: a generic +struct / parameterized protocol / type-returning function used as `Box(s64)` is +gated exactly like a bare leaf type — the constructor head must be reachable over +your own or a direct flat import, not two hops away. A bare reference to a +namespaced-only import's member — function, module constant, or **type** (leaf or +generic head) — is likewise not visible and is rejected (`type 'X' is not visible; +#import the module that declares it`); qualify it as `m.name`. (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 e.g. `List(T).append`'s `alloc: Allocator` is visible there regardless of the call site.) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 0e17847..889e230 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1019,8 +1019,19 @@ pub const Lowering = struct { .field_access => |fa| fa.field, else => "", }; + // A namespaced callee (`ns.Box(..)`) is an explicit qualified + // reach, exempt from the bare-head visibility gate (E4). + const head_qualified = call_data.callee.data == .field_access; if (callee_name.len > 0) { - if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| { + if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| reg: { + // 2-hop generic-struct head leak: poison the alias with + // `.unresolved` (suppressed downstream) so the use site + // sees no fabricated-stub cascade, only the loud + // "not visible" diagnostic the gate already emitted. + if (!head_qualified and self.headTypeLeak(callee_name, call_data.callee.span)) { + self.putTypeAlias(self.current_source_file, cd.name, .unresolved); + break :reg; + } const inst_id = self.instantiateGenericStruct(tmpl, call_data.args); // Register under the alias name const alias_name_id = self.module.types.internString(cd.name); @@ -1055,7 +1066,9 @@ pub const Lowering = struct { } else if (self.program_index.fn_ast_map.get(callee_name)) |fd| { // Type-returning function: Foo :: Complex(u32) if (fd.type_params.len > 0) { - if (self.instantiateTypeFunction(cd.name, callee_name, fd, call_data.args)) |result_ty| { + if (!head_qualified and self.headFnLeak(callee_name, call_data.callee.span)) { + self.putTypeAlias(self.current_source_file, cd.name, .unresolved); + } else if (self.instantiateTypeFunction(cd.name, callee_name, fd, call_data.args)) |result_ty| { self.putTypeAlias(self.current_source_file, cd.name, result_ty); } } @@ -1065,7 +1078,12 @@ pub const Lowering = struct { // Type alias for generic struct (from type_bridge path) const pt = &cd.value.data.parameterized_type_expr; const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name; - if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| { + const pt_qualified = std.mem.indexOfScalar(u8, pt.name, '.') != null; + if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| reg: { + if (!pt_qualified and self.headTypeLeak(base_name, cd.value.span)) { + self.putTypeAlias(self.current_source_file, cd.name, .unresolved); + break :reg; + } const inst_id = self.instantiateGenericStruct(tmpl, pt.args); const alias_name_id = self.module.types.internString(cd.name); const inst_info = self.module.types.get(inst_id); @@ -6837,6 +6855,7 @@ pub const Lowering = struct { } // Try as generic struct if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| { + if (cl.callee.data != .field_access and self.headTypeLeak(callee_name, cl.callee.span)) return .unresolved; return self.instantiateGenericStruct(tmpl, cl.args); } return .unresolved; @@ -8575,8 +8594,11 @@ pub const Lowering = struct { const inner_name = inner_call.callee.data.identifier.name; const resolved = if (self.scope) |scope| (scope.lookupFn(inner_name) orelse inner_name) else inner_name; - // Generic struct static method: Animated(Size).make(...) + // Generic struct static method: Animated(Size).make(...). + // `inner_call.callee` is an identifier here (guarded above), so + // the head is unqualified and subject to the bare-head gate (E4). if (self.program_index.struct_template_map.getPtr(resolved)) |tmpl| { + if (self.headTypeLeak(inner_name, inner_call.callee.span)) return Ref.none; const inst_ty = self.instantiateGenericStruct(tmpl, inner_call.args); const inst_name = self.formatTypeName(inst_ty); // Look up template method, monomorphize, and call @@ -8601,6 +8623,7 @@ pub const Lowering = struct { if (self.program_index.fn_ast_map.get(resolved)) |fd| { if (fd.type_params.len > 0) { + if (self.headFnLeak(inner_name, inner_call.callee.span)) return Ref.none; // Try instantiate as type function if (self.instantiateTypeFunction(inner_name, inner_name, fd, inner_call.args)) |result_ty| { const type_info = self.module.types.get(result_ty); @@ -13821,8 +13844,65 @@ pub const Lowering = struct { d.addFmt(.err, arg_node.span, "value {} does not fit in {s} parameter {s}", .{ value, type_name, param_name }); } + /// Single-hop non-transitive visibility gate for an UNQUALIFIED parameterized + /// type HEAD that names a generic STRUCT or a parameterized PROTOCOL + /// (`Box(s64)`, `VL(s64)`) — the constructor-head analog of the bare-leaf + /// type gate (E4). A head is visible iff a TYPE author for `name` is reachable + /// from the USE site over its OWN declaration or a DIRECT flat-import edge — + /// the SAME single-hop set the bare leaf / value / fn leaves use (0706), NOT + /// the transitive closure. Emits the leak diagnostic + returns TRUE when the + /// head is a real type author somewhere but NOT reachable here (a 2-flat-hop + /// leak), so the caller poisons with `.unresolved`. Falls open (FALSE, no + /// diagnostic) when import facts are unwired (registration / comptime — no + /// querying module), the source context is absent, or the compiler-synthesized + /// default-Context emitter is running (built-in infrastructure resolves + /// independent of the user program's import style, F1). A block-local generic + /// of THIS source is visible in its own scope. Library-internal heads stay + /// visible because every instantiation kind is source-pinned to the template's + /// defining module (E3/E4 #1): the query originates THERE, where the head is a + /// direct flat import — not at the cross-module call site. Only the bare + /// (identifier-callee / dotless) form is gated; a namespaced `ns.Box(..)` head + /// is an explicit qualified reach and is exempt (the caller skips this gate). + fn headTypeLeak(self: *Lowering, name: []const u8, span: ?ast.Span) bool { + if (self.emitting_default_context) return false; + if (self.program_index.module_decls == null or self.program_index.flat_import_graph == null) return false; + const from = self.current_source_file orelse return false; + // Reachable as a TYPE author over own / direct-flat edges → visible. + if (self.moduleTypeAuthor(from, name) != null) return false; + if (self.flatTypeAuthorCount(name, from) != .none) return false; + // A block-local generic declared in THIS source is visible here. + if (self.localTypeInSource(from, name)) return false; + // Authored as a TYPE somewhere but unreachable from `from` → a leak. + if (!self.nameAuthoredAsTypeAnywhere(name)) return false; + if (self.diagnostics) |d| + d.addFmt(.err, span, "type '{s}' is not visible; #import the module that declares it", .{name}); + return true; + } + + /// Single-hop non-transitive visibility gate for an UNQUALIFIED type-returning + /// FUNCTION head used as a type (`Make(N, T)` where `Make :: ($K, $T) -> Type`). + /// A type-fn is a `fn_decl`, so its visibility is FUNCTION visibility + /// (`isNameVisible`, the single-hop flat name set) — NOT the type-author model. + /// Emits + returns TRUE when the fn is authored somewhere but not reachable + /// from the use site (a 2-flat-hop leak). A scope-local (mangled) type-fn is + /// visible in its own scope and 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 { + if (self.emitting_default_context) return false; + if (self.current_source_file == null) return false; + if (self.scope) |s| if (s.lookupFn(name) != null) return false; + if (self.isNameVisible(name)) return false; + if (self.diagnostics) |d| + d.addFmt(.err, span, "type '{s}' is not visible; #import the module that declares it", .{name}); + return true; + } + /// 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 { + // A namespaced callee (`ns.Box(..)`) is an explicit qualified reach and is + // exempt from the bare-head visibility gate; only a plain identifier head + // is policed (E4). + const is_qualified = cl.callee.data == .field_access; const callee_name: []const u8 = switch (cl.callee.data) { .identifier => |id| id.name, .field_access => |fa| fa.field, @@ -13836,6 +13916,7 @@ pub const Lowering = struct { } // User-defined generic struct if (self.program_index.struct_template_map.getPtr(callee_name)) |tmpl| { + if (!is_qualified and self.headTypeLeak(callee_name, cl.callee.span)) return .unresolved; return self.instantiateGenericStruct(tmpl, cl.args); } // User-defined type-returning function: Complex(u32), Sx(f32) @@ -13843,6 +13924,7 @@ pub const Lowering = struct { const resolved_name = if (self.scope) |scope| (scope.lookupFn(callee_name) orelse callee_name) else callee_name; if (self.program_index.fn_ast_map.get(resolved_name)) |fd| { if (fd.type_params.len > 0) { + if (!is_qualified and self.headFnLeak(callee_name, cl.callee.span)) return .unresolved; if (self.instantiateTypeFunction(callee_name, callee_name, fd, cl.args)) |ty| { return ty; } @@ -13859,6 +13941,10 @@ pub const Lowering = struct { fn resolveParameterizedWithBindings(self: *Lowering, pt: *const ast.ParameterizedTypeExpr, span: ?ast.Span) TypeId { const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name; const table = &self.module.types; + // A namespaced base (`ns.Box(..)`) is an explicit qualified reach and is + // exempt from the bare-head visibility gate; only a dotless head is + // policed (E4). + const is_qualified = std.mem.indexOfScalar(u8, pt.name, '.') != null; // Vector(N, T) — built-in parameterized type. A backtick raw base // (`` `Vector(…) ``) is the LITERAL user type named `Vector`, so it @@ -13873,6 +13959,7 @@ pub const Lowering = struct { // User-defined generic struct: look up template and instantiate if (self.program_index.struct_template_map.getPtr(base_name)) |tmpl| { + if (!is_qualified and self.headTypeLeak(base_name, span)) return .unresolved; return self.instantiateGenericStruct(tmpl, pt.args); } @@ -13880,6 +13967,7 @@ pub const Lowering = struct { // 16-byte protocol value with the type-arg bound (not a 0-field stub). if (self.program_index.protocol_ast_map.get(base_name)) |pd| { if (pd.type_params.len > 0) { + if (!is_qualified and self.headTypeLeak(base_name, span)) return .unresolved; return self.instantiateParamProtocol(pd, pt.args); } } @@ -13892,6 +13980,7 @@ pub const Lowering = struct { const resolved_name = if (self.scope) |scope| (scope.lookupFn(base_name) orelse base_name) else base_name; if (self.program_index.fn_ast_map.get(resolved_name)) |fd| { if (fd.type_params.len > 0) { + if (!is_qualified and self.headFnLeak(base_name, span)) return .unresolved; if (self.instantiateTypeFunction(base_name, base_name, fd, pt.args)) |ty| { return ty; }