From 454ea06bd4ded378c1f7434ad4f2994f3fa5f32e Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 07:51:16 +0300 Subject: [PATCH] fix(ir): validate const-expression typed module-const initializers [F0.7] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...0162-types-typed-module-const-roundtrip.sx | 24 +++- ...diagnostics-typed-module-const-mismatch.sx | 21 ++-- ...-types-typed-module-const-roundtrip.stdout | 1 + ...nostics-typed-module-const-mismatch.stderr | 28 +++-- ...-typed-module-const-annotation-mismatch.md | 56 +++++---- readme.md | 5 +- specs.md | 11 +- src/ir/lower.zig | 117 ++++++++++++------ src/ir/program_index.test.zig | 13 ++ 9 files changed, 188 insertions(+), 88 deletions(-) diff --git a/examples/0162-types-typed-module-const-roundtrip.sx b/examples/0162-types-typed-module-const-roundtrip.sx index d899733..cdcfd8e 100644 --- a/examples/0162-types-typed-module-const-roundtrip.sx +++ b/examples/0162-types-typed-module-const-roundtrip.sx @@ -1,21 +1,29 @@ // Valid typed module-level constants compile, fold, and print correctly across // every initializer/annotation pairing the registrar accepts: -// - integer → integer (`K : s64 : 4`) — usable as an array count too -// - integer → float (`W : f32 : 800`) -// - float → float (`PI : f32 : 3.14159`) -// - string → string (`S : string : "hi"`) -// - null → pointer (`P : *void : null`) +// - integer literal → integer (`K : s64 : 4`) — usable as an array count too +// - integer literal → float (`W : f32 : 800`) +// - float literal → float (`PI : f32 : 3.14159`) +// - string literal → string (`S : string : "hi"`) +// - null → pointer (`P : *void : null`) +// - integer EXPRESSION → integer (`KE : s64 : M + 2`) — usable as a count too +// - integer EXPRESSION → float (`WE : f32 : M + 2`) // // Companion to the negative example 1143: the issue-0088 fix rejects a typed // const whose initializer mismatches its annotation, and these correctly-typed -// consts must keep working (no over-rejection). +// consts must keep working (no over-rejection) — including const-EXPRESSION +// initializers, whose type-based validation (attempt 2) must accept a correctly +// typed expression even though it isn't a literal. #import "modules/std.sx"; +M :: 2; + K : s64 : 4; W : f32 : 800; PI : f32 : 3.14159; S : string : "hi"; P : *void : null; +KE : s64 : M + 2; +WE : f32 : M + 2; main :: () { // Integer const: prints AND drives an array dimension (len 4). @@ -32,4 +40,8 @@ main :: () { // Null pointer const is null. print("P_is_null={}\n", P == null); + + // Integer const-EXPRESSION: prints AND drives an array dimension (len 4). + b : [KE]s64 = ---; + print("KE={} len={} WE={}\n", KE, b.len, WE); } diff --git a/examples/1143-diagnostics-typed-module-const-mismatch.sx b/examples/1143-diagnostics-typed-module-const-mismatch.sx index afbb2ed..9b1748a 100644 --- a/examples/1143-diagnostics-typed-module-const-mismatch.sx +++ b/examples/1143-diagnostics-typed-module-const-mismatch.sx @@ -1,19 +1,26 @@ // A typed module-level constant whose initializer does not match its // annotation is a compile-time type error — not a silently-accepted const. -// Each declaration below pairs a literal with an incompatible annotation, so -// the compiler must emit a `type mismatch` diagnostic at the initializer and +// Each declaration below pairs an initializer with an incompatible annotation, +// so the compiler must emit a `type mismatch` diagnostic at the initializer and // abort (exit 1) rather than registering a usable const. // // Regression (issue 0088): `N : string : 4` was accepted; `print(N)` then // segfaulted (an integer emitted as a `string` const → a bogus pointer) and -// `[N]s64` folded `N` to 4. The fix rejects the declaration at the root. +// `[N]s64` folded `N` to 4. The fix rejects the declaration at the root. The +// validation is type-based, so a const-EXPRESSION initializer (`E : string : +// M + 2`, `V : string : -M`) is rejected just like a literal — not skipped +// because its node kind isn't a literal (issue 0088, attempt 2). #import "modules/std.sx"; -N : string : 4; // integer literal where a string is annotated -F : s64 : "x"; // string literal where an integer is annotated -B : s64 : true; // boolean literal where an integer is annotated -G : s64 : 1.5; // float literal where an integer is annotated +M :: 2; + +N : string : 4; // integer literal where a string is annotated +F : s64 : "x"; // string literal where an integer is annotated +B : s64 : true; // boolean literal where an integer is annotated +G : s64 : 1.5; // float literal where an integer is annotated +E : string : M + 2; // integer EXPRESSION where a string is annotated +V : string : -M; // integer (unary) expression where a string is annotated main :: () { print("unreachable\n"); diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.stdout b/examples/expected/0162-types-typed-module-const-roundtrip.stdout index 379f4df..2368e62 100644 --- a/examples/expected/0162-types-typed-module-const-roundtrip.stdout +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stdout @@ -2,3 +2,4 @@ K=4 len=4 a0=10 a3=40 W=800.000000 PI=3.141590 S=hi P_is_null=true +KE=4 len=4 WE=4.000000 diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr index dbb4b57..c997a24 100644 --- a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr @@ -1,23 +1,35 @@ error: type mismatch: constant 'N' is declared 'string' but its initializer is an integer literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:13:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:18:14 | -13 | N : string : 4; // integer literal where a string is annotated +18 | N : string : 4; // integer literal where a string is annotated | ^ error: type mismatch: constant 'F' is declared 's64' but its initializer is a string literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:14:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:19:14 | -14 | F : s64 : "x"; // string literal where an integer is annotated +19 | F : s64 : "x"; // string literal where an integer is annotated | ^^^ error: type mismatch: constant 'B' is declared 's64' but its initializer is a boolean literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:15:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:20:14 | -15 | B : s64 : true; // boolean literal where an integer is annotated +20 | B : s64 : true; // boolean literal where an integer is annotated | ^^^^ error: type mismatch: constant 'G' is declared 's64' but its initializer is a float literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:16:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:21:14 | -16 | G : s64 : 1.5; // float literal where an integer is annotated +21 | G : s64 : 1.5; // float literal where an integer is annotated | ^^^ + +error: type mismatch: constant 'E' is declared 'string' but its initializer is an integer expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:22:14 + | +22 | E : string : M + 2; // integer EXPRESSION where a string is annotated + | ^^^^^ + +error: type mismatch: constant 'V' is declared 'string' but its initializer is an integer expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:23:14 + | +23 | V : string : -M; // integer (unary) expression where a string is annotated + | ^^ diff --git a/issues/0088-typed-module-const-annotation-mismatch.md b/issues/0088-typed-module-const-annotation-mismatch.md index be8948b..b921f4e 100644 --- a/issues/0088-typed-module-const-annotation-mismatch.md +++ b/issues/0088-typed-module-const-annotation-mismatch.md @@ -11,31 +11,45 @@ > folded the const into an integer COUNT by inspecting the `int_literal` node > alone, ignoring `ModuleConstInfo.ty` (so `[N]s64` folded to 4). > +> Both LITERAL initializers (`N : string : 4`) and const-EXPRESSION initializers +> (`M :: 2; N : string : M + 2`, `V : string : -M`) are rejected — the validation +> is type-based, so a non-literal node kind can no longer escape it (attempt 2). +> > **Fix per file.** -> - `src/ir/lower.zig` — `registerTypedModuleConst` now validates the -> initializer against the resolved annotation via the new `typedConstInitFits` -> (arms mirror `emitModuleConst`'s faithful-emit precondition: int → int/float, -> float → float, bool → bool, string → string, null → pointer/optional, -> `---` → any). A mismatch emits `type mismatch: constant '' is declared -> '' but its initializer is ` at the initializer span and does NOT -> register the const (it also evicts the pass-0 placeholder so a count use -> can't still fold it). `literalKindName` names the literal kind for the -> message. -> - `src/ir/program_index.zig` — `moduleConstInt` / `moduleConstIntFramed` now -> take the `TypeTable` and gate the fold on `isCountableConstType(ci.ty)` -> (integer of any width, or a float), so a non-numeric typed const can never be -> folded into a count off its initializer node. Callers in `lower.zig` and -> `type_bridge.zig` updated. +> - `src/ir/lower.zig` — `registerTypedModuleConst` validates the initializer +> against the resolved annotation BY TYPE, covering literals AND +> const-expressions (binary_op / unary_op) uniformly. `typedConstInitFits` +> keeps the literal arms (int → int/float, float → float, bool → bool, +> string → string, null → pointer/optional, `---` → any) and routes any +> non-literal through `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 `type mismatch: constant '' is declared +> '' but its initializer is ` at the initializer span (a literal +> names its kind; a const-expression is described by its inferred type, e.g. +> "an integer expression"), and does NOT register the const — it evicts the +> pass-0 placeholder so a count use can't still fold it. On a MATCH the const is +> registered at its resolved annotation type (the same `put` the literal path +> always did), so a const-expression folds and emits at its declared type. +> - `src/ir/program_index.zig` — `moduleConstInt` / `moduleConstIntFramed` take +> the `TypeTable` and gate the fold on `isCountableConstType(ci.ty)` (integer +> of any width, or a float), so a non-numeric typed const can never be folded +> into a count off its initializer node — whether that node is a literal or a +> foldable integer expression. Callers in `lower.zig` and `type_bridge.zig` +> updated. > > **Regression tests.** -> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: four -> mismatch shapes (`int→string`, `string→s64`, `bool→s64`, `float→s64`) each -> emit a `type mismatch` diagnostic, exit 1. -> - `examples/0162-types-typed-module-const-roundtrip.sx` — positive: valid -> typed consts (`s64` as count + printed, `f32` from int, `f32` float, -> `string`, `*void` null) compile, fold, and print correctly. +> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: six +> mismatch shapes — four literal (`int→string`, `string→s64`, `bool→s64`, +> `float→s64`) and two const-expression (`M + 2 → string`, `-M → string`) — +> each emit a `type mismatch` diagnostic, exit 1. +> - `examples/0162-types-typed-module-const-roundtrip.sx` — positive: valid typed +> consts (`s64` as count + printed, `f32` from int, `f32` float, `string`, +> `*void` null, plus const-expression `s64 : M + 2` used as a count + printed +> and `f32 : M + 2`) compile, fold, and print correctly. > - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the -> declared type, not the initializer node`. +> declared type, not the initializer node` (covers both a literal and a +> binary_op value node declared with a non-numeric type). # 0088 — Typed module const annotation mismatch is accepted diff --git a/readme.md b/readme.md index 6c31334..1c54c60 100644 --- a/readme.md +++ b/readme.md @@ -115,8 +115,9 @@ z : s32 = ---; // uninitialized ``` A typed constant's initializer must be compatible with its annotation — an -integer literal fits any integer or float, a float a float type, a string -`string`, `null` a pointer/optional. A mismatch like `N : string : 4` is a +integer fits any integer or float, a float a float type, a string `string`, +`null` a pointer/optional. The check is type-based, so it covers a literal and a +constant expression alike: both `N : string : 4` and `N : string : M + 2` are a compile-time `type mismatch` error, not a silently-accepted constant. Builtin type names (`s2`, `u8`, `bool`, `string`, …) are reserved and a *bare* diff --git a/specs.md b/specs.md index bdcd3a7..14a0c00 100644 --- a/specs.md +++ b/specs.md @@ -1458,12 +1458,13 @@ SOME_FUNC :: () => 42; // () -> s32 SOME_TYPE :: f64; // type alias ``` -With an explicit annotation, the initializer literal must be compatible with the +With an explicit annotation, the initializer must be compatible with the annotated type, or the declaration is a compile-time `type mismatch` error: an -integer literal fits any integer or float type (`W : f32 : 800`), a float literal -a float type, a boolean `bool`, a string literal `string`, `null` a pointer or -optional, and `---` any type. A mismatch such as `N : string : 4` is rejected at -the declaration — it does not register a usable constant. +integer fits any integer or float type (`W : f32 : 800`), a float a float type, a +boolean `bool`, a string `string`, `null` a pointer or optional, and `---` any +type. The check is type-based, so it applies equally to a literal and to a +constant expression: both `N : string : 4` and `N : string : M + 2` (with +`M :: 2`) are rejected at the declaration — neither registers a usable constant. ### Variable Binding (mutable) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 23a6789..46d3f80 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 => "+", diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index 00eee7f..21916f8 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -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" {