diff --git a/examples/0759-modules-undeclared-type-in-import.sx b/examples/0759-modules-undeclared-type-in-import.sx new file mode 100644 index 0000000..0bc9c14 --- /dev/null +++ b/examples/0759-modules-undeclared-type-in-import.sx @@ -0,0 +1,22 @@ +// A genuinely-undeclared type name used in an IMPORTED (non-main) module must +// emit a clean "unknown type" diagnostic, not silently compile. +// +// The `UnknownTypeChecker` only walks MAIN-file decls — imported / library +// modules are trusted and never checked. So an undeclared type name in an +// imported module used to fall through the type leaf's empty-struct stub and +// silently fabricate a 0-field struct: `make_thing()` below compiled and ran +// (printing `thing.x = 42`) even though `lib.sx` references the non-existent +// type `Coordnate`. The source-aware nominal leaf now poisons a genuinely- +// undeclared name with the `.unresolved` sentinel and emits the diagnostic at +// the reference, so the typo surfaces instead of mis-sizing `Thing` downstream. +// +// Expected: `error: unknown type 'Coordnate'` pointing into lib.sx; exit 1. +// Regression (stdlib E3). +#import "modules/std.sx"; + +#import "0759-modules-undeclared-type-in-import/lib.sx"; + +main :: () -> s32 { + print("thing.x = {}\n", make_thing()); + return 0; +} diff --git a/examples/0759-modules-undeclared-type-in-import/lib.sx b/examples/0759-modules-undeclared-type-in-import/lib.sx new file mode 100644 index 0000000..3806618 --- /dev/null +++ b/examples/0759-modules-undeclared-type-in-import/lib.sx @@ -0,0 +1,14 @@ +// Flat-imported helper. `Coordnate` is a typo — no such type is declared +// anywhere. Because this module is imported (not the main file), the +// `UnknownTypeChecker` trusts it and never walks it, so the type leaf is the +// sole guard against the silently-fabricated empty-struct stub. +Thing :: struct { + x: s32; + y: Coordnate; +} + +make_thing :: () -> s32 { + t : Thing = ---; + t.x = 42; + return t.x; +} diff --git a/examples/expected/0759-modules-undeclared-type-in-import.exit b/examples/expected/0759-modules-undeclared-type-in-import.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0759-modules-undeclared-type-in-import.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0759-modules-undeclared-type-in-import.stderr b/examples/expected/0759-modules-undeclared-type-in-import.stderr new file mode 100644 index 0000000..5b1998d --- /dev/null +++ b/examples/expected/0759-modules-undeclared-type-in-import.stderr @@ -0,0 +1,5 @@ +error: unknown type 'Coordnate' + --> examples/0759-modules-undeclared-type-in-import/lib.sx:7:8 + | + 7 | y: Coordnate; + | ^^^^^^^^^ diff --git a/examples/expected/0759-modules-undeclared-type-in-import.stdout b/examples/expected/0759-modules-undeclared-type-in-import.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0759-modules-undeclared-type-in-import.stdout @@ -0,0 +1 @@ + diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9a82fc5..b5bae0b 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1002,7 +1002,7 @@ pub const Lowering = struct { // `.ambiguous` (same-name RHS authored by ≥2 flat // imports) leaves A unwritten like `.not_visible`; // the loud diagnostic fires where A is USED. - .pending, .undeclared, .not_visible, .ambiguous => {}, + .pending, .forward, .undeclared, .not_visible, .ambiguous => {}, } } } @@ -1463,12 +1463,13 @@ pub const Lowering = struct { progressed = true; }, // B not yet a resolved type author from this source: a forward - // alias still pending (re-tried next round), an undeclared - // name, a namespaced-only type that is not bare-aliasable, or - // an ambiguous same-name shadow (≥2 flat authors). Leave A + // alias still pending (re-tried next round), a forward / not- + // yet-registered named author, an undeclared name, a + // namespaced-only type that is not bare-aliasable, or an + // ambiguous same-name shadow (≥2 flat authors). Leave A // unwritten — no global last-wins leak; the ambiguity surfaces // where A is used. - .pending, .undeclared, .not_visible, .ambiguous => {}, + .pending, .forward, .undeclared, .not_visible, .ambiguous => {}, } } } @@ -1750,10 +1751,27 @@ pub const Lowering = struct { /// A const author is visible but its alias target is not resolved yet — /// a forward identifier alias. Routes back into the existing /// `resolveForwardIdentifierAliases` fixpoint (source-aware in E1.5). + /// `resolveNominalLeaf` keeps the empty-struct stub (the alias resolves on + /// a later fixpoint round). pending, - /// No flat-visible (own ∪ flat-import) author declares `name` as a type. - /// E1 keeps the existing empty-struct stub; E3 turns this into the - /// `.unresolved` sentinel + a diagnostic. + /// A flat-visible author DOES declare `name` as a type, but its TypeId + /// slot is not registered yet — a forward / self / mutual reference + /// resolved mid-registration (`next: *ArenaChunk`), or a foreign / + /// lazily-registered author with no `findByName` slot. `resolveNominalLeaf` + /// keeps the empty-struct stub, which `internNamedTypeDecl` ADOPTS (key- + /// stable `updatePreservingKey`) when the type registers — so the forward + /// reference binds to the eventually-filled type. NOT an error: the author + /// exists, it is simply not interned yet. + forward, + /// NO author anywhere declares `name` as a type, an alias, or a const — + /// a genuinely-undeclared name (a typo, or a value parameter used as a + /// type). `resolveNominalLeaf` poisons it with the `.unresolved` sentinel + /// + an "unknown type" diagnostic, never a silently-fabricated 0-field + /// struct (which would mis-size every downstream load / store). In the + /// MAIN file the `UnknownTypeChecker` is the diagnostic authority (it owns + /// scope context + value-param hints, and a valid unbound generic leaf + /// like `-> T` on a template legitimately lands here), so the leaf keeps + /// the legacy stub there and defers the diagnostic to the checker. undeclared, /// `name` IS a registered named type, but it is reachable from the /// querying module ONLY through a namespaced import — not bare-visible @@ -1942,7 +1960,10 @@ pub const Lowering = struct { .alias => |tid| return .{ .resolved = tid }, .named => |ref| { if (self.namedRefTid(ref, name)) |tid| return .{ .resolved = tid }; - return .undeclared; + // The author exists but its slot is not interned yet (self / + // forward / mutual reference resolved mid-registration) — a + // forward stub the type adopts when it registers, NOT undeclared. + return .forward; }, }; switch (self.flatTypeAuthorCount(name, from)) { @@ -1952,7 +1973,7 @@ pub const Lowering = struct { // A flat author exists but is not registered as a findByName-able type // yet (a forward reference, or a foreign / lazily-registered class) → // the legacy empty-struct stub, NOT a namespaced-only leak (arm 3). - .unregistered => return .undeclared, + .unregistered => return .forward, } // 2. A block-local type (declared inside a fn / init body) clobbers the @@ -2169,26 +2190,50 @@ pub const Lowering = struct { } /// 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). `.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. + /// Routes through the source-aware `selectNominalLeaf`. `.pending` (forward + /// alias) and `.forward` (a real author not interned yet — self / forward / + /// foreign reference) keep the empty-struct stub, which the type ADOPTS on + /// registration (`internNamedTypeDecl`). `.undeclared` (NO author anywhere) + /// is genuinely-undeclared: in a NON-main module — which the + /// `UnknownTypeChecker` trusts and never walks — the leaf is the only guard, + /// so it emits "unknown type" and poisons with `.unresolved` (never a silent + /// 0-field struct). In the MAIN file the checker owns the diagnostic (and a + /// valid unbound generic leaf legitimately reaches here), so the leaf keeps + /// the legacy stub. `.not_visible` / `.ambiguous` surface their own loud + /// diagnostic + `.unresolved`. 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)) { .resolved => |t| t, - // The legacy empty-struct stub for an as-yet-unregistered / forward - // name — `resolveNamed`'s tail, reproduced for byte-identity. A raw - // or non-raw bare name both land the same struct stub here. - .pending, .undeclared => self.module.types.intern(.{ .@"struct" = .{ + // A forward alias (`.pending`) or a forward / not-yet-interned named + // author (`.forward`) — keep the empty-struct stub the type adopts + // when it registers. A raw or non-raw bare name both land the same + // stub here. + .pending, .forward => self.module.types.intern(.{ .@"struct" = .{ .name = self.module.types.internString(name), .fields = &.{}, } }), + // Genuinely undeclared: no type / alias / const author anywhere. + .undeclared => { + // The MAIN file is the `UnknownTypeChecker`'s domain — it emits + // the canonical "unknown type" (with scope context + value-param + // hints) and `hasErrors` halts before the stub reaches codegen, + // and a valid unbound generic leaf (`-> T` on a template) also + // lands here — so keep the legacy stub and do NOT double-report. + // A NON-main (imported / library) module is checker-trusted, so + // this leaf is the sole guard: emit + poison with `.unresolved`. + const is_main = if (self.main_file) |mf| std.mem.eql(u8, from, mf) else true; + if (is_main) return self.module.types.intern(.{ .@"struct" = .{ + .name = self.module.types.internString(name), + .fields = &.{}, + } }); + if (self.diagnostics) |d| + d.addFmt(.err, span, "unknown type '{s}'", .{name}); + return .unresolved; + }, // 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. @@ -6771,7 +6816,7 @@ pub const Lowering = struct { } return .unresolved; }, - .parameterized_type_expr => |pt| return self.resolveParameterizedWithBindings(&pt), + .parameterized_type_expr => |pt| return self.resolveParameterizedWithBindings(&pt, te.span), .identifier => |id| { const name_id = self.module.types.internString(id.name); return self.module.types.findByName(name_id) orelse .unresolved; @@ -13507,7 +13552,7 @@ pub const Lowering = struct { if (TypeResolver.resolveBinding(node, self.resolveEnv())) |t| return t; // Even without active type_bindings, handle parameterized types with struct templates if (node.data == .parameterized_type_expr) { - return self.resolveParameterizedWithBindings(&node.data.parameterized_type_expr); + return self.resolveParameterizedWithBindings(&node.data.parameterized_type_expr, node.span); } if (node.data == .call) { return self.resolveTypeCallWithBindings(&node.data.call); @@ -13719,7 +13764,8 @@ pub const Lowering = struct { /// Resolve a parameterized type expr, substituting bindings for type/value params. /// Handles both built-in types (Vector) and user-defined generic structs. - fn resolveParameterizedWithBindings(self: *Lowering, pt: *const ast.ParameterizedTypeExpr) TypeId { + /// `span` locates the reference for the unresolved-base diagnostic. + 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; @@ -13761,9 +13807,14 @@ pub const Lowering = struct { } } - // Fallback: register as named type placeholder - const name_id = table.internString(pt.name); - return table.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } }); + // The base names no known type constructor — not Vector, not a generic + // struct template, not a parameterized protocol, not a type-returning + // function. A silent 0-field stub here would mis-size every downstream + // `b.field` / `b.len`; emit the diagnostic and poison with `.unresolved` + // (the `.call`-node sibling `resolveTypeCallWithBindings` already poisons). + if (self.diagnostics) |d| + d.addFmt(.err, span, "unknown type '{s}'", .{base_name}); + return .unresolved; } /// Instantiate a generic struct template with concrete args.