fix(ir): reject typed module const whose initializer mismatches annotation [F0.7]

A typed module-level constant whose initializer did not match its
annotation was silently accepted: `N : string : 4` compiled, then
`print(N)` segfaulted (an integer emitted as a `string` const → a bogus
pointer) and `[N]s64` folded `N` to 4 as an integer count. Issue 0088.

Root cause: `registerTypedModuleConst` stored the annotation type but never
validated the initializer literal against it, and
`program_index.moduleConstInt` folded a const into a count by inspecting
the initializer node alone, ignoring `ModuleConstInfo.ty`.

Fix at the declaration (kills both symptoms):
- lower.zig: `registerTypedModuleConst` now validates the initializer via
  `typedConstInitFits` (arms mirror `emitModuleConst`'s faithful-emit
  precondition: int→int/float, float→float, bool→bool, string→string,
  null→pointer/optional, `---`→any). A mismatch emits a `type mismatch`
  diagnostic at the initializer span and does not register the const (also
  evicting the pass-0 placeholder). Not routed through
  `coercionResolver().classify`: that runtime-coercion planner is unsound
  here (null's natural type is void → false-rejects `*T`; bool is 1 bit →
  false-accepts s64).
- program_index.zig: `moduleConstInt` now takes the `TypeTable` and gates
  the fold on `isCountableConstType(ci.ty)` (integer of any width, or a
  float), so a non-numeric typed const can never fold into a count off its
  initializer node. Callers in lower.zig and type_bridge.zig updated.

Regression:
- examples/1143-diagnostics-typed-module-const-mismatch.sx (negative, exit 1)
- examples/0162-types-typed-module-const-roundtrip.sx (positive)
- program_index.test.zig: gate-on-declared-type unit test

Docs: specs.md §3 Constant Binding + readme.md note the compatibility rule.
This commit is contained in:
agra
2026-06-05 07:17:20 +03:00
parent 3edb60762d
commit 156edf8e28
15 changed files with 326 additions and 16 deletions

View File

@@ -920,12 +920,75 @@ pub const Lowering = struct {
switch (cd.value.data) {
.int_literal, .float_literal, .bool_literal, .string_literal, .undef_literal, .null_literal => {
const ty = self.resolveType(ta);
// An unresolvable annotation is already diagnosed by the type
// resolver; don't pile a bogus type-mismatch on top, and don't
// leave the pass-0 placeholder (registered off the initializer
// literal) behind as a usable const.
if (ty == .unresolved) {
_ = self.program_index.module_const_map.remove(cd.name);
return;
}
// Validate the initializer literal against the explicit
// annotation. A mismatch (`N : string : 4`) is a type error, not
// a silently-accepted const — registering it would let
// `emitModuleConst` stamp the literal with the wrong IR type
// (an int emitted as a `string` const → a bogus pointer that
// segfaults at the use site) and let the count path fold it
// (`[N]s64` → 4). Issue 0088.
if (!self.typedConstInitFits(cd.value, ty)) {
if (self.diagnostics) |d| {
d.addFmt(.err, cd.value.span, "type mismatch: constant '{s}' is declared '{s}' but its initializer is {s}", .{
cd.name, self.formatTypeName(ty), literalKindName(cd.value),
});
}
// Evict the pass-0 placeholder (`N : string : 4` was
// pre-registered as `.s64` off its `int_literal` in
// scanDecls pass 0); leaving it would let a count use still
// fold `N` to 4.
_ = self.program_index.module_const_map.remove(cd.name);
return;
}
self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = ty }) catch {};
},
else => {},
}
}
/// True iff a literal initializer of `value`'s kind is faithfully
/// representable at the declared `dst_ty` — the precondition
/// `emitModuleConst` relies on when it materialises the constant. The arms
/// match `emitModuleConst`'s arms exactly, using the same type-kind
/// predicates (`isIntEx` / `isFloat` / the `module.types.get` tag) the rest
/// of lowering uses.
///
/// Deliberately NOT routed through `coercionResolver().classify`
/// (conversions.zig): that planner judges RUNTIME value coercions and is
/// unsound as a compile-time literal-representability oracle here — a `null`
/// literal's natural type is `.void`, so `classify(.void, *T)` yields `.none`
/// and would reject the valid `P : *void : null`; `bool` is 1 bit wide, so
/// `classify(.bool, s64)` yields `.widen` and would accept the bogus
/// `B : s64 : true`.
fn typedConstInitFits(self: *Lowering, value: *const Node, dst_ty: TypeId) bool {
return switch (value.data) {
// `---` zero-inits at any type.
.undef_literal => true,
// Integer literal → any integer (incl. custom widths) or float
// (`WIDTH : f32 : 800`).
.int_literal => self.isIntEx(dst_ty) or isFloat(dst_ty),
// Float literal → a float type only (the float arm emits `constFloat`).
.float_literal => isFloat(dst_ty),
.bool_literal => dst_ty == .bool,
.string_literal => dst_ty == .string,
// `null` → a pointer or optional.
.null_literal => !dst_ty.isBuiltin() and switch (self.module.types.get(dst_ty)) {
.pointer, .many_pointer, .optional => true,
else => false,
},
// Only the literal kinds the caller's switch admits reach here.
else => true,
};
}
/// Register a top-level mutable global (e.g., `context : Context = ---;`).
/// Run AFTER `resolveForwardIdentifierAliases` so a forward identifier alias
/// in the type annotation (`A :: B; B :: s32; g : A = 7;`) resolves to its
@@ -11876,7 +11939,7 @@ pub const Lowering = struct {
// The module-const branch is shared verbatim with the stateless
// registration-time resolver (`type_bridge`) so a `[N]T` dimension
// resolves to the same length on both paths (issue 0083).
return program_index_mod.moduleConstInt(&self.program_index.module_const_map, name);
return program_index_mod.moduleConstInt(&self.program_index.module_const_map, &self.module.types, name);
}
/// Resolve a type node, checking type_bindings first for generic type params.
@@ -15519,6 +15582,20 @@ pub const Lowering = struct {
return self.closureShapeKey(params, self.returnValuePart(ret));
}
/// Human-readable name for a literal initializer kind, used in the typed
/// module-const type-mismatch diagnostic.
fn literalKindName(node: *const Node) []const u8 {
return switch (node.data) {
.int_literal => "an integer literal",
.float_literal => "a float literal",
.bool_literal => "a boolean literal",
.string_literal => "a string literal",
.null_literal => "null",
.undef_literal => "'---'",
else => "a value",
};
}
fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) {
.add => "+",

View File

@@ -215,6 +215,8 @@ test "floatToIntExact accepts integral floats, rejects the rest" {
}
test "moduleConstInt folds expression-RHS consts and rejects cycles" {
var table = types.TypeTable.init(std.testing.allocator);
defer table.deinit();
var map = std.StringHashMap(pi.ModuleConstInfo).init(std.testing.allocator);
defer map.deinit();
@@ -236,12 +238,12 @@ test "moduleConstInt folds expression-RHS consts and rejects cycles" {
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);
try std.testing.expectEqual(@as(?i64, 2), pi.moduleConstInt(&map, &table, "M"));
try std.testing.expectEqual(@as(?i64, 3), pi.moduleConstInt(&map, &table, "N"));
try std.testing.expectEqual(@as(?i64, 6), pi.moduleConstInt(&map, &table, "P"));
try std.testing.expectEqual(@as(?i64, 4), pi.moduleConstInt(&map, &table, "F"));
try std.testing.expect(pi.moduleConstInt(&map, &table, "G") == null);
try std.testing.expect(pi.moduleConstInt(&map, &table, "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
@@ -256,9 +258,29 @@ test "moduleConstInt folds expression-RHS consts and rejects cycles" {
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);
try std.testing.expect(pi.moduleConstInt(&map, &table, "A") == null);
try std.testing.expect(pi.moduleConstInt(&map, &table, "B") == null);
try std.testing.expect(pi.moduleConstInt(&map, &table, "C") == null);
}
test "moduleConstInt gates the fold on the declared type, not the initializer node" {
var table = types.TypeTable.init(std.testing.allocator);
defer table.deinit();
var map = std.StringHashMap(pi.ModuleConstInfo).init(std.testing.allocator);
defer map.deinit();
// An `int_literal` value node folds to an integer ONLY when the declared
// type is numeric. A `string`/`bool`-typed const carrying an integer-looking
// initializer must never be folded into a count (issue 0088): the count path
// consults `ModuleConstInfo.ty`, not just the node shape.
var int_val = nLit(4);
try map.put("OK", .{ .value = &int_val, .ty = .s64 });
try map.put("STR", .{ .value = &int_val, .ty = .string });
try map.put("BOOLEAN", .{ .value = &int_val, .ty = .bool });
try std.testing.expectEqual(@as(?i64, 4), pi.moduleConstInt(&map, &table, "OK"));
try std.testing.expect(pi.moduleConstInt(&map, &table, "STR") == null);
try std.testing.expect(pi.moduleConstInt(&map, &table, "BOOLEAN") == null);
}
test "evalConstIntExpr folds an integral float literal, halts on a fractional one" {

View File

@@ -91,20 +91,40 @@ fn moduleConstFrameContains(frame: ?*const ModuleConstFrame, name: []const u8) b
/// scope, so `lookupPackLen` is always null.
const ModuleConstCtx = struct {
consts: *const std.StringHashMap(ModuleConstInfo),
table: *const types.TypeTable,
frame: ?*const ModuleConstFrame,
pub fn lookupDimName(self: ModuleConstCtx, name: []const u8) ?i64 {
return moduleConstIntFramed(self.consts, name, self.frame);
return moduleConstIntFramed(self.consts, self.table, 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 {
/// A module const may serve as an integer COUNT only when its DECLARED type is
/// numeric — an integer of any width or a float (an integral float folds to its
/// int via `floatToIntExact`). `moduleConstIntFramed` consults this so a count
/// is gated on `ModuleConstInfo.ty`, not just the shape of the initializer node:
/// a `string`/`bool`/pointer/struct-typed const can never be folded into a count
/// off an integer-looking initializer (issue 0088 — the second symptom, where
/// `N : string : 4` folded `[N]s64` to 4 by reading the `int_literal` node and
/// ignoring the `string` annotation).
fn isCountableConstType(table: *const types.TypeTable, ty: TypeId) bool {
return switch (ty) {
.s8, .s16, .s32, .s64, .u8, .u16, .u32, .u64, .usize, .isize, .f32, .f64 => true,
else => if (ty.isBuiltin()) false else switch (table.get(ty)) {
.signed, .unsigned => true,
else => false,
},
};
}
fn moduleConstIntFramed(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8, parent: ?*const ModuleConstFrame) ?i64 {
if (moduleConstFrameContains(parent, name)) return null;
const ci = consts.get(name) orelse return null;
if (!isCountableConstType(table, ci.ty)) return null;
var frame = ModuleConstFrame{ .name = name, .parent = parent };
return evalConstIntExpr(ci.value, ModuleConstCtx{ .consts = consts, .frame = &frame });
return evalConstIntExpr(ci.value, ModuleConstCtx{ .consts = consts, .table = table, .frame = &frame });
}
/// A name bound to a module-global integer constant → its value, else null.
@@ -120,8 +140,8 @@ fn moduleConstIntFramed(consts: *const std.StringHashMap(ModuleConstInfo), name:
/// 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 {
return moduleConstIntFramed(consts, name, null);
pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8) ?i64 {
return moduleConstIntFramed(consts, table, name, null);
}
/// Evaluate a constant integer expression to its value. THE single

View File

@@ -70,7 +70,7 @@ const StatelessInner = struct {
/// operand may legitimately be negative.
pub fn lookupDimName(self: StatelessInner, name: []const u8) ?i64 {
const consts = self.consts orelse return null;
return program_index_mod.moduleConstInt(consts, name);
return program_index_mod.moduleConstInt(consts, self.table, name);
}
/// Pack-length leaf for the shared integer-expression evaluator. The
/// registration-time path has no pack-arity information (packs are bound