fix(ir): converge the comptime-int count surface (0083)

Three adjacent cells of the shared count surface still diverged from the
rest; all now route through the same leaf+fold+narrow+diagnose path.

1. Aliased integer constraint bypassed the value-param range gate — only
   builtin constraint names matched intTypeRange, so Box(5_000_000_000)
   with `$K: Count` (Count :: u32) compiled and bound a truncated value.
   resolveValueParamArg (shared by both the struct AND type-fn binder) now
   resolves the constraint to its underlying builtin via
   canonicalIntConstraintName (Count -> u32, Small -> s8) before
   range-checking, so an aliased integer constraint behaves exactly like
   the builtin it names.

2. A named const with an expression RHS (M :: 2; N :: M + 1) did not fold
   as a count — moduleConstInt read only a literal RHS node. It now folds
   every const's RHS through the shared evalConstIntExpr, cycle-guarded
   (mutual / self cycles fold to null, not a stack overflow), and pass-0
   pre-registers expression-RHS consts. N :: M + 1 == 3 at every consumer:
   dim (direct + alias), Vector lane, value-param (struct + type-fn),
   inline for.

3. Stateful resolveArrayLen still fabricated length 0 after a failed fold;
   it now returns null -> the .unresolved sentinel (no fabrication). The
   binding's lowering never reaches sizeOf (alloca defers it; hasErrors
   aborts first) and a field access on an already-diagnosed .unresolved
   value is poison-suppressed (emitFieldError), so a failed-fold dim emits
   ONE clean diagnostic with no panic.

Regressions: examples/0146 (full positive matrix — every consumer x leaf
form), 1135 (aliased u32 + s8 overflow), 1136 (direct non-const dim halts
cleanly). The cascade cleanup also tightened 1502/1503 to one diagnostic.
Unit test added for moduleConstInt expression-folding + cycle detection.
This commit is contained in:
agra
2026-06-04 14:09:46 +03:00
parent e03c087e5a
commit a821323c3c
18 changed files with 328 additions and 33 deletions

View File

@@ -673,6 +673,13 @@ pub const Lowering = struct {
switch (cd.value.data) {
.int_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {},
.float_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .f64 }) catch {},
// A const whose RHS is an integer EXPRESSION over other consts
// (`M :: 2; N :: M + 1`) is itself a usable count: register it so
// `moduleConstInt` can fold the RHS through `evalConstIntExpr`
// (issue 0083). Placeholder `.s64` type — the count consumers read
// only the value; if the expression doesn't fold (references a
// non-const), `moduleConstInt` yields null and the use diagnoses.
.binary_op, .unary_op => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {},
else => {},
}
}
@@ -11676,13 +11683,17 @@ pub const Lowering = struct {
if (result == .ok) return result.ok;
// A non-const / oversized / negative dim is a hard error. Emit the
// shared diagnostic (single wording source — `program_index.reportDimError`,
// also used by the stateless alias path so the two cannot diverge), then
// return a harmless `0` so body lowering finishes without touching the
// `.unresolved` sentinel (which would `@panic` in `sizeOf` mid-lowering,
// before the diagnostic surfaces). The diagnostic — not the returned
// length — guarantees no garbage ships (issue 0083).
// also used by the stateless alias path so the two cannot diverge) and
// return null so `resolveCompound` yields the `.unresolved` sentinel — NO
// fabricated length (issue 0083: a `0` here gives a 0-byte alloca and OOB
// element access). Lowering the binding never computes the failed type's
// size: `alloca` records the type but defers `sizeOf` to LLVM emission,
// which the emitted diagnostic pre-empts via `hasErrors()`, and a
// downstream use of the `.unresolved`-typed value is poison-suppressed (a
// field access stays silent — `emitFieldError`). So the failure surfaces
// as ONE clean diagnostic and never reaches the `sizeOf` panic.
if (self.diagnostics) |d| program_index_mod.reportDimError(d, len_node.span, result);
return 0;
return null;
}
/// Leaf-name lookup for the shared dimension evaluator: a name bound to a
@@ -11874,7 +11885,14 @@ pub const Lowering = struct {
/// counts" holds; any other integer type range-checks against
/// `program_index.intTypeRange`; an unrecognised type folds without bounding.
fn resolveValueParamArg(self: *Lowering, arg_node: *const Node, param_name: []const u8, type_name: ?[]const u8) ?i64 {
if (type_name) |tn| {
// Resolve an ALIASED integer constraint (`$K: Count` where `Count :: u32`,
// `$K: Small` where `Small :: s8`) to its underlying builtin so the range
// gate below treats it exactly like `$K: u32` / `$K: s8` (issue 0083 — an
// alias previously slipped past `intTypeRange`, so `Box(5_000_000_000)`
// with `$K: Count` bound a truncated value). A non-integer / unrecognised
// constraint yields null → no range bound (fold only), as before.
const tn_canon: ?[]const u8 = if (type_name) |tn| self.canonicalIntConstraintName(tn) else null;
if (tn_canon) |tn| {
if (std.mem.eql(u8, tn, "u32")) {
switch (program_index_mod.foldDimU32(arg_node, self, 0)) {
.ok => |n| return n,
@@ -11897,7 +11915,7 @@ pub const Lowering = struct {
self.diagValueParamNotConst(arg_node, param_name);
return null;
};
if (type_name) |tn| {
if (tn_canon) |tn| {
if (program_index_mod.intTypeRange(tn)) |r| {
if (v < r.min or v > r.max) {
self.diagValueParamRange(arg_node, param_name, tn, v);
@@ -11908,6 +11926,23 @@ pub const Lowering = struct {
return v;
}
/// Resolve a generic value-param constraint type NAME to its canonical builtin
/// integer type name, chasing a type alias (`Count :: u32` → "u32",
/// `Small :: s8` → "s8") so an ALIASED integer constraint range-checks exactly
/// like the builtin it names. Returns the name unchanged when it is already a
/// builtin integer; null when it isn't an integer type (directly or via alias)
/// — the caller then folds without a range bound rather than guessing. The
/// alias map + type table are the same single sources every other resolver
/// reads, so this can't diverge from how the alias is laid out elsewhere.
fn canonicalIntConstraintName(self: *Lowering, name: []const u8) ?[]const u8 {
if (program_index_mod.intTypeRange(name) != null) return name;
if (self.program_index.type_alias_map.get(name)) |tid| {
const canon = self.module.types.typeName(tid);
if (program_index_mod.intTypeRange(canon) != null) return canon;
}
return null;
}
fn diagValueParamNotConst(self: *Lowering, arg_node: *const Node, param_name: []const u8) void {
if (self.diagnostics) |d|
d.addFmt(.err, arg_node.span, "generic value parameter '{s}' must be a compile-time integer constant", .{param_name});
@@ -14104,9 +14139,18 @@ pub const Lowering = struct {
}
fn emitFieldError(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref {
if (self.diagnostics) |diags| {
const ty_name = self.formatTypeName(obj_ty);
diags.addFmt(.err, span, "field '{s}' not found on type '{s}'", .{ field, ty_name });
// A field access on an already-`.unresolved` object is a cascade from an
// upstream type-resolution failure that was ALREADY diagnosed (e.g. an
// unresolvable / oversized array dimension — issue 0083). The
// `.unresolved` sentinel never exists without an accompanying error, so
// piling a second "field not found on unresolved" onto the real one is
// pure noise; stay silent and return a placeholder so lowering finishes
// and `hasErrors()` aborts the build on the genuine diagnostic.
if (obj_ty != .unresolved) {
if (self.diagnostics) |diags| {
const ty_name = self.formatTypeName(obj_ty);
diags.addFmt(.err, span, "field '{s}' not found on type '{s}'", .{ field, ty_name });
}
}
return self.emitPlaceholder(field);
}

View File

@@ -214,6 +214,53 @@ test "floatToIntExact accepts integral floats, rejects the rest" {
try std.testing.expect(f(1.0e30) == null);
}
test "moduleConstInt folds expression-RHS consts and rejects cycles" {
var map = std.StringHashMap(pi.ModuleConstInfo).init(std.testing.allocator);
defer map.deinit();
// M :: 2 (literal), N :: M + 1 (expression), P :: N * 2 (expression over an
// expression const), F :: 4.0 (integral float), G :: 4.5 (fractional).
var m_val = nLit(2);
var m_id = nIdent("M");
var one = nLit(1);
var n_val = nBin(.add, &m_id, &one);
var n_id = nIdent("N");
var two = nLit(2);
var p_val = nBin(.mul, &n_id, &two);
var f_val = nFloat(4.0);
var g_val = nFloat(4.5);
try map.put("M", .{ .value = &m_val, .ty = .s64 });
try map.put("N", .{ .value = &n_val, .ty = .s64 });
try map.put("P", .{ .value = &p_val, .ty = .s64 });
try map.put("F", .{ .value = &f_val, .ty = .f64 });
try map.put("G", .{ .value = &g_val, .ty = .f64 });
try std.testing.expectEqual(@as(?i64, 2), pi.moduleConstInt(&map, "M"));
try std.testing.expectEqual(@as(?i64, 3), pi.moduleConstInt(&map, "N"));
try std.testing.expectEqual(@as(?i64, 6), pi.moduleConstInt(&map, "P"));
try std.testing.expectEqual(@as(?i64, 4), pi.moduleConstInt(&map, "F"));
try std.testing.expect(pi.moduleConstInt(&map, "G") == null);
try std.testing.expect(pi.moduleConstInt(&map, "absent") == null);
// A cyclic const has no compile-time integer value, and folding it must not
// recurse forever: mutual `A :: B + 0; B :: A + 0` and self `C :: C + 0` all
// fold to null via the frame-based cycle guard.
var a_id = nIdent("A");
var b_id = nIdent("B");
var c_id = nIdent("C");
var zero = nLit(0);
var a_val = nBin(.add, &b_id, &zero);
var b_val = nBin(.add, &a_id, &zero);
var c_val = nBin(.add, &c_id, &zero);
try map.put("A", .{ .value = &a_val, .ty = .s64 });
try map.put("B", .{ .value = &b_val, .ty = .s64 });
try map.put("C", .{ .value = &c_val, .ty = .s64 });
try std.testing.expect(pi.moduleConstInt(&map, "A") == null);
try std.testing.expect(pi.moduleConstInt(&map, "B") == null);
try std.testing.expect(pi.moduleConstInt(&map, "C") == null);
}
test "evalConstIntExpr folds an integral float literal, halts on a fractional one" {
const eval = pi.evalConstIntExpr;
const ctx = DimCtx{};

View File

@@ -63,6 +63,49 @@ pub fn floatToIntExact(v: f64) ?i64 {
return @intFromFloat(v);
}
/// A frame in the chain of module consts currently being folded by
/// `moduleConstInt`. Stack-allocated (each recursive frame lives on the Zig
/// call stack), so cycle detection needs no allocation.
const ModuleConstFrame = struct {
name: []const u8,
parent: ?*const ModuleConstFrame,
};
fn moduleConstFrameContains(frame: ?*const ModuleConstFrame, name: []const u8) bool {
var cur = frame;
while (cur) |c| : (cur = c.parent) {
if (std.mem.eql(u8, c.name, name)) return true;
}
return false;
}
/// Folding context for a module-const EXPRESSION RHS (`N :: M + 1`): a leaf name
/// resolves to another module const via `moduleConstInt`, recursively, so the
/// SAME shared `evalConstIntExpr` that folds an inline dim expression (`[M + 1]`)
/// also folds an expression hidden behind a const name. `frame` is the chain of
/// const names currently being resolved; a name already on it is a cyclic
/// definition (`N :: N`; `N :: M + 1; M :: N`) — which has no compile-time
/// integer value — so it folds to null (→ the clean "not a compile-time integer
/// constant" diagnostic) rather than recursing forever. No pack arity at module
/// scope, so `lookupPackLen` is always null.
const ModuleConstCtx = struct {
consts: *const std.StringHashMap(ModuleConstInfo),
frame: ?*const ModuleConstFrame,
pub fn lookupDimName(self: ModuleConstCtx, name: []const u8) ?i64 {
return moduleConstIntFramed(self.consts, name, self.frame);
}
pub fn lookupPackLen(_: ModuleConstCtx, _: []const u8) ?i64 {
return null;
}
};
fn moduleConstIntFramed(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8, parent: ?*const ModuleConstFrame) ?i64 {
if (moduleConstFrameContains(parent, name)) return null;
const ci = consts.get(name) orelse return null;
var frame = ModuleConstFrame{ .name = name, .parent = parent };
return evalConstIntExpr(ci.value, ModuleConstCtx{ .consts = consts, .frame = &frame });
}
/// A name bound to a module-global integer constant → its value, else null.
/// SINGLE source for both array-dimension resolvers — the stateful
/// body-lowering path (`Lowering.comptimeIntNamed`) and the stateless
@@ -70,17 +113,14 @@ pub fn floatToIntExact(v: f64) ?i64 {
/// which named consts a `[N]T` dimension resolves to; if they diverge, an array
/// laid out via a type alias (`Arr :: [N]T`, stateless) gets a different length
/// than the direct form (`a : [N]T`, stateful) — the issue-0083 miscompile.
/// Untyped (`N :: 16`) and typed (`N : s64 : 16`) consts store an `.int_literal`
/// value node; a float-typed const (`N : f64 : 4.0`, `N :: 4.0`) stores a
/// `.float_literal` and resolves iff its value is an integral float (via
/// `floatToIntExact`) — `4.5` is not an integer → null.
/// Every const's RHS is folded through the shared `evalConstIntExpr`, so an
/// untyped (`N :: 16`) / typed (`N : s64 : 16`) literal, an integral float
/// (`N : f64 : 4.0` → 4, via `floatToIntExact`; `4.5` → null), AND an expression
/// RHS over other consts (`M :: 2; N :: M + 1` → 3) all resolve identically and
/// everywhere a count is accepted. Cyclic consts fold to null (see
/// `ModuleConstCtx`).
pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8) ?i64 {
const ci = consts.get(name) orelse return null;
return switch (ci.value.data) {
.int_literal => |lit| lit.value,
.float_literal => |lit| floatToIntExact(lit.value),
else => null,
};
return moduleConstIntFramed(consts, name, null);
}
/// Evaluate a constant integer expression to its value. THE single