From c490ffcfe981c22761d8a874f9314647acb7e705 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 2 Jun 2026 10:24:30 +0300 Subject: [PATCH] fix(types): reject unknown type names instead of silent empty struct (issue 0064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An identifier used in a type position that resolved to nothing fell through to `type_bridge.resolveTypeName`'s empty-struct-stub fallback, silently interning a 0-field struct named after the identifier. A value parameter mistakenly used as a type (`(T: Type, ...) -> T`, missing the `$`) or a typo'd type name therefore compiled and ran, rendering as `T{}`. New post-scan diagnostic pass `checkUnknownTypeNames` (lower.zig Pass 1f) walks every main-file function signature and non-generic struct field type and rejects any leaf name that is not a primitive, an in-scope generic param (`$T` / `type_params`), a declared type, or a real (non-stub) registered type. The load-bearing empty-struct stub is left intact — forward references and foreign-class opaque types still depend on it during the scan — and the pass runs before body lowering, so `hasErrors()` halts the build before any stub reaches codegen. A value param used as a type gets a tailored hint to write `$T: Type`; a genuine unknown gets "unknown type 'X'". Imported concrete types are recognized via the type table, and inline compound spellings (`[:0]u8`), arbitrary-width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are exempted to avoid false positives. Regressions: examples/1111 (tailored hint) + 1112 (typo'd field type). --- ...agnostics-nondollar-type-param-rejected.sx | 10 + ...-diagnostics-unknown-type-name-rejected.sx | 13 ++ ...nostics-nondollar-type-param-rejected.exit | 1 + ...stics-nondollar-type-param-rejected.stderr | 11 ++ ...stics-nondollar-type-param-rejected.stdout | 1 + ...iagnostics-unknown-type-name-rejected.exit | 1 + ...gnostics-unknown-type-name-rejected.stderr | 5 + ...gnostics-unknown-type-name-rejected.stdout | 1 + ...ondollar-type-param-silent-empty-struct.md | 21 ++ src/ir/lower.zig | 185 ++++++++++++++++++ 10 files changed, 249 insertions(+) create mode 100644 examples/1111-diagnostics-nondollar-type-param-rejected.sx create mode 100644 examples/1112-diagnostics-unknown-type-name-rejected.sx create mode 100644 examples/expected/1111-diagnostics-nondollar-type-param-rejected.exit create mode 100644 examples/expected/1111-diagnostics-nondollar-type-param-rejected.stderr create mode 100644 examples/expected/1111-diagnostics-nondollar-type-param-rejected.stdout create mode 100644 examples/expected/1112-diagnostics-unknown-type-name-rejected.exit create mode 100644 examples/expected/1112-diagnostics-unknown-type-name-rejected.stderr create mode 100644 examples/expected/1112-diagnostics-unknown-type-name-rejected.stdout diff --git a/examples/1111-diagnostics-nondollar-type-param-rejected.sx b/examples/1111-diagnostics-nondollar-type-param-rejected.sx new file mode 100644 index 0000000..bf35270 --- /dev/null +++ b/examples/1111-diagnostics-nondollar-type-param-rejected.sx @@ -0,0 +1,10 @@ +// A value parameter declared `T: Type` (WITHOUT the `$` generic sigil) used in +// a type position is rejected with a hint to write `$T: Type`. Previously the +// type resolver silently fabricated a 0-field struct named `T`, so the call +// compiled and rendered as `T{}` at runtime with no diagnostic. +// Regression (issue 0064). Expected: one error per `T` use site; exit 1. +idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); } + +main :: () -> s32 { + return idwrap(s32, closure(() -> s32 { return 7; })); +} diff --git a/examples/1112-diagnostics-unknown-type-name-rejected.sx b/examples/1112-diagnostics-unknown-type-name-rejected.sx new file mode 100644 index 0000000..33f09cc --- /dev/null +++ b/examples/1112-diagnostics-unknown-type-name-rejected.sx @@ -0,0 +1,13 @@ +// An identifier used in a type position that names no declared type, builtin, +// or in-scope generic parameter is rejected. Previously the type resolver's +// empty-struct-stub fallback silently interned a 0-field struct under the typo, +// so the program compiled and ran. Regression (issue 0064, broader fix). +// Expected: a clean "unknown type" error at the field; exit 1. +Point :: struct { + x: s32; + y: Coordnate; // typo for a non-existent type +} + +main :: () -> s32 { + return 0; +} diff --git a/examples/expected/1111-diagnostics-nondollar-type-param-rejected.exit b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stderr b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stderr new file mode 100644 index 0000000..dffa4bf --- /dev/null +++ b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stderr @@ -0,0 +1,11 @@ +error: 'T' is a value parameter, not a type; introduce a generic type parameter with `$T: Type` + --> /Users/agra/projects/sx/examples/1111-diagnostics-nondollar-type-param-rejected.sx:6:37 + | + 6 | idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); } + | ^ + +error: 'T' is a value parameter, not a type; introduce a generic type parameter with `$T: Type` + --> /Users/agra/projects/sx/examples/1111-diagnostics-nondollar-type-param-rejected.sx:6:43 + | + 6 | idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); } + | ^ diff --git a/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stdout b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1111-diagnostics-nondollar-type-param-rejected.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1112-diagnostics-unknown-type-name-rejected.exit b/examples/expected/1112-diagnostics-unknown-type-name-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1112-diagnostics-unknown-type-name-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1112-diagnostics-unknown-type-name-rejected.stderr b/examples/expected/1112-diagnostics-unknown-type-name-rejected.stderr new file mode 100644 index 0000000..a41cc74 --- /dev/null +++ b/examples/expected/1112-diagnostics-unknown-type-name-rejected.stderr @@ -0,0 +1,5 @@ +error: unknown type 'Coordnate' + --> /Users/agra/projects/sx/examples/1112-diagnostics-unknown-type-name-rejected.sx:8:8 + | + 8 | y: Coordnate; // typo for a non-existent type + | ^^^^^^^^^ diff --git a/examples/expected/1112-diagnostics-unknown-type-name-rejected.stdout b/examples/expected/1112-diagnostics-unknown-type-name-rejected.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1112-diagnostics-unknown-type-name-rejected.stdout @@ -0,0 +1 @@ + diff --git a/issues/0064-nondollar-type-param-silent-empty-struct.md b/issues/0064-nondollar-type-param-silent-empty-struct.md index ab736ce..6263e18 100644 --- a/issues/0064-nondollar-type-param-silent-empty-struct.md +++ b/issues/0064-nondollar-type-param-silent-empty-struct.md @@ -1,5 +1,26 @@ # 0064 — non-`$` `T: Type` function param used as a type silently yields `{}` +> **✅ RESOLVED (2026-06-02).** Root cause as diagnosed: an identifier in a type +> position that resolved to nothing fell through to `type_bridge.resolveTypeName`'s +> empty-struct stub, silently interning a 0-field struct under the name. **Fix +> (option 2, surfaced as a diagnostic):** a new post-scan pass +> `checkUnknownTypeNames` ([src/ir/lower.zig], Pass 1f) walks every main-file +> function signature and non-generic struct field type and rejects any leaf name +> that is not a primitive, an in-scope generic param (`$T` / `type_params`), a +> declared type, or a real (non-stub) registered type. The load-bearing +> empty-struct stub is left intact (forward references + foreign-class opaque +> types still rely on it during the scan); the pass runs after scanning and before +> body lowering, so `core.zig`'s `hasErrors()` halts the build before any stub +> reaches codegen. A value param used as a type gets the tailored hint +> *"'T' is a value parameter, not a type; introduce a generic type parameter with +> `$T: Type`"*; a genuine unknown name gets *"unknown type 'X'"*. Imported concrete +> types are recognized via the type table (`findByName`), so cross-module +> references aren't false-flagged; inline compound spellings (`[:0]u8`), arbitrary- +> width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are all exempted. +> Regression tests: `examples/1111-diagnostics-nondollar-type-param-rejected.sx` +> (tailored hint, exit 1) and `examples/1112-diagnostics-unknown-type-name-rejected.sx` +> (typo'd field type, exit 1). Suite: 347 passed, 0 failed. + ## Symptom A function parameter declared `T: Type` (or lowercase `T: type`) — i.e. WITHOUT diff --git a/src/ir/lower.zig b/src/ir/lower.zig index f96a202..bb7326e 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -367,6 +367,12 @@ pub const Lowering = struct { // before body lowering — purely a diagnostic pass; `core.zig` halts on // any error before codegen. self.checkErrorFlow(decls); + // Pass 1f: reject identifiers used in a type position that name no + // declared type / primitive / in-scope generic param (issue 0064). + // Runs after scanning (so every real type name is registered) and + // before body lowering, so the diagnostic halts via `core.zig` + // `hasErrors()` before the empty-struct stub can reach codegen. + self.checkUnknownTypeNames(decls); // Pass 2: lower main (and comptime side-effects) self.lowerMainAndComptime(decls); // Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered @@ -518,6 +524,185 @@ pub const Lowering = struct { } } + /// Diagnostic pass (issue 0064): reject an identifier used in a type + /// position that names no declared type, primitive, or in-scope generic + /// type parameter. Without it, `type_bridge.resolveTypeName`'s + /// empty-struct-stub fallback silently fabricates a 0-field struct named + /// after the unknown identifier — so a value param mistakenly used as a + /// type (`(T: Type, …) -> T`, missing the `$`) or a typo'd type name + /// compiles and runs, rendering as `T{}`. Main-file decls only; imported / + /// library modules are trusted, matching `checkErrorFlow`. + fn checkUnknownTypeNames(self: *Lowering, decls: []const *const Node) void { + if (self.diagnostics == null) return; + var declared = std.StringHashMap(void).init(self.alloc); + defer declared.deinit(); + self.collectDeclaredTypeNames(decls, &declared); + for (decls) |decl| { + if (self.main_file) |mf| { + if (decl.source_file) |sf| { + if (!std.mem.eql(u8, sf, mf)) continue; + } + } + switch (decl.data) { + .fn_decl => self.checkFnSignatureTypes(&decl.data.fn_decl, &declared), + .struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared), + .const_decl => |cd| switch (cd.value.data) { + .fn_decl => self.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared), + .struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared), + else => {}, + }, + else => {}, + } + } + } + + /// Collect every top-level name that can legitimately appear in a type + /// position: const-decl names (covers `T :: struct/enum/union/error/alias` + /// and value consts), plus the scan-populated foreign-class / generic- + /// template / protocol / alias maps. Built across ALL files so a main-file + /// reference to an imported type isn't flagged. + fn collectDeclaredTypeNames(self: *Lowering, decls: []const *const Node, out: *std.StringHashMap(void)) void { + for (decls) |decl| { + switch (decl.data) { + .const_decl => |cd| out.put(cd.name, {}) catch {}, + .struct_decl => |sd| out.put(sd.name, {}) catch {}, + else => {}, + } + } + var it_fc = self.foreign_class_map.keyIterator(); + while (it_fc.next()) |k| out.put(k.*, {}) catch {}; + var it_tmpl = self.struct_template_map.keyIterator(); + while (it_tmpl.next()) |k| out.put(k.*, {}) catch {}; + var it_pd = self.protocol_decl_map.keyIterator(); + while (it_pd.next()) |k| out.put(k.*, {}) catch {}; + var it_pa = self.protocol_ast_map.keyIterator(); + while (it_pa.next()) |k| out.put(k.*, {}) catch {}; + var it_al = self.type_alias_map.keyIterator(); + while (it_al.next()) |k| out.put(k.*, {}) catch {}; + } + + fn checkStructFieldTypes(self: *Lowering, sd: *const ast.StructDecl, declared: *std.StringHashMap(void)) void { + // Generic struct fields reference the struct's own type params ($T) — + // resolved at instantiation, not here. + if (sd.type_params.len != 0) return; + for (sd.field_types) |ft| self.checkTypeNodeForUnknown(ft, declared, &.{}, &.{}); + } + + fn checkFnSignatureTypes(self: *Lowering, fd: *const ast.FnDecl, declared: *std.StringHashMap(void)) void { + // Value params declared `: Type` (no `$`) — using one in a type + // position is the issue-0064 misuse; surface a tailored hint. + var type_vals = std.ArrayList([]const u8).empty; + defer type_vals.deinit(self.alloc); + for (fd.params) |p| { + if (p.type_expr.data == .type_expr) { + const cn = p.type_expr.data.type_expr.name; + if (std.mem.eql(u8, cn, "Type") or std.mem.eql(u8, cn, "type")) { + type_vals.append(self.alloc, p.name) catch {}; + } + } + } + for (fd.params) |p| self.checkTypeNodeForUnknown(p.type_expr, declared, fd.type_params, type_vals.items); + if (fd.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, fd.type_params, type_vals.items); + } + + /// Recurse a type-annotation node to its leaf names, reporting any unknown. + fn checkTypeNodeForUnknown( + self: *Lowering, + node: *const Node, + declared: *std.StringHashMap(void), + in_scope: []const ast.StructTypeParam, + type_vals: []const []const u8, + ) void { + switch (node.data) { + // A `$`-prefixed name (`-> $R`) introduces/references a generic type + // param inline — always valid in a type position. + .type_expr => |te| if (!te.is_generic) self.reportIfUnknownType(te.name, node.span, declared, in_scope, type_vals), + .identifier => |id| self.reportIfUnknownType(id.name, node.span, declared, in_scope, type_vals), + .pointer_type_expr => |pt| self.checkTypeNodeForUnknown(pt.pointee_type, declared, in_scope, type_vals), + .many_pointer_type_expr => |mp| self.checkTypeNodeForUnknown(mp.element_type, declared, in_scope, type_vals), + .slice_type_expr => |st| self.checkTypeNodeForUnknown(st.element_type, declared, in_scope, type_vals), + .optional_type_expr => |ot| self.checkTypeNodeForUnknown(ot.inner_type, declared, in_scope, type_vals), + .array_type_expr => |at| self.checkTypeNodeForUnknown(at.element_type, declared, in_scope, type_vals), + .tuple_type_expr => |tt| for (tt.field_types) |ft| self.checkTypeNodeForUnknown(ft, declared, in_scope, type_vals), + .function_type_expr => |ft| { + for (ft.param_types) |pt| self.checkTypeNodeForUnknown(pt, declared, in_scope, type_vals); + if (ft.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope, type_vals); + }, + .closure_type_expr => |ct| { + // Variadic type-pack closures (`Closure(..$args) -> R`) resolve + // their projections specially — don't walk them here. + if (ct.pack_name != null) return; + for (ct.param_types) |pt| self.checkTypeNodeForUnknown(pt, declared, in_scope, type_vals); + if (ct.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope, type_vals); + }, + // Builtin constructors (Vector) and generic templates resolve the + // base name specially; just check the type args. + .parameterized_type_expr => |pt| for (pt.args) |a| self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals), + else => {}, + } + } + + fn reportIfUnknownType( + self: *Lowering, + name: []const u8, + span: ?ast.Span, + declared: *std.StringHashMap(void), + in_scope: []const ast.StructTypeParam, + type_vals: []const []const u8, + ) void { + // Only bare identifiers are validated. Inline-spelled compound types + // (`[:0]u8`, `mod.Type`, …) carry non-identifier characters — trust them. + if (!isIdentLike(name)) return; + if (isBuiltinTypeName(name)) return; + for (in_scope) |tp| if (std.mem.eql(u8, tp.name, name)) return; + if (declared.contains(name)) return; + // Registered as a real (non-stub) type — covers imported concrete + // structs / enums / unions absent from the main-file decl list. A + // fabricated empty-struct stub (the very thing we're catching) is the + // sole 0-field-struct case, so it doesn't suppress the diagnostic. + const sid = self.module.types.internString(name); + if (self.module.types.findByName(sid)) |tid| { + const info = self.module.types.get(tid); + const empty_struct_stub = info == .@"struct" and info.@"struct".fields.len == 0; + if (!empty_struct_stub) return; + } + const diags = self.diagnostics orelse return; + for (type_vals) |tv| { + if (std.mem.eql(u8, tv, name)) { + diags.addFmt(.err, span, "'{s}' is a value parameter, not a type; introduce a generic type parameter with `${s}: Type`", .{ name, name }); + return; + } + } + diags.addFmt(.err, span, "unknown type '{s}'", .{name}); + } + + fn isBuiltinTypeName(name: []const u8) bool { + if (type_bridge.resolveTypePrimitive(name) != null) return true; + // Arbitrary-width integers / floats: u1, s7, u128, f16, f80, … + if (name.len >= 2 and (name[0] == 'u' or name[0] == 's' or name[0] == 'f')) { + var all_digits = true; + for (name[1..]) |c| { + if (!std.ascii.isDigit(c)) { + all_digits = false; + break; + } + } + if (all_digits) return true; + } + const extra = [_][]const u8{ "Type", "type", "int", "float", "Self", "self", "any", "noreturn", "usize", "isize", "comptime_int", "comptime_float" }; + for (extra) |e| if (std.mem.eql(u8, name, e)) return true; + return false; + } + + fn isIdentLike(name: []const u8) bool { + if (name.len == 0) return false; + if (!(std.ascii.isAlphabetic(name[0]) or name[0] == '_')) return false; + for (name) |c| { + if (!(std.ascii.isAlphanumeric(c) or c == '_')) return false; + } + return true; + } + /// Analyze one function (or lambda) body as its own boundary — a fresh /// binding context and an empty proven set. fn analyzeFnBody(self: *Lowering, body: *const Node) void {