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

@@ -0,0 +1,28 @@
// The 7 type-introspection builtins (size_of, align_of, field_count,
// type_name, type_eq, type_is_unsigned, is_flags) take ONLY types. A
// value argument is a compile-time error, not a silently-reinterpreted
// TypeId index.
//
// Regression (issue 0090, attempt 2): each builtin used to accept a
// non-type value and reinterpret it — `type_is_unsigned(6)` read 6 as a
// TypeId and returned the signedness of types[6] (`u8` → true);
// `size_of(true)` sized `typeof(true)` (8). The strict `$T: Type` guard
// (`Lowering.reflectionTypeArgGuard`) now rejects any argument whose
// static type is not `Type` and emits "<builtin> expects a type, got
// '<type>'" at the offending argument, aborting (exit 1).
//
// Reject cases use `true`/`1.5` (whose types — bool/f64 — are stable)
// rather than an integer literal, so the pinned diagnostics don't drift
// when the int-literal default type changes.
#import "modules/std.sx";
main :: () {
print("{}\n", size_of(true)); // bool, not a type
print("{}\n", align_of(1.5)); // f64, not a type
print("{}\n", field_count(true)); // bool, not a type
print("{}\n", type_name(1.5)); // f64, not a type
print("{}\n", type_eq(true, false)); // both bool — both rejected
print("{}\n", type_is_unsigned(true)); // bool, not a type
print("{}\n", is_flags(1.5)); // f64, not a type
}

View File

@@ -0,0 +1,47 @@
error: size_of expects a type, got 'bool'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:21:27
|
21 | print("{}\n", size_of(true)); // bool, not a type
| ^^^^
error: align_of expects a type, got 'f64'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:22:28
|
22 | print("{}\n", align_of(1.5)); // f64, not a type
| ^^^
error: field_count expects a type, got 'bool'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:23:31
|
23 | print("{}\n", field_count(true)); // bool, not a type
| ^^^^
error: type_name expects a type, got 'f64'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:24:29
|
24 | print("{}\n", type_name(1.5)); // f64, not a type
| ^^^
error: type_eq expects a type, got 'bool'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:25:27
|
25 | print("{}\n", type_eq(true, false)); // both bool — both rejected
| ^^^^
error: type_eq expects a type, got 'bool'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:25:33
|
25 | print("{}\n", type_eq(true, false)); // both bool — both rejected
| ^^^^^
error: type_is_unsigned expects a type, got 'bool'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:26:36
|
26 | print("{}\n", type_is_unsigned(true)); // bool, not a type
| ^^^^
error: is_flags expects a type, got 'f64'
--> examples/1144-diagnostics-reflection-builtin-needs-type.sx:27:28
|
27 | print("{}\n", is_flags(1.5)); // f64, not a type
| ^^^

View File

@@ -38,6 +38,29 @@
> pins both extremes plus a width spread (s8/s16/s32 + u8/u16/u32/u64,
> mins/maxes, 0, ordinary values). Unit tests: `isUnsignedInt` in
> `src/ir/types.test.zig`.
>
> **Follow-up (F0.8 attempt 2) — strict `$T: Type` on all 7 reflection
> builtins.** The stress-review of the additive `type_is_unsigned` builtin
> found it (and the whole reflection family) silently accepted a non-type
> argument: `type_is_unsigned(6)` reinterpreted `6` as a TypeId index and
> returned the signedness of `types[6]` (`u8` → true); `size_of(6)`/`(true)`
> sized its `typeof` (8); `type_name(6)` returned `types[6]`'s name.
> Per Agra's ruling, all 7 type-introspection builtins — `size_of`,
> `align_of`, `field_count`, `type_name`, `type_eq`, `type_is_unsigned`,
> `is_flags` — now STRICTLY require a type (compile-time): a value argument
> is rejected with `"<builtin> expects a type, got '<type>'"`.
> - `src/ir/lower.zig` — one shared guard, `reflectionTypeArgGuard` (run at
> the top of `tryLowerReflectionCall`), classifies each arg via
> `reflectionArgIsType`: a spelled / compile-time type or generic type
> param (the `isStaticTypeArg` shapes), 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. The
> existing 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).
> - Negative regression: `examples/1144-diagnostics-reflection-builtin-needs-type.sx`
> (reject cases across all 7, exit 1). Unit test: `reflectionArgIsType` in
> `src/ir/lower.test.zig`.
> STATUS (original): OPEN. Pre-existing + orthogonal; surfaced (not introduced) by NL.1.
> Manager-verified independent of the numeric-limit accessors. Scheduled separately.

View File

@@ -432,7 +432,7 @@ The standard library (`modules/std.sx`) provides:
- **Strings**: `concat`, `substr`, `int_to_string`, `uint_to_string`, `float_to_string`, `cstring`
- **Memory**: `Allocator` protocol, `GPA` (general purpose), `Arena` (bump allocator)
- **Math**: `sqrt`, `sin`, `cos`
- **Introspection**: `type_of`, `type_name`, `type_is_unsigned`, `field_count`, `field_name`, `field_value`, `size_of`
- **Introspection**: `type_of`, `type_name`, `type_is_unsigned`, `type_eq`, `field_count`, `field_name`, `field_value`, `size_of`, `align_of`, `is_flags` — the type-only builtins (`size_of`, `align_of`, `field_count`, `type_name`, `type_eq`, `type_is_unsigned`, `is_flags`) require a type argument (a spelled type, a generic `T`, or a runtime `Type` value); passing a value is a compile-time error
### Command-line interface (`modules/std/cli.sx`)

View File

@@ -2221,6 +2221,7 @@ Built-in functions are declared in `std.sx` with the `#builtin` suffix, which te
- `memcpy(dst: *void, src: *void, size: s64) -> *void` — copy `size` bytes from `src` to `dst`
- `memset(dst: *void, val: s64, size: s64) -> void` — fill `size` bytes at `dst` with `val`
- `size_of($T: Type) -> s64` — size of type `T` in bytes
- `align_of($T: Type) -> s64` — alignment of type `T` in bytes
### Type Introspection
- `type_of(val: $T) -> Type` — returns the runtime type tag of a value
@@ -2231,6 +2232,10 @@ Built-in functions are declared in `std.sx` with the `#builtin` suffix, which te
- `field_value_int($T: Type, idx: s64) -> s64` — returns the integer value of the `idx`-th enum variant
- `field_index($T: Type, val: T) -> s64` — returns the sequential variant index for an explicit enum value (reverse of `field_value_int`). Returns `-1` if no variant matches.
- `is_flags($T: Type) -> bool` — returns `true` if `T` is a flags enum (declared with `#flags`)
- `type_eq($A: Type, $B: Type) -> bool` — structural TypeId equality (`type_eq(s64, s64)` is `true`, distinct shapes are `false`); folds at compile time, so `inline if type_eq(...)` is comptime-decidable
- `type_is_unsigned($T: Type) -> bool` — `true` if `T` is an unsigned integer (`u8`/`u16`/`u32`/`u64`/`usize`); used by `{}` formatting to print unsigned integers as unsigned decimal
The seven type-only builtins — `size_of`, `align_of`, `field_count`, `type_name`, `type_eq`, `type_is_unsigned`, `is_flags` — strictly require a **type** argument: a spelled type (`s64`, `*u8`, `Point`), a generic type parameter (`T`), or a runtime `Type` value (`type_of(x)`, a `[]Type` element, a `Type`-typed local). Passing a value (`size_of(6)`, `type_is_unsigned(true)`) is a compile-time error — `<builtin> expects a type, got '<type>'` — not a silent reinterpretation of the value's bits as a type.
### Type Conversion
- `cast(Type) expr` — prefix operator that converts `expr` to `Type`. Examples: `cast(s32) 3.14`, `cast(f64) n`. When `Type` is a runtime `Type` value inside a type-category match arm, the compiler generates a dispatch switch over all types in the category, monomorphizing the callee for each concrete type.

View File

@@ -1073,3 +1073,26 @@ test "lower: vectorLaneIndex maps swizzle components, colour aliases, rejects no
try std.testing.expectEqual(@as(?u32, null), Lowering.vectorLaneIndex("len"));
try std.testing.expectEqual(@as(?u32, null), Lowering.vectorLaneIndex(""));
}
test "lower: reflectionArgIsType accepts spelled types, rejects plain values (issue 0090)" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const span = ast.Span{ .start = 0, .end = 0 };
const ty_node = Node{ .span = span, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
const int_node = Node{ .span = span, .data = .{ .int_literal = .{ .value = 6 } } };
const float_node = Node{ .span = span, .data = .{ .float_literal = .{ .value = 1.5 } } };
const bool_node = Node{ .span = span, .data = .{ .bool_literal = .{ .value = true } } };
// A spelled type is a type → the introspection builtins accept it.
try std.testing.expect(l.reflectionArgIsType(&ty_node));
// Plain values are NOT types — these are exactly the arguments issue
// 0090's strict `$T: Type` guard rejects, before a builtin could
// reinterpret the value as a TypeId index (`type_is_unsigned(6)` → true)
// or size its `typeof` (`size_of(true)` → 8).
try std.testing.expect(!l.reflectionArgIsType(&int_node));
try std.testing.expect(!l.reflectionArgIsType(&float_node));
try std.testing.expect(!l.reflectionArgIsType(&bool_node));
}

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)