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:
50
examples/0161-types-numeric-limit-value-shadow.sx
Normal file
50
examples/0161-types-numeric-limit-value-shadow.sx
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
77
issues/0092-raw-reserved-value-numeric-limit-shadow.md
Normal file
77
issues/0092-raw-reserved-value-numeric-limit-shadow.md
Normal 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`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
8
specs.md
8
specs.md
@@ -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).
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user