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:
agra
2026-06-05 00:21:32 +03:00
parent b0cc22a8c0
commit 6478ccbe3c
8 changed files with 225 additions and 30 deletions

View File

@@ -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));
}

View File

@@ -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))

View File

@@ -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);