fix(ir): validate const-expression typed module-const initializers [F0.7]

Attempt 1 rejected only LITERAL initializers that mismatch a typed module
const's annotation; a const-EXPRESSION initializer escaped, so the same
issue-0088 root remained for `M :: 2; N : string : M + 2` — accepted at exit 0,
folding `[N]s64` to 4 and printing N as an integer.

Root cause: `registerTypedModuleConst` validated only the enumerated literal
node kinds; any other kind fell through to `else => {}`, and pass 0
pre-registers binary_op/unary_op consts as a `.s64` placeholder that was never
reconciled with the annotation.

Fix — validate by TYPE, not by node kind:
- lower.zig: `registerTypedModuleConst` now covers literals AND const-expressions
  (binary_op/unary_op) through one path. `typedConstInitFits` keeps the literal
  arms and routes any non-literal through the new `constExprInitFits`, which
  compares the initializer's INFERRED type (`inferExprType`, the existing
  type-inference facility — no second const evaluator) to the annotation with the
  same integer/float compatibility. A mismatch emits the `type mismatch` diagnostic
  (a const-expression is described by its inferred type, e.g. "an integer
  expression") and evicts the pass-0 placeholder; a match registers the const at
  its resolved annotation type (the same `put` the literal path always did), so a
  const-expression folds and emits at its declared type.
- `literalKindName` → `initializerDescription` (+ `constExprDescription`) so the
  message is accurate for both a literal and a const-expression initializer.

Regression:
- examples/1143: extended with `E : string : M + 2` and `V : string : -M`
  (const-expr mismatches → exit 1, pinned diagnostics).
- examples/0162: extended with `KE : s64 : M + 2` (used as a count + printed) and
  `WE : f32 : M + 2` (over-rejection guard — valid const-exprs still work).
- program_index.test.zig: count-gate test extended with a binary_op value node
  declared `string` (must not fold as a count).

Docs: specs.md §3 + readme.md generalized from "initializer literal" to cover
constant expressions; issues/0088 RESOLVED banner updated.
This commit is contained in:
agra
2026-06-05 07:51:16 +03:00
parent 156edf8e28
commit 454ea06bd4
9 changed files with 188 additions and 88 deletions

View File

@@ -917,41 +917,47 @@ pub const Lowering = struct {
/// would otherwise mistype the constant (issue 0070).
fn registerTypedModuleConst(self: *Lowering, cd: *const ast.ConstDecl) void {
const ta = cd.type_annotation orelse return;
// Only initializer shapes that pass 0 (binary_op / unary_op → placeholder
// `.s64`) or the literal path register as a USABLE module const need
// reconciling against the annotation. Every other shape (call,
// struct/array literal, bare identifier) is never registered as a
// foldable / emittable const, so it cannot manifest the issue-0088
// wrong-type fold/emit; a use-site diagnostic covers it.
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 => {},
.int_literal, .float_literal, .bool_literal, .string_literal, .undef_literal, .null_literal, .binary_op, .unary_op => {},
else => return,
}
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 behind as a usable const.
if (ty == .unresolved) {
_ = self.program_index.module_const_map.remove(cd.name);
return;
}
// Validate the initializer against the explicit annotation BY TYPE, so a
// const-EXPRESSION initializer (`N : string : M + 2`) is checked exactly
// like a literal rather than skipped. A mismatch is a type error, not a
// silently-accepted const — registering it would let `emitModuleConst`
// stamp the value 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), self.initializerDescription(cd.value),
});
}
// Evict the pass-0 placeholder (`N : string : 4` and
// `N : string : M + 2` are both pre-registered as `.s64` in scanDecls
// pass 0); leaving it would let a count use still fold `N`.
_ = self.program_index.module_const_map.remove(cd.name);
return;
}
// Reconcile the registration with the resolved annotation (pass 0 stored
// a literal/expression placeholder type), so the const folds and emits at
// its declared type — the same `put` the literal path always did.
self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = ty }) catch {};
}
/// True iff a literal initializer of `value`'s kind is faithfully
@@ -984,11 +990,33 @@ pub const Lowering = struct {
.pointer, .many_pointer, .optional => true,
else => false,
},
// Only the literal kinds the caller's switch admits reach here.
else => true,
// Const-EXPRESSION initializer (binary_op / unary_op — the only
// non-literal kinds the caller admits): validate by the initializer's
// INFERRED type so coverage is type-based, not a per-node-kind
// allowlist where an unenumerated kind silently escapes (issue 0088,
// attempt 2). The integer/float fit mirrors the literal arms above.
else => self.constExprInitFits(self.inferExprType(value), dst_ty),
};
}
/// True iff a const-expression initializer of inferred type `init_ty` is
/// faithfully representable at the declared `dst_ty`. Type-based so it covers
/// every const-expression shape (binary_op, unary_op, …) through one check
/// rather than per-node-kind arms. The integer/float arms mirror the
/// int/float literal arms of `typedConstInitFits` (an integer expression fits
/// an integer or float annotation; a float expression fits a float).
fn constExprInitFits(self: *Lowering, init_ty: TypeId, dst_ty: TypeId) bool {
// An initializer whose type we couldn't infer is left for the use-site /
// emission diagnostic rather than rejected here (no over-rejection).
if (init_ty == .unresolved) return true;
if (self.isIntEx(init_ty)) return self.isIntEx(dst_ty) or isFloat(dst_ty);
if (isFloat(init_ty)) return isFloat(dst_ty);
if (init_ty == .bool) return dst_ty == .bool;
if (init_ty == .string) return dst_ty == .string;
// Any other concrete initializer type must match the annotation exactly.
return init_ty == dst_ty;
}
/// 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
@@ -15582,9 +15610,12 @@ 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 {
/// Human-readable description of a typed module-const initializer, used in
/// the issue-0088 type-mismatch diagnostic. A literal names its kind; a
/// const-expression is described by its inferred type category, so the
/// message is accurate for `N : string : M + 2` ("an integer expression")
/// as well as for `N : string : 4` ("an integer literal").
fn initializerDescription(self: *Lowering, node: *const Node) []const u8 {
return switch (node.data) {
.int_literal => "an integer literal",
.float_literal => "a float literal",
@@ -15592,10 +15623,18 @@ pub const Lowering = struct {
.string_literal => "a string literal",
.null_literal => "null",
.undef_literal => "'---'",
else => "a value",
else => self.constExprDescription(self.inferExprType(node)),
};
}
fn constExprDescription(self: *Lowering, init_ty: TypeId) []const u8 {
if (self.isIntEx(init_ty)) return "an integer expression";
if (isFloat(init_ty)) return "a floating-point expression";
if (init_ty == .bool) return "a boolean expression";
if (init_ty == .string) return "a string expression";
return "an expression of an incompatible type";
}
fn binOpSymbol(op: ast.BinaryOp.Op) []const u8 {
return switch (op) {
.add => "+",

View File

@@ -281,6 +281,19 @@ test "moduleConstInt gates the fold on the declared type, not the initializer no
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);
// The same gate holds for a const-EXPRESSION value node (`M + 2`), not just
// a bare literal: a `string`-typed const whose initializer is a foldable
// integer expression must still never fold as a count (issue 0088 attempt 2 —
// the const-expression leak). `KEXPR : s64 : M + 2` (numeric type) folds; the
// same expression declared `string` does not.
var m_lit = nLit(2);
var add2 = nLit(2);
var expr_val = nBin(.add, &m_lit, &add2);
try map.put("KEXPR", .{ .value = &expr_val, .ty = .s64 });
try map.put("STREXPR", .{ .value = &expr_val, .ty = .string });
try std.testing.expectEqual(@as(?i64, 4), pi.moduleConstInt(&map, &table, "KEXPR"));
try std.testing.expect(pi.moduleConstInt(&map, &table, "STREXPR") == null);
}
test "evalConstIntExpr folds an integral float literal, halts on a fractional one" {