fix(lang): numeric-limit shadow guard covers all 3 value sources [NL.2]
The issue-0092 fix guarded the numeric-limit accessor intercept against
raw value shadowing using only lexical Scope.lookup. The ordinary
identifier field-access path resolves a value through THREE sources
(scope / program_index.global_names / program_index.module_const_map),
so a backtick raw identifier bound at module scope — a global
`` `f64 := Box.{…} `` or a module constant `` `f64 :: Box.{…} `` — still
folded `` `f64.epsilon `` to the numeric limit instead of reading the
value's field (issue 0093, plus the module-const variant: same root
cause, same fix).
Fix: a single shared helper Lowering.identifierBindsValue(name) that
returns true when the name resolves through scope OR global_names OR
module_const_map. Used in BOTH lowerNumericLimit (lower.zig) and the
numeric-limit inference arm (expr_typer.zig) so the two resolvers can't
desync (issue-0083 class). A bare `f64.epsilon` / `s32.max` (a
.type_expr receiver) still folds even when a raw value of the same
spelling is bound — the bare receiver is never value-shadowed.
- examples/0161: extended to exercise all three binding kinds — a
GLOBAL `` `f32 ``, a MODULE-CONST `` `s16 ``, and LOCAL
`` `f64 ``/`` `s32 ``/`` `u8 `` — each reading its field while the
bare spelling still folds.
- src/ir/expr_typer.test.zig: unit test pinning the global +
module-const sources of the shared guard.
- issues/0093: RESOLVED banner (3-source root cause + fix, module-const
variant folded in).
- specs.md / readme.md: numeric-limit shadow note now source-agnostic
(local / global / module-const).
This commit is contained in:
@@ -5,30 +5,52 @@
|
||||
// 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.
|
||||
// numeric limit. Both behaviors coexist: 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).
|
||||
// A raw value binding can reach the intercept through THREE sources, exactly
|
||||
// mirroring the ordinary identifier field-access path (scope / globals / module
|
||||
// consts). This example exercises all three: a GLOBAL `` `f32 ``, a MODULE-CONST
|
||||
// `` `s16 ``, and LOCAL `` `f64 ``/`` `s32 ``/`` `u8 `` — each reads its field,
|
||||
// and the bare spelling of each STILL folds.
|
||||
//
|
||||
// Regression (issues 0092 local, 0093 global + module-const): 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). The attempt-3 fix guarded only lexical scope, so
|
||||
// GLOBAL and MODULE-CONST raw bindings still folded (issue 0093).
|
||||
#import "modules/std.sx";
|
||||
|
||||
FBox :: struct { epsilon: s64; max: s64; min_positive: s64; }
|
||||
IBox :: struct { max: s64; min: s64; }
|
||||
UBox :: struct { max: s64; }
|
||||
|
||||
// GLOBAL raw value binding whose spelling shadows the builtin `f32`. Reachable
|
||||
// via `program_index.global_names`, not lexical scope (issue 0093).
|
||||
`f32 := FBox.{ epsilon = 44, max = 55, min_positive = 66 };
|
||||
|
||||
// MODULE-CONST raw value binding whose spelling shadows the builtin `s16`.
|
||||
// Reachable via `program_index.module_const_map` (issue 0093, const variant).
|
||||
`s16 :: IBox.{ max = 99, min = -99 };
|
||||
|
||||
main :: () -> s32 {
|
||||
// Raw value bindings whose spelling shadows a builtin numeric type name.
|
||||
// LOCAL 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
|
||||
print("local f64: epsilon={} max={} min_positive={}\n",
|
||||
`f64.epsilon, `f64.max, `f64.min_positive); // 11 22 33
|
||||
print("local s32: max={} min={}\n", `s32.max, `s32.min); // 78 -78
|
||||
print("local u8: max={}\n", `u8.max); // 7
|
||||
|
||||
// GLOBAL raw receiver → ordinary field READ (issue 0093).
|
||||
print("global f32: epsilon={} max={} min_positive={}\n",
|
||||
`f32.epsilon, `f32.max, `f32.min_positive); // 44 55 66
|
||||
// MODULE-CONST raw receiver → ordinary field READ (issue 0093).
|
||||
print("const s16: max={} min={}\n", `s16.max, `s16.min); // 99 -99
|
||||
|
||||
// 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.
|
||||
@@ -36,15 +58,19 @@ main :: () -> s32 {
|
||||
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.
|
||||
// numeric limit, even though a LOCAL (`s32`/`u8`/`f64`), GLOBAL (`f32`), or
|
||||
// MODULE-CONST (`s16`) value of the same spelling is bound. The bare receiver
|
||||
// is never blocked by any of the three value sources.
|
||||
print("lim s32.max={} s32.min={}\n", s32.max, s32.min); // 2147483647 -2147483648
|
||||
print("lim u8.max={}\n", u8.max); // 255
|
||||
print("lim s16.max={} s16.min={}\n", s16.max, s16.min); // 32767 -32768
|
||||
|
||||
// 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
|
||||
print("lim f32.inf > f32.max: {}\n", f32.inf > f32.max); // true
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
val f64: epsilon=11 max=22 min_positive=33
|
||||
val s32: max=78 min=-78
|
||||
val u8: max=7
|
||||
local f64: epsilon=11 max=22 min_positive=33
|
||||
local s32: max=78 min=-78
|
||||
local u8: max=7
|
||||
global f32: epsilon=44 max=55 min_positive=66
|
||||
const s16: max=99 min=-99
|
||||
typed val e=11
|
||||
lim s32.max=2147483647 s32.min=-2147483648
|
||||
lim u8.max=255
|
||||
lim s16.max=32767 s16.min=-32768
|
||||
lim (1.0+f64.epsilon)!=1.0: true
|
||||
lim f64.inf > f64.max: true
|
||||
lim f64.min == -f64.max: true
|
||||
lim f32.inf > f32.max: true
|
||||
|
||||
84
issues/0093-global-raw-value-numeric-limit-shadow.md
Normal file
84
issues/0093-global-raw-value-numeric-limit-shadow.md
Normal file
@@ -0,0 +1,84 @@
|
||||
> **RESOLVED** (NL.2 attempt 4). Root cause: the issue-0092 fix guarded the
|
||||
> numeric-limit intercept against value shadowing using ONLY lexical
|
||||
> `Scope.lookup`. But the ordinary identifier field-access path resolves a
|
||||
> value through THREE sources (`expr_typer.zig` `.identifier` arm): lexical
|
||||
> `scope` → program `global_names` → module value constants
|
||||
> `module_const_map`. A backtick raw identifier bound at MODULE scope
|
||||
> (`` `f64 := Box.{…} ``, a global, or `` `f64 :: Box.{…} ``, a module const)
|
||||
> is registered in `global_names` / `module_const_map`, NOT in `Scope`, so the
|
||||
> scope-only guard missed it and the intercept still folded `` `f64.epsilon ``
|
||||
> to the numeric limit — the same silent-wrong-value bug as 0092, one source
|
||||
> deeper. The module-const variant has the same root cause and is covered by
|
||||
> the same fix (no separate issue).
|
||||
>
|
||||
> Fix (close ALL THREE value-binding sources in one pass): a single shared
|
||||
> helper `Lowering.identifierBindsValue(name)` returns true when `name`
|
||||
> resolves through `scope.lookup` OR `program_index.global_names` OR
|
||||
> `program_index.module_const_map`. Used in BOTH resolvers so they cannot
|
||||
> desync (issue-0083 two-resolver class):
|
||||
> - `src/ir/lower.zig` — `lowerNumericLimit`: defer to ordinary field lowering
|
||||
> (return null) when an `.identifier` receiver `identifierBindsValue`.
|
||||
> - `src/ir/expr_typer.zig` — numeric-limit inference arm: the `shadowed`
|
||||
> check now calls the same helper.
|
||||
>
|
||||
> A bare `f64.epsilon` / `s32.max` (a `.type_expr` receiver, never an
|
||||
> `.identifier`) still folds, even when a global or module-const raw value of
|
||||
> the same spelling exists — the bare receiver is never value-shadowed.
|
||||
> Float-only-on-int and non-numeric-receiver errors are unchanged.
|
||||
>
|
||||
> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` now exercises
|
||||
> all three binding kinds — a GLOBAL `` `f32 ``, a MODULE-CONST `` `s16 ``, and
|
||||
> LOCAL `` `f64 ``/`` `s32 ``/`` `u8 `` — each reading its field while the bare
|
||||
> spelling still folds. Unit test `src/ir/expr_typer.test.zig` pins the global
|
||||
> + module-const sources. NL.1 (`examples/0148`) / NL.2 (`examples/0159`,
|
||||
> `examples/0160`) unregressed.
|
||||
|
||||
# 0093 — numeric-limit intercept hijacks global raw reserved-spelled value receivers
|
||||
|
||||
## Symptom
|
||||
|
||||
Field access on a **global** 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; }
|
||||
|
||||
`f64 := Box.{ epsilon = 12, max = 34 };
|
||||
`s32 := Box.{ epsilon = 56, max = 78 };
|
||||
|
||||
main :: () -> s32 {
|
||||
print("{} {}\n", `f64.epsilon, `s32.max);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
|
||||
Investigate issue 0093: the issue-0092 value-binding precedence fix covers
|
||||
lexical locals but misses global raw value bindings. In `src/ir/lower.zig`, start
|
||||
at `Lowering.lowerNumericLimit` and the new issue-0092 guard around
|
||||
`Scope.lookup`. That guard returns `null` for a shadowing local, but global raw
|
||||
bindings are registered in `ProgramIndex.global_names` (and module constants in
|
||||
`ProgramIndex.module_const_map`), not in `Scope`, so an `.identifier` receiver
|
||||
whose text is `f64` / `s32` still folds to a numeric limit before ordinary
|
||||
global field lowering can read the value. Mirror the same rule in
|
||||
`src/ir/expr_typer.zig` so inferred types match lowering.
|
||||
|
||||
Likely fix: for `.identifier` numeric-limit receivers, prefer any in-scope value
|
||||
binding source over the builtin-type fold: lexical `Scope.lookup`, global values
|
||||
(`program_index.global_names`), and module value constants where applicable.
|
||||
Keep `.type_expr` receivers folding as type receivers, so bare `f64.epsilon` and
|
||||
`s32.max` still fold even when a raw global value of the same spelling exists.
|
||||
|
||||
Verification: pin the repro above as a regression. 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`,
|
||||
`examples/0160-types-float-numeric-limits-errors.sx`, and
|
||||
`examples/0161-types-numeric-limit-value-shadow.sx`.
|
||||
@@ -98,8 +98,8 @@ 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
|
||||
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.
|
||||
`` `f64.epsilon ``) reads the value's field, not the limit — for a local, global,
|
||||
or module-constant binding alike. See `specs.md` → Numeric Limits.
|
||||
|
||||
### Declarations
|
||||
|
||||
|
||||
10
specs.md
10
specs.md
@@ -343,10 +343,12 @@ qn := f64.nan; // a quiet NaN
|
||||
- **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
|
||||
spelling shadows a type name (F0.6) is an ordinary value: `` `f64.epsilon ``
|
||||
reads that value's `epsilon` field — it does **not** fold to the limit. This
|
||||
holds for **every** value-binding kind — a `` `f64 := … `` local, a module-scope
|
||||
global, or a `` `f64 :: … `` module constant — so the fold can never silently
|
||||
hijack a raw value, whatever its scope. The two never collide: a bare builtin
|
||||
name in expression position is always a type, and only the raw `` `…` `` spelling
|
||||
can bind a value under it.
|
||||
|
||||
### Enum Types
|
||||
|
||||
@@ -118,3 +118,62 @@ test "expr_typer: raw value binding shadows numeric-limit, bare type still folds
|
||||
var type_fa = node(.{ .field_access = .{ .object = &type_recv, .field = "epsilon" } });
|
||||
try std.testing.expectEqual(TypeId.f64, l.inferExprType(&type_fa));
|
||||
}
|
||||
|
||||
// issue 0093: a raw value binding can shadow a builtin numeric type name through
|
||||
// any of three sources — lexical scope (issue 0092), program globals, or module
|
||||
// value constants. The shared `identifierBindsValue` guard consults all three,
|
||||
// so a global `` `f32 := Box.{…} `` and a module-const `` `s16 :: Box.{…} `` each
|
||||
// read the value's field (NOT the numeric-limit fold), while a bare `f32.max` /
|
||||
// `s16.max` (a `.type_expr` receiver) still folds. Pins the guard across the two
|
||||
// non-lexical sources the attempt-3 scope-only fix missed.
|
||||
test "expr_typer: global and module-const raw bindings shadow numeric-limit" {
|
||||
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);
|
||||
|
||||
// `Box { max: s64 }` — the struct both raw bindings resolve to.
|
||||
const box_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
|
||||
.{ .name = module.types.internString("max"), .ty = .s64 },
|
||||
};
|
||||
const box_ty = module.types.intern(.{ .@"struct" = .{
|
||||
.name = module.types.internString("Box"),
|
||||
.fields = &box_fields,
|
||||
} });
|
||||
|
||||
// GLOBAL raw binding `` `f32 := Box.{…} `` — registered in global_names.
|
||||
try l.program_index.global_names.put("f32", .{ .id = @enumFromInt(0), .ty = box_ty });
|
||||
// MODULE-CONST raw binding `` `s16 :: Box.{…} `` — registered in module_const_map.
|
||||
var const_val = node(.{ .int_literal = .{ .value = 0 } });
|
||||
try l.program_index.module_const_map.put("s16", .{ .value = &const_val, .ty = box_ty });
|
||||
|
||||
// The shared guard sees both non-lexical bindings, but not an unbound spelling.
|
||||
try std.testing.expect(l.identifierBindsValue("f32"));
|
||||
try std.testing.expect(l.identifierBindsValue("s16"));
|
||||
try std.testing.expect(!l.identifierBindsValue("u8"));
|
||||
|
||||
// `` `f32.max `` — global raw receiver → ordinary field read, types as s64
|
||||
// (the field), not f32 (the fold).
|
||||
var g_recv = node(.{ .identifier = .{ .name = "f32", .is_raw = true } });
|
||||
var g_fa = node(.{ .field_access = .{ .object = &g_recv, .field = "max" } });
|
||||
try std.testing.expectEqual(TypeId.s64, l.inferExprType(&g_fa));
|
||||
|
||||
// `` `s16.max `` — module-const raw receiver → ordinary field read, types as s64.
|
||||
var c_recv = node(.{ .identifier = .{ .name = "s16", .is_raw = true } });
|
||||
var c_fa = node(.{ .field_access = .{ .object = &c_recv, .field = "max" } });
|
||||
try std.testing.expectEqual(TypeId.s64, l.inferExprType(&c_fa));
|
||||
|
||||
// bare `f32.max` — type_expr receiver, never shadowed → folds to f32, even
|
||||
// though a global `` `f32 `` value is bound.
|
||||
var bare_f32 = node(.{ .type_expr = .{ .name = "f32" } });
|
||||
var bare_f32_fa = node(.{ .field_access = .{ .object = &bare_f32, .field = "max" } });
|
||||
try std.testing.expectEqual(TypeId.f32, l.inferExprType(&bare_f32_fa));
|
||||
|
||||
// bare `s16.max` — type_expr receiver, never shadowed → folds to the s16
|
||||
// type, even though a module-const `` `s16 `` value is bound.
|
||||
var bare_s16 = node(.{ .type_expr = .{ .name = "s16" } });
|
||||
var bare_s16_fa = node(.{ .field_access = .{ .object = &bare_s16, .field = "max" } });
|
||||
try std.testing.expectEqual(TypeId.s16, l.inferExprType(&bare_s16_fa));
|
||||
}
|
||||
|
||||
@@ -143,9 +143,11 @@ pub const ExprTyper = struct {
|
||||
// Skip the fold when a raw value binding shadows the
|
||||
// builtin type name (`` `f64 := … ``) — mirrors the
|
||||
// lowerNumericLimit guard so inference matches lowering
|
||||
// (issue 0092). A `.type_expr` receiver is never shadowed.
|
||||
// (issues 0092, 0093). The shared helper consults all
|
||||
// three value sources (scope / globals / module consts);
|
||||
// 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);
|
||||
self.l.identifierBindsValue(tn);
|
||||
if (!shadowed and
|
||||
(TypeResolver.integerLimitFor(tn, fa.field) != null or
|
||||
TypeResolver.floatLimitFor(tn, fa.field) != null))
|
||||
|
||||
@@ -4904,6 +4904,27 @@ pub const Lowering = struct {
|
||||
return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span);
|
||||
}
|
||||
|
||||
/// True when an `.identifier` receiver text resolves to an in-scope VALUE
|
||||
/// binding rather than a builtin type. A backtick raw identifier (F0.6) can
|
||||
/// bind a value whose spelling shadows a builtin type name (`` `f64 := … ``);
|
||||
/// such a value is reachable through the same three sources the ordinary
|
||||
/// identifier field-access path consults (see `expr_typer` `.identifier`
|
||||
/// arm): lexical `scope`, program `global_names`, and module value
|
||||
/// constants `module_const_map`. The numeric-limit intercept must defer to
|
||||
/// ordinary field access whenever ANY of the three binds the name, so a
|
||||
/// raw value field read is never hijacked into a numeric-limit fold
|
||||
/// (issues 0092 local / 0093 global + module-const). A single helper used
|
||||
/// by both lowering and inference keeps the two resolvers in lockstep
|
||||
/// (issue-0083 two-resolver defect class).
|
||||
pub fn identifierBindsValue(self: *Lowering, name: []const u8) bool {
|
||||
if (self.scope) |scope| {
|
||||
if (scope.lookup(name) != null) return true;
|
||||
}
|
||||
if (self.program_index.global_names.get(name) != null) return true;
|
||||
if (self.program_index.module_const_map.get(name) != null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Numeric-limit accessor intercept (`<Type>.min`/`.max`/`.epsilon`/
|
||||
/// `.min_positive`/`.true_min`/`.inf`/`.nan`), a sibling of the `error.X` /
|
||||
/// `Struct.CONST` / pack-arity identifier-receiver intercepts in
|
||||
@@ -4933,13 +4954,10 @@ pub const Lowering = struct {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// a value binding through any of scope / globals / module consts
|
||||
// (issues 0092, 0093). A `.type_expr` receiver is unambiguously a type
|
||||
// and can never be value-shadowed.
|
||||
if (fa.object.data == .identifier and self.identifierBindsValue(name)) return null;
|
||||
|
||||
if (TypeResolver.integerLimitFor(name, fa.field)) |value| {
|
||||
return self.builder.constInt(value, ty);
|
||||
|
||||
Reference in New Issue
Block a user