fix(stdlib/E4): gate unqualified parameterized type heads non-transitively

attempt-3: extend the E4 single-hop bare-TYPE gate to parameterized type
HEADS (the constructor-head analog of the bare-leaf gate). Before this, the
head lookup hit the global struct_template_map / protocol_ast_map /
fn_ast_map *before* any source-aware visibility check, so a 2-flat-hop
imported generic struct/protocol/type-fn remained bare-visible (e.g.
`Box(s64)` when main imports only b.sx and b.sx imports c.sx).

- headTypeLeak: generic-struct / parameterized-protocol heads use the same
  type-author single-hop model as the bare-leaf gate (moduleTypeAuthor +
  flatTypeAuthorCount + localTypeInSource + nameAuthoredAsTypeAnywhere).
- headFnLeak: type-returning-function heads use single-hop function
  visibility (isNameVisible), exempting scope-local mangled type-fns.
- Gated at every unqualified head site: resolveParameterizedWithBindings,
  resolveTypeCallWithBindings, the scanDecls alias-decl dispatch (poisoning
  the alias with .unresolved on leak), resolveArrayLiteralType, and the
  generic-static-method call path. Namespaced (`ns.Box(..)`) heads are an
  explicit qualified reach and stay exempt. Source-pinned instantiation
  (E3/E4) is preserved, so library-internal heads still resolve where they
  are visible.

Regression: examples/0764-modules-import-generic-head-non-transitive
(2-hop `Box(s64)` -> "type 'Box' is not visible", exit 1; direct #import
resolves). Fails-before on a250964 (printed 3), passes-after.

README: note the non-transitive rule covers parameterized type heads.

Gate: zig build 0, zig build test 0 (LSP 522, 423/423), run_examples
505/0, FFI 12xx/13xx/14xx green, 0706/0763/0544/0105 green & byte-identical,
m3te ios-sim build+launch exit 0.
This commit is contained in:
agra
2026-06-08 12:37:00 +03:00
parent a250964ced
commit 4f99fb0d85
8 changed files with 140 additions and 8 deletions

View File

@@ -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;
}