fix(ir): reject non-type args to the 7 type-introspection builtins [F0.8]

size_of, align_of, field_count, type_name, type_eq, type_is_unsigned,
and is_flags silently reinterpreted a value argument as a type:
type_is_unsigned(6) read 6 as a TypeId index (types[6] = u8 -> true),
size_of(6)/size_of(true) sized its typeof (8), type_name(6) returned
types[6]'s name. Per Agra's ruling, all 7 now strictly require a type
(compile-time): a value argument is a compile error.

One shared guard (Lowering.reflectionTypeArgGuard, run at the top of
tryLowerReflectionCall) classifies each arg via reflectionArgIsType: a
spelled / compile-time type or generic type parameter (isStaticTypeArg),
or a runtime Type value (static type .any -- type_of(x), a []Type
element list[i], a Type-typed local/field/param) is accepted; anything
else is rejected with "<builtin> expects a type, got '<type>'". The
runtime path for type_name / type_is_unsigned is preserved (the {}
formatter calls type_is_unsigned(type_of(val)) at runtime). The 5
comptime-only builtins stay comptime-only (runtime reflection deferred).

Regression: examples/1144-diagnostics-reflection-builtin-needs-type.sx
(reject cases across all 7, exit 1). Unit test: reflectionArgIsType in
lower.test.zig. specs.md / readme.md document the strict type
requirement (and add the previously-undocumented align_of, type_eq,
type_is_unsigned). issues/0090 RESOLVED banner updated.
This commit is contained in:
agra
2026-06-05 11:22:59 +03:00
parent 64f77e9779
commit b053c64149
9 changed files with 210 additions and 1 deletions

View File

@@ -10499,6 +10499,13 @@ pub const Lowering = struct {
/// Try to lower a call as a reflection builtin (expanded inline during lowering).
/// Returns null if the call is not a recognized reflection builtin.
fn tryLowerReflectionCall(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
// Strict `$T: Type` guard for the type-introspection builtins. A
// value argument (`6`, `true`, `5.2`, a struct) is rejected with a
// diagnostic instead of being silently reinterpreted as a TypeId
// index / sized via its `typeof` (issue 0090). One shared
// classification covers all 7; it runs before dispatch.
if (self.reflectionTypeArgGuard(name, c)) |sentinel| return sentinel;
if (std.mem.eql(u8, name, "size_of")) {
// size_of(T) → const_int(sizeof(T))
const ty = self.resolveTypeArg(c.args[0]);
@@ -10738,6 +10745,80 @@ pub const Lowering = struct {
return null;
}
/// Strict `$T: Type` classification shared by the 7 type-introspection
/// builtins. An argument denotes a type iff it is a spelled /
/// compile-time type or generic type parameter (the `isStaticTypeArg`
/// shapes), or a runtime `Type` value — which is `.any`-typed at
/// runtime (`type_of(x)`, a `[]Type` element `list[i]`, a `Type`-typed
/// local / field / param). Any other expression — a value of type
/// s64 / f64 / bool / a struct — is NOT a type.
pub fn reflectionArgIsType(self: *Lowering, arg: *const Node) bool {
if (self.isStaticTypeArg(arg)) return true;
return self.inferExprType(arg) == .any;
}
/// Guard for the type-introspection builtins (`size_of`, `align_of`,
/// `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
/// `is_flags`): every argument must denote a type. A value argument is
/// rejected with a diagnostic rather than silently reinterpreted as a
/// TypeId index or sized via its `typeof` (issue 0090).
///
/// Returns null when `name` is not a guarded builtin OR every argument
/// is a type (→ fall through to normal dispatch). Returns a harmless
/// result-typed sentinel Ref when a violation was diagnosed; the
/// emitted `.err` gates the build so the value is never observed.
fn reflectionTypeArgGuard(self: *Lowering, name: []const u8, c: *const ast.Call) ?Ref {
const arity: usize = if (std.mem.eql(u8, name, "type_eq"))
2
else if (std.mem.eql(u8, name, "size_of") or
std.mem.eql(u8, name, "align_of") or
std.mem.eql(u8, name, "field_count") or
std.mem.eql(u8, name, "type_name") or
std.mem.eql(u8, name, "type_is_unsigned") or
std.mem.eql(u8, name, "is_flags"))
1
else
return null;
var ok = true;
if (c.args.len != arity) {
if (self.diagnostics) |d| {
d.addFmt(.err, c.callee.span, "{s} expects {d} type argument{s}, got {d}", .{
name, arity, if (arity == 1) @as([]const u8, "") else "s", c.args.len,
});
}
ok = false;
} else {
for (c.args) |a| {
if (self.reflectionArgIsType(a)) continue;
if (self.diagnostics) |d| {
d.addFmt(.err, a.span, "{s} expects a type, got '{s}'", .{
name, self.formatTypeName(self.inferExprType(a)),
});
}
ok = false;
}
}
if (ok) return null;
return self.reflectionErrorSentinel(name);
}
/// Result-typed placeholder returned after `reflectionTypeArgGuard`
/// diagnoses a non-type argument: a string for `type_name`, a bool for
/// the predicate builtins, an int for the size / count builtins. Never
/// observed at runtime — the diagnostic already fails the build — but
/// keeps the IR well-typed so lowering can finish and report every
/// error in one pass.
fn reflectionErrorSentinel(self: *Lowering, name: []const u8) Ref {
if (std.mem.eql(u8, name, "type_name"))
return self.builder.constString(self.module.types.internString(""));
if (std.mem.eql(u8, name, "type_eq") or
std.mem.eql(u8, name, "type_is_unsigned") or
std.mem.eql(u8, name, "is_flags"))
return self.builder.constBool(false);
return self.builder.constInt(0, .s64);
}
/// Resolve a type argument from a call expression. Handles:
/// - Type param bindings ($T → concrete type via type_bindings)
/// - Direct type names (Vec4 → lookup in TypeTable)