ir: dedicated TypeId.unresolved sentinel; kill inferred_type => .s64

An unannotated param resolving to a plausible .s64 was the classic
silent-default trap (root of the 2.5 multi-param-closure bug). Replace it
with a dedicated TypeId.unresolved at slot 0, so a zero-initialised or
forgotten TypeId trips the sentinel instead of masquerading as a real type.

- types.zig: TypeId.unresolved = 0 (void moves to 17); TypeInfo.unresolved;
  sizeOf/toLLVMType @panic on it (codegen tripwire); hash/eql/printer cover it.
- type_bridge: inferred_type => .unresolved (was .s64).
- resolveParamType: emit "parameter 'x' has no type annotation" for a
  genuinely-unannotated value param (comptime/variadic/pack params exempt --
  they resolve via per-call substitution).
- lowerLambda: resolve unannotated params from the target closure signature;
  otherwise emit "cannot infer type of lambda parameter".
- CLAUDE.md: .void documented as an UNACCEPTABLE failed-type sentinel (it
  conflates with a real, heavily-checked type); prescribe a distinct
  .unresolved-style value + codegen tripwire.

Snapshot churn: one .ir (ffi-objc-call-06) -- the runtime type-name table and
typeof match arms renumber by the new builtin slot; program output unchanged.
This commit is contained in:
agra
2026-05-29 22:25:45 +03:00
parent 5fd513466f
commit 55e62694d1
6 changed files with 575 additions and 509 deletions

View File

@@ -93,10 +93,28 @@ everything else.
**Required:** when a lookup that *must* succeed fails, emit a **Required:** when a lookup that *must* succeed fails, emit a
diagnostic via `self.diagnostics.addFmt(.err, span, "...", .{...})` diagnostic via `self.diagnostics.addFmt(.err, span, "...", .{...})`
and return the most clearly-broken sentinel the calling code can and return a **dedicated, unmistakable sentinel** — one that can never
survive (e.g. `.void`, a `Ref.none`, or via a `?T` return that forces be confused with a legitimate result — or a `?T` return that forces the
the caller to handle the null). Errors must surface to the user as caller to handle the null. Errors must surface to the user as text, not
text, not as a silently-corrupted size or alignment. as a silently-corrupted size or alignment.
**`.void` is an UNACCEPTABLE sentinel for a failed *type* lookup.**
`void` is a real, heavily-checked type (void returns, void params, "no
value" markers), and pervasive `if (ty == .void) { skip / return-nothing }`
checks would silently swallow the failure — trading one silent default
(`.s64`) for another (`.void`) one layer down. The same objection rules
out `noreturn` (diverging expressions) and any other load-bearing builtin.
Instead, add a **distinct** `.unresolved`-style `TypeId` whose sole meaning
is "resolution failed". A dedicated value (1) can't be mistaken for a real
type by any downstream check, (2) makes the exhaustive `switch`es in the
type table fail to compile until every site handles it (forcing coverage),
and (3) can be a hard tripwire — `if (ty == .unresolved) @panic(...)` in
codegen/emit guarantees it never silently ships. The one-time plumbing
cost is exactly the trade this file mandates ("the field plumbing is a
one-time cost; silent-clobber debugging is forever"). The same principle
applies to non-type sentinels: prefer a `Ref.none`-style value that is
distinct from every valid result, not a real one that "looks broken
enough".
If you find an existing default-return in the compiler that swallows If you find an existing default-return in the compiler that swallows
a lookup failure, treat it as a discovered bug — file an issue per a lookup failure, treat it as a discovered bug — file an issue per

View File

@@ -4289,6 +4289,9 @@ pub const LLVMEmitter = struct {
// Comptime-only: a pack is expanded to flat positional args before // Comptime-only: a pack is expanded to flat positional args before
// codegen, so it must never reach LLVM type emission. // codegen, so it must never reach LLVM type emission.
.pack => @panic("pack type has no LLVM representation (comptime-only)"), .pack => @panic("pack type has no LLVM representation (comptime-only)"),
// Tripwire: a failed type resolution must have been diagnosed and
// aborted long before LLVM emission.
.unresolved => @panic("unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted"),
}; };
} }

View File

@@ -7101,13 +7101,22 @@ pub const Lowering = struct {
// User params follow the ctx (optional) + env slots in `params`. // User params follow the ctx (optional) + env slots in `params`.
const user_param_base: usize = (if (lambda_wants_ctx) @as(usize, 1) else 0) + 1; const user_param_base: usize = (if (lambda_wants_ctx) @as(usize, 1) else 0) + 1;
for (lam.params, 0..) |p, pi| { for (lam.params, 0..) |p, pi| {
var pty = self.resolveParamType(&p); const pty: TypeId = blk: {
// Infer param type from target closure type if no annotation // Unannotated lambda params take their type positionally from
if (p.type_expr.data == .inferred_type and target_closure_params != null) { // the target `Closure(T0, …)` signature. Resolve them here so
if (pi < target_closure_params.?.len) { // `resolveParamType` (which would diagnose a missing annotation)
pty = target_closure_params.?[pi]; // is only called for params that carry one.
if (p.type_expr.data == .inferred_type) {
if (target_closure_params != null and pi < target_closure_params.?.len) {
break :blk target_closure_params.?[pi];
}
if (self.diagnostics) |d| {
d.addFmt(.err, p.type_expr.span, "cannot infer type of lambda parameter '{s}'; annotate it or use the lambda where a closure type is expected", .{p.name});
}
break :blk .unresolved;
} }
} break :blk self.resolveParamType(&p);
};
params.append(self.alloc, .{ params.append(self.alloc, .{
.name = self.module.types.internString(p.name), .name = self.module.types.internString(p.name),
.ty = pty, .ty = pty,
@@ -10743,6 +10752,18 @@ pub const Lowering = struct {
} }
fn resolveParamType(self: *Lowering, p: *const ast.Param) TypeId { fn resolveParamType(self: *Lowering, p: *const ast.Param) TypeId {
// A plain value param with no annotation can only be typed from
// context (a lambda's target closure signature). When `resolveParamType`
// is reached for one, there is no such context — so it's a genuine
// "missing annotation" error, not an 8-byte-int guess. (Comptime/
// variadic pack params also carry `inferred_type` but get their types
// from per-call substitution, so they're exempt here.)
if (p.type_expr.data == .inferred_type and !p.is_comptime and !p.is_variadic and !p.is_pack) {
if (self.diagnostics) |d| {
d.addFmt(.err, p.type_expr.span, "parameter '{s}' has no type annotation", .{p.name});
}
return .unresolved;
}
const declared_ty = self.resolveTypeWithBindings(p.type_expr); const declared_ty = self.resolveTypeWithBindings(p.type_expr);
if (p.is_variadic) { if (p.is_variadic) {
// Two surface forms: // Two surface forms:

View File

@@ -37,7 +37,14 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable) TypeId {
}, },
.tuple_literal => |tl| resolveTupleLiteralAsType(&tl, table), .tuple_literal => |tl| resolveTupleLiteralAsType(&tl, table),
.parameterized_type_expr => |pt| resolveParameterizedType(&pt, table), .parameterized_type_expr => |pt| resolveParameterizedType(&pt, table),
.inferred_type => .s64, // inferred — default until we have type inference // An unannotated param. Its type must be resolved from context
// (contextual closure typing, generic binding, or pack substitution)
// *before* reaching here; if it doesn't, returning a plausible `.s64`
// silently fabricates an 8-byte int (the classic silent-default trap).
// Return the dedicated `.unresolved` sentinel — never a legitimate
// type — so the omission surfaces; the lowering-side `resolveParamType`
// turns it into a real diagnostic.
.inferred_type => .unresolved,
// Inline type declarations (used as field types) // Inline type declarations (used as field types)
.enum_decl => |ed| resolveInlineEnum(&ed, table), .enum_decl => |ed| resolveInlineEnum(&ed, table),
.struct_decl => |sd| resolveInlineStruct(&sd, table), .struct_decl => |sd| resolveInlineStruct(&sd, table),

View File

@@ -5,8 +5,16 @@ const Allocator = std.mem.Allocator;
// Opaque handle into the TypeTable. First 16 slots are reserved for builtins. // Opaque handle into the TypeTable. First 16 slots are reserved for builtins.
pub const TypeId = enum(u32) { pub const TypeId = enum(u32) {
// Builtin slots 015 // Builtin slots 017.
void = 0, /// Resolution failed (e.g. an unannotated param whose type was never
/// inferred from context). A dedicated sentinel — never a legitimate
/// result — so downstream `== .void`/`== .s64` checks can't silently
/// swallow it. Must never reach codegen; sizeOf/toLLVMType panic on it.
///
/// Deliberately slot 0: a zero-initialised or forgotten `TypeId` (the most
/// common accidental value) thus reads as `.unresolved` and trips the
/// tripwire, rather than silently masquerading as `.void`.
unresolved = 0,
bool = 1, bool = 1,
s8 = 2, s8 = 2,
s16 = 3, s16 = 3,
@@ -23,9 +31,10 @@ pub const TypeId = enum(u32) {
noreturn = 14, noreturn = 14,
isize = 15, isize = 15,
usize = 16, usize = 16,
_, // user-defined types start at 17 void = 17,
_, // user-defined types start at 18
pub const first_user: u32 = 17; pub const first_user: u32 = 18;
pub fn index(self: TypeId) u32 { pub fn index(self: TypeId) u32 {
return @intFromEnum(self); return @intFromEnum(self);
@@ -73,6 +82,8 @@ pub const TypeInfo = union(enum) {
noreturn, noreturn,
usize, usize,
isize, isize,
/// Resolution-failure sentinel (see `TypeId.unresolved`).
unresolved,
pub const StructInfo = struct { pub const StructInfo = struct {
name: StringId, name: StringId,
@@ -280,9 +291,9 @@ pub const TypeTable = struct {
.slice_arena = std.heap.ArenaAllocator.init(alloc), .slice_arena = std.heap.ArenaAllocator.init(alloc),
}; };
// Pre-populate builtin slots 015 (must match TypeId enum order) // Pre-populate builtin slots 017 (must match TypeId enum order)
const builtins = [_]TypeInfo{ const builtins = [_]TypeInfo{
.void, // 0 .unresolved, // 0: resolution-failure sentinel
.bool, // 1 .bool, // 1
.{ .signed = 8 }, // 2: s8 .{ .signed = 8 }, // 2: s8
.{ .signed = 16 }, // 3: s16 .{ .signed = 16 }, // 3: s16
@@ -299,6 +310,7 @@ pub const TypeTable = struct {
.noreturn, // 14 .noreturn, // 14
.isize, // 15: isize (pointer-sized signed) .isize, // 15: isize (pointer-sized signed)
.usize, // 16: usize (pointer-sized unsigned) .usize, // 16: usize (pointer-sized unsigned)
.void, // 17
}; };
for (&builtins) |info| { for (&builtins) |info| {
table.infos.append(alloc, info) catch unreachable; table.infos.append(alloc, info) catch unreachable;
@@ -477,6 +489,9 @@ pub const TypeTable = struct {
// Comptime-only: a pack must be expanded to flat positional args // Comptime-only: a pack must be expanded to flat positional args
// before codegen. Reaching runtime layout means a pack leaked. // before codegen. Reaching runtime layout means a pack leaked.
.pack => @panic("pack type has no runtime layout (comptime-only)"), .pack => @panic("pack type has no runtime layout (comptime-only)"),
// Tripwire: a failed type resolution must have surfaced a
// diagnostic and aborted before any layout query.
.unresolved => @panic("unresolved type reached sizeOf — a type resolution failure was not diagnosed/aborted"),
}; };
} }
@@ -674,6 +689,7 @@ pub const TypeTable = struct {
.noreturn => "noreturn", .noreturn => "noreturn",
.isize => "isize", .isize => "isize",
.usize => "usize", .usize => "usize",
.unresolved => "<unresolved>",
else => { else => {
// User types — format from TypeInfo // User types — format from TypeInfo
const info = self.get(id); const info = self.get(id);
@@ -813,7 +829,7 @@ fn hashTypeInfo(h: *std.hash.Wyhash, info: TypeInfo) void {
switch (info) { switch (info) {
.signed => |w| h.update(&.{w}), .signed => |w| h.update(&.{w}),
.unsigned => |w| h.update(&.{w}), .unsigned => |w| h.update(&.{w}),
.f32, .f64, .void, .bool, .string, .any, .noreturn, .usize, .isize => {}, .f32, .f64, .void, .bool, .string, .any, .noreturn, .usize, .isize, .unresolved => {},
.pointer => |p| h.update(std.mem.asBytes(&p.pointee)), .pointer => |p| h.update(std.mem.asBytes(&p.pointee)),
.many_pointer => |p| h.update(std.mem.asBytes(&p.element)), .many_pointer => |p| h.update(std.mem.asBytes(&p.element)),
.slice => |s| h.update(std.mem.asBytes(&s.element)), .slice => |s| h.update(std.mem.asBytes(&s.element)),
@@ -866,7 +882,7 @@ fn typeInfoEql(a: TypeInfo, b: TypeInfo) bool {
return switch (a) { return switch (a) {
.signed => |w| w == b.signed, .signed => |w| w == b.signed,
.unsigned => |w| w == b.unsigned, .unsigned => |w| w == b.unsigned,
.f32, .f64, .void, .bool, .string, .any, .noreturn, .usize, .isize => true, .f32, .f64, .void, .bool, .string, .any, .noreturn, .usize, .isize, .unresolved => true,
.pointer => |p| p.pointee == b.pointer.pointee, .pointer => |p| p.pointee == b.pointer.pointee,
.many_pointer => |p| p.element == b.many_pointer.element, .many_pointer => |p| p.element == b.many_pointer.element,
.slice => |s| s.element == b.slice.element, .slice => |s| s.element == b.slice.element,

File diff suppressed because it is too large Load Diff