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

@@ -9,7 +9,9 @@ const Node = ast.Node;
const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId;
const Ref = ir_mod.Ref;
const Lowering = ir_mod.Lowering;
const Scope = @import("lower.zig").Scope;
fn node(data: ast.Node.Data) Node {
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 } });
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,
};
if (type_name) |tn| {
if (TypeResolver.integerLimitFor(tn, fa.field) != null or
TypeResolver.floatLimitFor(tn, fa.field) != null)
// 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.
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;
}

View File

@@ -4928,6 +4928,19 @@ pub const Lowering = struct {
};
if (!TypeResolver.isLimitField(fa.field)) 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| {
return self.builder.constInt(value, ty);
}