fix(lower): genuinely-undeclared type → diagnostic + .unresolved (no silent stub) [stdlib E3]
Phase E3: remove the silent empty-struct fall-throughs in type resolution for genuinely-undeclared names, replacing them with a real "unknown type" diagnostic + the dedicated `.unresolved` sentinel (already present, with the sizeOf @panic tripwire) — the REJECTED-PATTERN this project bans. Split `TypeHeadResolution.undeclared` into `.forward` (a real author not interned yet — self/forward/mutual/foreign reference, adopted on registration via internNamedTypeDecl) vs `.undeclared` (NO author anywhere). `resolveNominalLeaf`: - `.pending` / `.forward` keep the empty-struct stub the type adopts on register. - `.undeclared` in a NON-main (imported/library) module — which the UnknownTypeChecker trusts and never walks — emits "unknown type 'X'" + poisons with `.unresolved`. In the MAIN file the checker owns the diagnostic (and a valid unbound generic leaf legitimately lands here), so the leaf keeps the legacy stub and does not double-report. Also convert the `parameterized_type_expr` constructor-head fallback (resolveParameterizedWithBindings): an unresolvable base now emits + returns `.unresolved` (mirroring the `.call`-node sibling) instead of a 0-field stub that mis-sizes `b.field` / `b.len`. Threads the reference span through both callers. Triage of the other empty-struct sites (all load-bearing on the green suite or unable to distinguish forward from undeclared — KEPT): resolveNamed's legacy namer (forward/generic/Self/foreign-opaque: R/Self/Object/Array), the foreign-class struct + JNI Self placeholders, the shadow-slot reservation, the type_bridge stateless pack/generic namer, and the struct-literal inference fallback (front-run by the leaf; 0 suite hits). Regression: examples/0759-modules-undeclared-type-in-import — an undeclared type in an imported module now errors (exit 1) instead of silently compiling (the pre-fix code printed `thing.x = 42`, exit 0). Gate: zig build; zig build test (423/423 + LSP corpus sweep); run_examples 497 passed / 0 failed (prior 496 byte-identical); m3te ios-sim build exit 0.
This commit is contained in:
22
examples/0759-modules-undeclared-type-in-import.sx
Normal file
22
examples/0759-modules-undeclared-type-in-import.sx
Normal file
@@ -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;
|
||||
}
|
||||
14
examples/0759-modules-undeclared-type-in-import/lib.sx
Normal file
14
examples/0759-modules-undeclared-type-in-import/lib.sx
Normal file
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
error: unknown type 'Coordnate'
|
||||
--> examples/0759-modules-undeclared-type-in-import/lib.sx:7:8
|
||||
|
|
||||
7 | y: Coordnate;
|
||||
| ^^^^^^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
107
src/ir/lower.zig
107
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.
|
||||
|
||||
Reference in New Issue
Block a user