fix(lang): numeric-limit intercept no longer shadows raw value bindings [NL.2]

The numeric-limit accessor intercept (NL.1 integer `.min`/`.max`, NL.2 float
`.epsilon`/`.min_positive`/`.true_min`/`.inf`/`.nan`) treated ANY receiver
whose text matched a builtin numeric type name as a TYPE receiver, without
first checking for an in-scope VALUE binding. An F0.6 backtick raw identifier
(`` `f64 := … ``) binds a local under the stripped name `f64`; field access on
it (`` `f64.epsilon ``) parses as an `.identifier` receiver, which the intercept
silently folded to the type's numeric limit — a silent-wrong-value bug
(issue 0092).

Fix: for `.identifier` receivers, prefer an in-scope value binding
(`Scope.lookup`) over the fold — defer to ordinary field lowering when the
identifier resolves to a value. `.type_expr` receivers are unambiguous types
and are never shadowed, so a bare `f64.epsilon`/`s32.max` still folds even in a
scope where `` `f64 `` is bound (the parser classifies a bare builtin name as a
`.type_expr`). Mirrored in expr_typer.zig so inference matches lowering
(avoids the issue-0083 two-resolver desync). Float-only-on-int and
non-numeric-receiver errors are unchanged.

- src/ir/lower.zig: value-binding guard in lowerNumericLimit.
- src/ir/expr_typer.zig: same guard in the numeric-limit inference arm.
- src/ir/expr_typer.test.zig: unit test pinning the two-resolver agreement.
- examples/0161-types-numeric-limit-value-shadow.sx: regression — raw
  `` `f64 ``/`` `s32 ``/`` `u8 `` value reads coexisting with bare folds.
- issues/0092: RESOLVED banner.
- specs.md / readme.md: receiver-vs-shadowing-value-binding note.
This commit is contained in:
agra
2026-06-04 23:59:11 +03:00
parent 463557990f
commit b0cc22a8c0
10 changed files with 217 additions and 3 deletions

View File

@@ -0,0 +1,50 @@
// Numeric-limit accessor vs. a raw value binding that shadows a builtin type
// name. A backtick raw identifier (F0.6) can legitimately bind a value whose
// spelling is a reserved numeric type name (`` `f64 ``, `` `s32 ``, `` `u8 ``).
// Field access on such a value is an ORDINARY field read — the numeric-limit
// intercept (NL.1 integer `.min`/`.max`, NL.2 float `.epsilon`/… ) must NOT
// hijack it. An adjacent BARE `f64.epsilon` / `s32.max` / `u8.max` — which the
// parser classifies as a type receiver, not the raw value — STILL folds to the
// numeric limit. Both behaviors coexist in one scope: the raw receiver reads
// the value, the bare receiver folds the limit.
//
// Regression (issue 0092): the intercept previously treated any identifier
// whose text matched a builtin numeric type name as a TYPE receiver, silently
// shadowing the in-scope value binding (`` `f64.epsilon `` folded to 2^-52,
// `` `s32.max `` folded to 2147483647 — a silent wrong value).
#import "modules/std.sx";
FBox :: struct { epsilon: s64; max: s64; min_positive: s64; }
IBox :: struct { max: s64; min: s64; }
UBox :: struct { max: s64; }
main :: () -> s32 {
// Raw value bindings whose spelling shadows a builtin numeric type name.
`f64 := FBox.{ epsilon = 11, max = 22, min_positive = 33 };
`s32 := IBox.{ max = 78, min = -78 };
`u8 := UBox.{ max = 7 };
// Raw receiver → ordinary field READ (the value), never the numeric limit.
print("val f64: epsilon={} max={} min_positive={}\n",
`f64.epsilon, `f64.max, `f64.min_positive); // 11 22 33
print("val s32: max={} min={}\n", `s32.max, `s32.min); // 78 -78
print("val u8: max={}\n", `u8.max); // 7
// The value-field read carries the field type (s64 here): round-trips
// through a typed binding, so a mistyped/boxed read would not type-check.
e : s64 = `f64.epsilon;
print("typed val e={}\n", e); // 11
// Bare receiver (a type receiver, NOT the raw value) → STILL folds to the
// numeric limit, even though `s32`/`u8`/`f64` are bound in this same scope.
print("lim s32.max={} s32.min={}\n", s32.max, s32.min); // 2147483647 -2147483648
print("lim u8.max={}\n", u8.max); // 255
// Bare float accessors still fold; the formatter is crude (issue 0090), so
// pin the values by their defining properties rather than by printing.
print("lim (1.0+f64.epsilon)!=1.0: {}\n", (1.0 + f64.epsilon) != 1.0); // true
print("lim f64.inf > f64.max: {}\n", f64.inf > f64.max); // true
print("lim f64.min == -f64.max: {}\n", f64.min == -f64.max); // true
return 0;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,9 @@
val f64: epsilon=11 max=22 min_positive=33
val s32: max=78 min=-78
val u8: max=7
typed val e=11
lim s32.max=2147483647 s32.min=-2147483648
lim u8.max=255
lim (1.0+f64.epsilon)!=1.0: true
lim f64.inf > f64.max: true
lim f64.min == -f64.max: true

View File

@@ -0,0 +1,77 @@
> **RESOLVED** (NL.2 attempt 3). Root cause: the numeric-limit accessor
> intercept treated ANY receiver whose text matched a builtin numeric type
> name as a TYPE receiver, without first checking whether that identifier
> resolved to an in-scope VALUE binding. An F0.6 backtick raw identifier
> (`` `f64 := … ``) binds a local under the stripped name `f64`; field access
> on it (`` `f64.epsilon ``) parses as an `.identifier` receiver, which the
> intercept silently folded to the type's numeric limit — a silent-wrong-value
> bug.
>
> Fix (value-binding precedence for `.identifier` receivers; `.type_expr`
> receivers are unambiguous types and never shadowed):
> - `src/ir/lower.zig` — `lowerNumericLimit`: after confirming the receiver is
> a builtin numeric type name and the field is a limit accessor, return null
> (defer to ordinary field lowering) when `fa.object` is an `.identifier`
> that `Scope.lookup` resolves to a value binding.
> - `src/ir/expr_typer.zig` — numeric-limit inference arm: mirror the same
> guard so inferred types match lowering (avoids the issue-0083 two-resolver
> desync).
>
> Bare `f64.epsilon` / `s32.max` (no shadowing binding) still fold — the parser
> classifies a bare builtin name as a `.type_expr` (parser.zig:2743), so the
> bare receiver is never value-shadowed even in a scope where `` `f64 `` is
> bound. Float-only-on-int and non-numeric-receiver errors are unchanged.
>
> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` (raw
> `` `f64 ``/`` `s32 ``/`` `u8 `` value reads coexisting with bare folds) +
> unit test in `src/ir/expr_typer.test.zig`. NL.1 (`examples/0148`) / NL.2
> (`examples/0159`, `examples/0160`) unregressed.
# 0092 — numeric-limit intercept hijacks raw reserved-spelled value receivers
## Symptom
Field access on a raw reserved-spelled value binding is interpreted as a builtin
type numeric-limit access instead of an ordinary value field access. Observed:
the repro prints `0.000000 2147483647` (`f64.epsilon` / `s32.max`). Expected:
it prints `12 78` from the `Box` fields.
## Reproduction
```sx
#import "modules/std.sx";
Box :: struct { epsilon: s64; max: s64; }
main :: () -> s32 {
`f64 := Box.{ epsilon = 12, max = 34 };
`s32 := Box.{ epsilon = 56, max = 78 };
print("{} {}\n", `f64.epsilon, `s32.max);
return 0;
}
```
## Investigation prompt
Investigate issue 0092: raw reserved-spelled value receivers are being captured
by the numeric-limit accessor intercept. In `src/ir/lower.zig`, start at
`Lowering.lowerFieldAccess` and `Lowering.lowerNumericLimit` (currently around
`lower.zig:4826` / `lower.zig:4923`). The intercept treats any
`.identifier`/`.type_expr` receiver whose text is a builtin type name as a type
receiver, without first checking whether the identifier resolves to a value
binding in the current lexical scope. Mirror the fix in `src/ir/expr_typer.zig`
around the numeric-limit inference arm so inferred types match lowering.
Likely fix: for `.identifier` receivers, prefer an in-scope value binding
(`Scope.lookup`) over the builtin-type numeric-limit intercept; keep the
intercept for actual type receivers (`.type_expr`, and bare reserved integer
names with no value binding). If raw provenance is available in the AST, using it
to disambiguate is also acceptable, but the observable rule must be that
`` `f64.epsilon `` reads the value field when `` `f64 `` is a value binding,
while bare `f64.epsilon` / `s32.max` still fold as numeric limits.
Verification: pin a regression test from the repro above. It should print
`12 78`. Also verify the existing numeric-limit examples still pass:
`examples/0148-types-int-numeric-limits.sx`,
`examples/0159-types-float-numeric-limits.sx`, and the negative diagnostics in
`examples/0160-types-float-numeric-limits-errors.sx`.

View File

@@ -96,7 +96,10 @@ C's `DBL_MIN`) plus the float-only `.epsilon` (ULP of 1.0, not C#'s denormal
`Epsilon`), `.min_positive` (smallest normal = C `DBL_MIN`), `.true_min` (smallest `Epsilon`), `.min_positive` (smallest normal = C `DBL_MIN`), `.true_min` (smallest
subnormal — beware flush-to-zero CPU modes), `.inf`, and `.nan`. A float-only subnormal — beware flush-to-zero CPU modes), `.inf`, and `.nan`. A float-only
accessor on an integer (`s32.epsilon`), or any accessor on a non-numeric type, is accessor on an integer (`s32.epsilon`), or any accessor on a non-numeric type, is
a clean compile error. See `specs.md` → Numeric Limits. a clean compile error. The fold applies only to a bare type-name receiver: a raw
identifier that binds a value shadowing a type name (`` `f64 := … `` then
`` `f64.epsilon ``) reads the value's field, not the limit. See `specs.md` →
Numeric Limits.
### Declarations ### Declarations

View File

@@ -340,6 +340,14 @@ qn := f64.nan; // a quiet NaN
`true_min = 0x0000000000000001`, `inf = 0x7FF0000000000000`; the `f32` set is `true_min = 0x0000000000000001`, `inf = 0x7FF0000000000000`; the `f32` set is
`0x7F7FFFFF` / `0xFF7FFFFF` / `0x34000000` / `0x00800000` / `0x00000001` / `0x7F7FFFFF` / `0xFF7FFFFF` / `0x34000000` / `0x00800000` / `0x00000001` /
`0x7F800000`. `0x7F800000`.
- **Type receiver vs. a shadowing value binding.** A numeric-limit access folds
only when the receiver is a builtin numeric **type name** (`f64.epsilon`,
`s32.max`, `u8.max`). A backtick raw identifier that binds a *value* whose
spelling shadows a type name (`` `f64 := … ``, F0.6) is an ordinary value:
`` `f64.epsilon `` reads that value's `epsilon` field — it does **not** fold to
the limit. The two never collide, even in the same scope — a bare builtin name
in expression position is always a type, and only the raw `` `` `` spelling
can bind a value under it.
### Enum Types ### Enum Types
User-defined sum types with named variants. Variants may optionally carry typed data (tagged unions). Internally, payload-less enums are represented as `i64` (variant index). Enums with payloads are represented as `{ i64, [max_payload_size x i8] }` (tag + data). User-defined sum types with named variants. Variants may optionally carry typed data (tagged unions). Internally, payload-less enums are represented as `i64` (variant index). Enums with payloads are represented as `{ i64, [max_payload_size x i8] }` (tag + data).

View File

@@ -9,7 +9,9 @@ const Node = ast.Node;
const ir_mod = @import("ir.zig"); const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId; const TypeId = ir_mod.TypeId;
const Ref = ir_mod.Ref;
const Lowering = ir_mod.Lowering; const Lowering = ir_mod.Lowering;
const Scope = @import("lower.zig").Scope;
fn node(data: ast.Node.Data) Node { fn node(data: ast.Node.Data) Node {
return .{ .span = .{ .start = 0, .end = 0 }, .data = data }; return .{ .span = .{ .start = 0, .end = 0 }, .data = data };
@@ -73,3 +75,46 @@ test "expr_typer: deref of a non-pointer is unresolved" {
var deref_n = node(.{ .deref_expr = .{ .operand = &i } }); var deref_n = node(.{ .deref_expr = .{ .operand = &i } });
try std.testing.expectEqual(TypeId.unresolved, l.inferExprType(&deref_n)); try std.testing.expectEqual(TypeId.unresolved, l.inferExprType(&deref_n));
} }
// issue 0092: a raw `` `f64 `` value binding shadows the builtin numeric type
// name — `` `f64.epsilon `` (an `.identifier` receiver) must type as the value's
// field, NOT the float numeric-limit fold. A bare `f64.epsilon` (a `.type_expr`
// receiver, never shadowed) still folds to the queried float type. This pins the
// two-resolver agreement: expr_typer's inference must match lowerNumericLimit.
test "expr_typer: raw value binding shadows numeric-limit, bare type still folds" {
// Arena-backed (like calls.test.zig's scope tests): the `.identifier`
// field-access path interns a `obj.field` probe string into `l.alloc`,
// which the production compiler owns via an arena — an explicit free would
// not match the real lifetime.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
// A struct `Box { epsilon: s64 }` bound to the raw name `` `f64 ``.
const box_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
.{ .name = module.types.internString("epsilon"), .ty = .s64 },
};
const box_ty = module.types.intern(.{ .@"struct" = .{
.name = module.types.internString("Box"),
.fields = &box_fields,
} });
var scope = Scope.init(alloc, null);
defer scope.deinit();
l.scope = &scope;
scope.put("f64", .{ .ref = Ref.none, .ty = box_ty, .is_alloca = false });
// `` `f64.epsilon `` — identifier receiver resolving to the value binding →
// ordinary field read, types as s64 (the field), not f64.
var raw_recv = node(.{ .identifier = .{ .name = "f64", .is_raw = true } });
var raw_fa = node(.{ .field_access = .{ .object = &raw_recv, .field = "epsilon" } });
try std.testing.expectEqual(TypeId.s64, l.inferExprType(&raw_fa));
// bare `f64.epsilon` — type_expr receiver, never shadowed → folds to f64.
var type_recv = node(.{ .type_expr = .{ .name = "f64" } });
var type_fa = node(.{ .field_access = .{ .object = &type_recv, .field = "epsilon" } });
try std.testing.expectEqual(TypeId.f64, l.inferExprType(&type_fa));
}

View File

@@ -140,8 +140,15 @@ pub const ExprTyper = struct {
else => null, else => null,
}; };
if (type_name) |tn| { if (type_name) |tn| {
if (TypeResolver.integerLimitFor(tn, fa.field) != null or // Skip the fold when a raw value binding shadows the
TypeResolver.floatLimitFor(tn, fa.field) != null) // builtin type name (`` `f64 := … ``) — mirrors the
// lowerNumericLimit guard so inference matches lowering
// (issue 0092). A `.type_expr` receiver is never shadowed.
const shadowed = fa.object.data == .identifier and
(if (self.l.scope) |scope| (scope.lookup(tn) != null) else false);
if (!shadowed and
(TypeResolver.integerLimitFor(tn, fa.field) != null or
TypeResolver.floatLimitFor(tn, fa.field) != null))
{ {
if (TypeResolver.resolveBuiltinName(tn, &self.l.module.types)) |t| return t; if (TypeResolver.resolveBuiltinName(tn, &self.l.module.types)) |t| return t;
} }

View File

@@ -4928,6 +4928,19 @@ pub const Lowering = struct {
}; };
if (!TypeResolver.isLimitField(fa.field)) return null; if (!TypeResolver.isLimitField(fa.field)) return null;
const ty = TypeResolver.resolveBuiltinName(name, &self.module.types) orelse return null; const ty = TypeResolver.resolveBuiltinName(name, &self.module.types) orelse return null;
// A backtick raw identifier (F0.6) can bind a value whose spelling
// shadows a builtin type name (`` `f64 := … ``). Field access on that
// value is an ordinary field read, not a numeric-limit fold — defer to
// the normal field-access path when the receiver identifier resolves to
// an in-scope value binding (issue 0092). A `.type_expr` receiver is
// unambiguously a type and can never be value-shadowed.
if (fa.object.data == .identifier) {
if (self.scope) |scope| {
if (scope.lookup(name) != null) return null;
}
}
if (TypeResolver.integerLimitFor(name, fa.field)) |value| { if (TypeResolver.integerLimitFor(name, fa.field)) |value| {
return self.builder.constInt(value, ty); return self.builder.constInt(value, ty);
} }