From 156edf8e28f08755cf0a21e6712c585f6d1f8946 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 07:17:20 +0300 Subject: [PATCH 1/3] fix(ir): reject typed module const whose initializer mismatches annotation [F0.7] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...0162-types-typed-module-const-roundtrip.sx | 35 +++++++ ...diagnostics-typed-module-const-mismatch.sx | 20 ++++ ...62-types-typed-module-const-roundtrip.exit | 1 + ...-types-typed-module-const-roundtrip.stderr | 1 + ...-types-typed-module-const-roundtrip.stdout | 4 + ...agnostics-typed-module-const-mismatch.exit | 1 + ...nostics-typed-module-const-mismatch.stderr | 23 +++++ ...nostics-typed-module-const-mismatch.stdout | 1 + ...-typed-module-const-annotation-mismatch.md | 93 +++++++++++++++++++ readme.md | 5 + specs.md | 7 ++ src/ir/lower.zig | 79 +++++++++++++++- src/ir/program_index.test.zig | 40 ++++++-- src/ir/program_index.zig | 30 +++++- src/ir/type_bridge.zig | 2 +- 15 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 examples/0162-types-typed-module-const-roundtrip.sx create mode 100644 examples/1143-diagnostics-typed-module-const-mismatch.sx create mode 100644 examples/expected/0162-types-typed-module-const-roundtrip.exit create mode 100644 examples/expected/0162-types-typed-module-const-roundtrip.stderr create mode 100644 examples/expected/0162-types-typed-module-const-roundtrip.stdout create mode 100644 examples/expected/1143-diagnostics-typed-module-const-mismatch.exit create mode 100644 examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr create mode 100644 examples/expected/1143-diagnostics-typed-module-const-mismatch.stdout create mode 100644 issues/0088-typed-module-const-annotation-mismatch.md diff --git a/examples/0162-types-typed-module-const-roundtrip.sx b/examples/0162-types-typed-module-const-roundtrip.sx new file mode 100644 index 0000000..d899733 --- /dev/null +++ b/examples/0162-types-typed-module-const-roundtrip.sx @@ -0,0 +1,35 @@ +// 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`) +// +// 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). +#import "modules/std.sx"; + +K : s64 : 4; +W : f32 : 800; +PI : f32 : 3.14159; +S : string : "hi"; +P : *void : null; + +main :: () { + // Integer const: prints AND drives an array dimension (len 4). + a : [K]s64 = ---; + a[0] = 10; + a[3] = 40; + print("K={} len={} a0={} a3={}\n", K, a.len, a[0], a[3]); + + // Integer-into-float and float consts print as floats. + print("W={} PI={}\n", W, PI); + + // String const prints its text. + print("S={}\n", S); + + // Null pointer const is null. + print("P_is_null={}\n", P == null); +} diff --git a/examples/1143-diagnostics-typed-module-const-mismatch.sx b/examples/1143-diagnostics-typed-module-const-mismatch.sx new file mode 100644 index 0000000..afbb2ed --- /dev/null +++ b/examples/1143-diagnostics-typed-module-const-mismatch.sx @@ -0,0 +1,20 @@ +// 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 +// 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. + +#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 + +main :: () { + print("unreachable\n"); +} diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.exit b/examples/expected/0162-types-typed-module-const-roundtrip.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0162-types-typed-module-const-roundtrip.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.stderr b/examples/expected/0162-types-typed-module-const-roundtrip.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.stdout b/examples/expected/0162-types-typed-module-const-roundtrip.stdout new file mode 100644 index 0000000..379f4df --- /dev/null +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stdout @@ -0,0 +1,4 @@ +K=4 len=4 a0=10 a3=40 +W=800.000000 PI=3.141590 +S=hi +P_is_null=true diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.exit b/examples/expected/1143-diagnostics-typed-module-const-mismatch.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr new file mode 100644 index 0000000..dbb4b57 --- /dev/null +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr @@ -0,0 +1,23 @@ +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 + | +13 | 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 + | +14 | 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 + | +15 | 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 + | +16 | G : s64 : 1.5; // float literal where an integer is annotated + | ^^^ diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stdout b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stdout @@ -0,0 +1 @@ + diff --git a/issues/0088-typed-module-const-annotation-mismatch.md b/issues/0088-typed-module-const-annotation-mismatch.md new file mode 100644 index 0000000..be8948b --- /dev/null +++ b/issues/0088-typed-module-const-annotation-mismatch.md @@ -0,0 +1,93 @@ +> **RESOLVED (F0.7)** — A typed module-level constant whose initializer does not +> match its annotation is now rejected at the declaration with a clear +> `type mismatch` diagnostic, killing both symptoms (the `print(N)` segfault and +> the `[N]s64` → 4 fold). +> +> **Root cause.** `registerTypedModuleConst` (`src/ir/lower.zig`) stored the +> annotation type on the const but never checked the initializer literal against +> it, so `N : string : 4` registered as `{value = int 4, ty = string}`. +> `emitModuleConst` then stamped the `int_literal` with the `string` type (a +> bogus pointer → segfault at the use site), and `program_index.moduleConstInt` +> folded the const into an integer COUNT by inspecting the `int_literal` node +> alone, ignoring `ModuleConstInfo.ty` (so `[N]s64` folded to 4). +> +> **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. +> +> **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. +> - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the +> declared type, not the initializer node`. + +# 0088 — Typed module const annotation mismatch is accepted + +## Symptom + +A module-level typed constant whose initializer does not match its annotation is +accepted. Observed: `N : string : 4` compiles; printing `N` segfaults, and using +`N` as an array dimension folds it as `4`. Expected: the const declaration emits +a type-mismatch diagnostic and no downstream use treats it as a valid string or +integer count. + +## Reproduction + +```sx +#import "modules/std.sx"; + +N : string : 4; + +main :: () { + print("N={}\n", N); +} +``` + +Related count-surface manifestation: + +```sx +#import "modules/std.sx"; + +N : string : 4; + +main :: () { + a : [N]s64 = ---; + print("{}\n", a.len); +} +``` + +Observed on `flow/sx-foundation/F0.4` attempt 10: the first repro segfaults in +the generated program; the second prints `4`. + +## Investigation prompt + +Fix issue 0088: typed module constants must validate/coerce their initializer +against the explicit annotation before being registered or used. Suspected area: +`src/ir/lower.zig`, especially `registerTypedModuleConst`, `lowerExpr`'s +module-const identifier path, and any const-declaration lowering that stores +`ProgramIndex.module_const_map` entries. `src/ir/program_index.zig`'s +`moduleConstInt` currently folds by inspecting the initializer node and ignores +`ModuleConstInfo.ty`; after the declaration is diagnosed or represented +correctly, a non-integer typed const such as `N : string : 4` must not become a +valid count. Likely fix: add a typed-const validation path that emits a clear +diagnostic for incompatible initializer/annotation pairs, and ensure the +module-const count lookup only accepts constants whose declared/inferred type is +numeric and integral-compatible. Verify by running the two repros above: expect +a non-zero compile with a type-mismatch diagnostic for `N : string : 4`, no +runtime segfault, and no `[N]` length of `4`. diff --git a/readme.md b/readme.md index e1846ec..6c31334 100644 --- a/readme.md +++ b/readme.md @@ -114,6 +114,11 @@ y : s32 = 0; // explicit type 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 +compile-time `type mismatch` error, not a silently-accepted constant. + Builtin type names (`s2`, `u8`, `bool`, `string`, …) are reserved and a *bare* spelling can't be used as an identifier at a **value-binding or declaration-name** site — a value binding (`:=` / typed local / parameter), a `::` constant or diff --git a/specs.md b/specs.md index d78ed4d..bdcd3a7 100644 --- a/specs.md +++ b/specs.md @@ -1458,6 +1458,13 @@ SOME_FUNC :: () => 42; // () -> s32 SOME_TYPE :: f64; // type alias ``` +With an explicit annotation, the initializer literal 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. + ### Variable Binding (mutable) ```sx diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1558a7e..23a6789 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 => "+", diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index fc3013e..00eee7f 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -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" { diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index e629189..b7a6657 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -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 diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 3d87458..96a85b2 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -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 From 454ea06bd4ded378c1f7434ad4f2994f3fa5f32e Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 07:51:16 +0300 Subject: [PATCH 2/3] 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" { From b69ec43ba336c77c06c0338742779f8fc3430f70 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 08:23:59 +0300 Subject: [PATCH 3/3] fix(ir): infer mixed int+float arithmetic as the promoted float [F0.7] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExprTyper.inferType`'s binary-op arm inferred every non-comparison op from the LHS alone, so `M + 0.5` (s64 + f64) statically typed as s64 while `0.5 + M` typed as f64 — operand-order-dependent. The value path (`lowerBinaryOp`) already promoted int×float → float, so static inference disagreed with the value: `M + 0.5` formatted as a truncated int and a typed const `BAD : s64 : M + 0.5` was accepted+truncated (issue 0088 mixed-numeric escape). Extract the value path's inline promotion into a shared `Lowering.arithResultType(lhs, rhs)` and reuse it at both sites, so arithmetic / bitwise / shift inference reports exactly the type the lowered value carries — int LHS × float RHS → the float, order- independent. The value-path behavior is unchanged (the block is moved verbatim into the helper), so no IR shifts; the suite stays green. The typed-const validation reuses `inferExprType`, so this auto-closes the escape with no change to the validation logic. - examples/1143: BAD/BAD2 (`s64 : M + 0.5`, `s64 : 0.5 + M`) rejected in both operand orders. - examples/0162: MF/MFR (`f64 : M + 0.5`, `f64 : 0.5 + M`) fold to 2.5. - examples/0163 (new): pins the inference fix in a value context (`print("{}", n + 0.5)` formats the float, both orders, +-*/, f32). - expr_typer.test.zig: arithResultType + mixed-arithmetic inference. - specs.md / readme.md: document the numeric-promotion rule. - issues/0088: RESOLVED banner notes the inferExprType root fix. --- ...0162-types-typed-module-const-roundtrip.sx | 25 +++++++--- .../0163-types-mixed-numeric-promotion.sx | 35 ++++++++++++++ ...diagnostics-typed-module-const-mismatch.sx | 20 +++++--- ...-types-typed-module-const-roundtrip.stdout | 1 + .../0163-types-mixed-numeric-promotion.exit | 1 + .../0163-types-mixed-numeric-promotion.stderr | 1 + .../0163-types-mixed-numeric-promotion.stdout | 5 ++ ...nostics-typed-module-const-mismatch.stderr | 48 ++++++++++++------- ...-typed-module-const-annotation-mismatch.md | 34 +++++++++++-- readme.md | 5 +- specs.md | 13 +++++ src/ir/expr_typer.test.zig | 42 +++++++++++++++- src/ir/expr_typer.zig | 7 ++- src/ir/lower.zig | 33 ++++++++----- 14 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 examples/0163-types-mixed-numeric-promotion.sx create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.exit create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.stderr create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.stdout diff --git a/examples/0162-types-typed-module-const-roundtrip.sx b/examples/0162-types-typed-module-const-roundtrip.sx index cdcfd8e..780eb9a 100644 --- a/examples/0162-types-typed-module-const-roundtrip.sx +++ b/examples/0162-types-typed-module-const-roundtrip.sx @@ -7,23 +7,30 @@ // - null → pointer (`P : *void : null`) // - integer EXPRESSION → integer (`KE : s64 : M + 2`) — usable as a count too // - integer EXPRESSION → float (`WE : f32 : M + 2`) +// - MIXED int+float EXPRESSION → float (`MF : f64 : M + 0.5`, both operand orders) // // 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) — including const-EXPRESSION // initializers, whose type-based validation (attempt 2) must accept a correctly // typed expression even though it isn't a literal. +// +// `MF`/`MFR` pin the attempt-3 inferExprType promotion fix: a mixed int+float +// arithmetic expression infers as the float result regardless of operand order, +// so it matches an `f64` annotation (and folds to 2.5, not a truncated 2). #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; +K : s64 : 4; +W : f32 : 800; +PI : f32 : 3.14159; +S : string : "hi"; +P : *void : null; +KE : s64 : M + 2; +WE : f32 : M + 2; +MF : f64 : M + 0.5; +MFR : f64 : 0.5 + M; main :: () { // Integer const: prints AND drives an array dimension (len 4). @@ -44,4 +51,8 @@ main :: () { // Integer const-EXPRESSION: prints AND drives an array dimension (len 4). b : [KE]s64 = ---; print("KE={} len={} WE={}\n", KE, b.len, WE); + + // Mixed int+float const-EXPRESSION folds to the promoted float (2.5), + // operand-order-independent. + print("MF={} MFR={}\n", MF, MFR); } diff --git a/examples/0163-types-mixed-numeric-promotion.sx b/examples/0163-types-mixed-numeric-promotion.sx new file mode 100644 index 0000000..f59d8df --- /dev/null +++ b/examples/0163-types-mixed-numeric-promotion.sx @@ -0,0 +1,35 @@ +// Mixed int+float arithmetic infers as the FLOAT result, operand-order-independent. +// +// `print("{}", expr)` selects integer- vs float-formatting from the STATIC type +// `inferExprType` reports for the argument (not the lowered value's type), so it +// exercises the binary-op inference arm directly — distinct from the typed-const +// validation path. Before the attempt-3 fix, binary-op inference was LHS-biased: +// `n + 0.5` (int LHS) inferred `s64` and printed a truncated `2`, while `0.5 + n` +// (float LHS) inferred `f64` and printed `2.5`. The fix routes both through the +// shared promotion rule (`Lowering.arithResultType`, the same one `lowerBinaryOp` +// applies for the value), so an int operand with a float operand promotes to the +// float in either order. +// +// Regression (issue 0088, attempt 3 — the inferExprType numeric-promotion root fix). + +#import "modules/std.sx"; + +main :: () { + n := 2; // runtime s64 + + // Addition, both operand orders — both promote to f64 → 2.5. + print("add: {} {}\n", n + 0.5, 0.5 + n); + + // Multiplication, both orders — both promote → 3.0. + print("mul: {} {}\n", n * 1.5, 1.5 * n); + + // Subtraction / division with the int on the left. + print("sub: {} div: {}\n", n - 0.5, n / 4.0); + + // f32 operand promotes too (int LHS, f32 RHS). + half : f32 = 0.5; + print("f32: {}\n", n + half); + + // A pure-int expression is unaffected — stays s64, prints as an integer. + print("int: {}\n", n + 3); +} diff --git a/examples/1143-diagnostics-typed-module-const-mismatch.sx b/examples/1143-diagnostics-typed-module-const-mismatch.sx index 9b1748a..f8b3b50 100644 --- a/examples/1143-diagnostics-typed-module-const-mismatch.sx +++ b/examples/1143-diagnostics-typed-module-const-mismatch.sx @@ -10,17 +10,25 @@ // 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). +// +// The mixed-numeric pair (`s64 : M + 0.5`, `s64 : 0.5 + M`) is rejected in BOTH +// operand orders: arithmetic binary-op inference now promotes int+float to the +// float result (`Lowering.arithResultType`), so an s64 annotation no longer +// matches a float-producing initializer regardless of which operand is the +// float (issue 0088, attempt 3 — the inferExprType promotion root fix). #import "modules/std.sx"; 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 +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 +BAD : s64 : M + 0.5; // mixed int+float (int LHS) → f64, rejected vs s64 +BAD2 : s64 : 0.5 + M; // mixed float+int (float LHS) → f64, rejected vs s64 — order-independent 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 2368e62..aac2be0 100644 --- a/examples/expected/0162-types-typed-module-const-roundtrip.stdout +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stdout @@ -3,3 +3,4 @@ W=800.000000 PI=3.141590 S=hi P_is_null=true KE=4 len=4 WE=4.000000 +MF=2.500000 MFR=2.500000 diff --git a/examples/expected/0163-types-mixed-numeric-promotion.exit b/examples/expected/0163-types-mixed-numeric-promotion.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0163-types-mixed-numeric-promotion.stderr b/examples/expected/0163-types-mixed-numeric-promotion.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0163-types-mixed-numeric-promotion.stdout b/examples/expected/0163-types-mixed-numeric-promotion.stdout new file mode 100644 index 0000000..1424183 --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.stdout @@ -0,0 +1,5 @@ +add: 2.500000 2.500000 +mul: 3.000000 3.000000 +sub: 1.500000 div: 0.500000 +f32: 2.500000 +int: 5 diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr index c997a24..8b93822 100644 --- a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr @@ -1,35 +1,47 @@ error: type mismatch: constant 'N' is declared 'string' but its initializer is an integer literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:18:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:24:15 | -18 | N : string : 4; // integer literal where a string is annotated - | ^ +24 | 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:19:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:25:15 | -19 | F : s64 : "x"; // string literal where an integer is annotated - | ^^^ +25 | 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:20:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:26:15 | -20 | B : s64 : true; // boolean literal where an integer is annotated - | ^^^^ +26 | 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:21:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:27:15 | -21 | G : s64 : 1.5; // float literal where an integer is annotated - | ^^^ +27 | 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 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:28:15 | -22 | E : string : M + 2; // integer EXPRESSION where a string is annotated - | ^^^^^ +28 | 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 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:29:15 | -23 | V : string : -M; // integer (unary) expression where a string is annotated - | ^^ +29 | V : string : -M; // integer (unary) expression where a string is annotated + | ^^ + +error: type mismatch: constant 'BAD' is declared 's64' but its initializer is a floating-point expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:30:15 + | +30 | BAD : s64 : M + 0.5; // mixed int+float (int LHS) → f64, rejected vs s64 + | ^^^^^^^ + +error: type mismatch: constant 'BAD2' is declared 's64' but its initializer is a floating-point expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:31:15 + | +31 | BAD2 : s64 : 0.5 + M; // mixed float+int (float LHS) → f64, rejected vs s64 — order-independent + | ^^^^^^^ diff --git a/issues/0088-typed-module-const-annotation-mismatch.md b/issues/0088-typed-module-const-annotation-mismatch.md index b921f4e..1cd2d12 100644 --- a/issues/0088-typed-module-const-annotation-mismatch.md +++ b/issues/0088-typed-module-const-annotation-mismatch.md @@ -15,6 +15,22 @@ > (`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). > +> **Mixed-numeric escape closed at the type-system root (attempt 3).** The +> type-based validation reuses `inferExprType`, which inferred a non-comparison +> binary op from its LHS alone — so `BAD : s64 : M + 0.5` (s64 + f64) inferred +> `s64` and was accepted+truncated, while `0.5 + M` inferred `f64` and was +> rejected: operand-order-dependent. The fix is in the binary-op arm of +> `ExprTyper.inferType` (`src/ir/expr_typer.zig`): arithmetic / bitwise / shift +> ops now infer the PROMOTED result of `(lhs, rhs)` via `Lowering.arithResultType` +> — the same int×float → float rule `lowerBinaryOp` already applied for the +> value (extracted from its inline block into a shared helper, so the two can't +> diverge). `M + 0.5` now infers `f64` in either operand order, so the typed-const +> validation rejects it against an `s64` annotation with no special-casing in the +> validation logic itself. This was a pre-existing inference bug broader than +> typed consts (it also mis-formatted `print("{}", M + 0.5)` as a truncated int); +> the typed-LOCAL `y : s64 = 1.5` → 1 narrowing is a SEPARATE assignment-coercion +> bug tracked as issue 0095. +> > **Fix per file.** > - `src/ir/lower.zig` — `registerTypedModuleConst` validates the initializer > against the resolved annotation BY TYPE, covering literals AND @@ -39,17 +55,25 @@ > updated. > > **Regression tests.** -> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: six +> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: eight > 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. +> `float→s64`), two const-expression (`M + 2 → string`, `-M → string`), and two +> mixed-numeric (`s64 : M + 0.5` and `s64 : 0.5 + M`, rejected in BOTH operand +> orders) — 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. +> `*void` null, const-expression `s64 : M + 2` used as a count + printed, +> `f32 : M + 2`, plus mixed-numeric `f64 : M + 0.5` and `f64 : 0.5 + M` folding +> to 2.5 in both orders) compile, fold, and print correctly. +> - `examples/0163-types-mixed-numeric-promotion.sx` — positive: pins the +> inferExprType promotion DIRECTLY in a value context (`print("{}", n + 0.5)` +> formats as the float `2.5`, both operand orders, across `+ - * /` and an f32 +> operand; a pure-int expression stays an integer). > - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the > declared type, not the initializer node` (covers both a literal and a > binary_op value node declared with a non-numeric type). +> - `src/ir/expr_typer.test.zig` — `arithResultType promotes int×float to the +> float regardless of operand order` (the shared promotion helper). # 0088 — Typed module const annotation mismatch is accepted diff --git a/readme.md b/readme.md index 1c54c60..c49d890 100644 --- a/readme.md +++ b/readme.md @@ -118,7 +118,10 @@ A typed constant's initializer must be compatible with its annotation — an 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. +compile-time `type mismatch` error, not a silently-accepted constant. Mixed +int+float arithmetic promotes to the float in either operand order (`n + 0.5` and +`0.5 + n` are both `f64`), so `C : s64 : M + 0.5` is rejected regardless of order +while `F : f64 : M + 0.5` folds to `2.5`. Builtin type names (`s2`, `u8`, `bool`, `string`, …) are reserved and a *bare* spelling can't be used as an identifier at a **value-binding or declaration-name** diff --git a/specs.md b/specs.md index 14a0c00..ffe9320 100644 --- a/specs.md +++ b/specs.md @@ -1465,6 +1465,10 @@ 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. +A constant expression's type is its promoted result type (see +[Arithmetic](#arithmetic)), so a mixed int+float initializer is a float in either +operand order: `C : s64 : M + 0.5` and `C : s64 : 0.5 + M` are both rejected, and +`F : f64 : M + 0.5` is accepted and folds to `2.5`. ### Variable Binding (mutable) @@ -1758,6 +1762,15 @@ x * x x + 2 ``` +**Numeric promotion.** When the two operands of an arithmetic op have different +numeric types, the result is the promoted type: an integer operand combined with +a floating-point operand yields the **float**, regardless of operand order +(`n + 0.5` and `0.5 + n` both produce an `f64`). This holds for the expression's +static type as well as its value, so `print("{}", n + 0.5)` formats a float and a +typed binding `x : f64 = n + 0.5` is exact (not truncated). A mixed-numeric +expression therefore does not satisfy an integer annotation — `C : s64 : n + 0.5` +is a `type mismatch` in either operand order. + ### Chained Comparisons Comparison operators can be chained. Each operand is evaluated exactly once. ```sx diff --git a/src/ir/expr_typer.test.zig b/src/ir/expr_typer.test.zig index b267ada..cabebe3 100644 --- a/src/ir/expr_typer.test.zig +++ b/src/ir/expr_typer.test.zig @@ -34,7 +34,7 @@ test "expr_typer: literal shapes" { try std.testing.expectEqual(TypeId.string, l.inferExprType(&str_n)); } -test "expr_typer: binary comparison is bool, arithmetic takes lhs type" { +test "expr_typer: binary comparison is bool, int arithmetic stays int" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); defer module.deinit(); @@ -50,6 +50,46 @@ test "expr_typer: binary comparison is bool, arithmetic takes lhs type" { try std.testing.expectEqual(TypeId.s64, l.inferExprType(&add)); } +// issue 0088 (attempt 3): a non-comparison binary op infers the PROMOTED result +// of (lhs, rhs), not the LHS alone — so a mixed int+float op types as the float +// in EITHER operand order (was LHS-biased: `int + float` → s64 while +// `float + int` → f64). This is what feeds the typed-const validation that +// rejected `s64 : 0.5 + M` but not `s64 : M + 0.5`. +test "expr_typer: mixed int+float arithmetic promotes to float, order-independent" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var l = Lowering.init(&module); + + var int_n = node(.{ .int_literal = .{ .value = 2 } }); + var float_n = node(.{ .float_literal = .{ .value = 0.5 } }); + + // int LHS, float RHS → f64 (was s64 before the fix). + var add_if = node(.{ .binary_op = .{ .op = .add, .lhs = &int_n, .rhs = &float_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&add_if)); + + // float LHS, int RHS → f64 (already correct; confirms order-independence). + var add_fi = node(.{ .binary_op = .{ .op = .add, .lhs = &float_n, .rhs = &int_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&add_fi)); + + // Multiplication promotes the same way. + var mul_if = node(.{ .binary_op = .{ .op = .mul, .lhs = &int_n, .rhs = &float_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&mul_if)); +} + +// The shared promotion helper itself (single source of truth for both +// `lowerBinaryOp`'s value type and `inferExprType`): an integer LHS with a +// floating-point RHS promotes to the float; every other pairing keeps the LHS. +test "arithResultType: int×float promotes to float, else takes lhs" { + try std.testing.expectEqual(TypeId.f64, Lowering.arithResultType(.s64, .f64)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.u32, .f32)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.s64, .f32)); + // Non-promoting pairings keep the LHS type. + try std.testing.expectEqual(TypeId.s64, Lowering.arithResultType(.s64, .s64)); + try std.testing.expectEqual(TypeId.f64, Lowering.arithResultType(.f64, .s64)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.f32, .f64)); +} + test "expr_typer: unary not is bool, negate preserves operand type" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index f9a1803..364cd78 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -49,7 +49,12 @@ pub const ExprTyper = struct { break :blk .bool; }, .eq, .neq, .lt, .lte, .gt, .gte, .and_op, .in_op => .bool, - else => self.l.inferExprType(bop.lhs), + // Arithmetic / bitwise / shift ops: infer the PROMOTED result + // of (lhs, rhs), not the LHS alone — `Lowering.arithResultType` + // is the same rule `lowerBinaryOp` applies, so `M + 0.5` types + // as `f64` regardless of operand order (was LHS-biased: `M + 0.5` + // → s64 while `0.5 + M` → f64). + else => Lowering.arithResultType(self.l.inferExprType(bop.lhs), self.l.inferExprType(bop.rhs)), }, .unary_op => |uop| switch (uop.op) { .not => .bool, diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 46d3f80..c031547 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -3263,19 +3263,12 @@ pub const Lowering = struct { const rhs_ref_pointee = self.refCapturePointee(bop.rhs); if (rhs_ref_pointee) |p| rhs = self.builder.load(rhs, p); self.target_type = saved_tt; - // Infer result type from LHS operand (covers float, bool, etc.) - var ty = lhs_ty; - - // Promote int×float → float (e.g., s64 * f32 → f32) - // Only for scalar int LHS — don't affect vectors or structs. - { - const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs); - const l_int = isInt(ty); - const r_float = (rhs_inferred == .f32 or rhs_inferred == .f64); - if (l_int and r_float) { - ty = rhs_inferred; - } - } + // Result type follows the shared promotion rule: an int LHS with a + // float RHS promotes to the float (`s64 * f32` → `f32`); vectors / + // structs keep the LHS type. `inferExprType` reuses the same helper + // so static typing agrees with the value produced here. + const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs); + var ty = arithResultType(lhs_ty, rhs_inferred); // Auto-unwrap optional operands for arithmetic/comparison if (!ty.isBuiltin()) { @@ -14528,6 +14521,20 @@ pub const Lowering = struct { return ty == .f32 or ty == .f64; } + /// Result type of an arithmetic / bitwise / shift binary op over two + /// scalar operand types. This is the single promotion rule shared by the + /// value path (`lowerBinaryOp`) and AST-level inference + /// (`ExprTyper.inferType`'s binary-op arm), so static typing reports + /// exactly the type the lowered value carries. An integer LHS with a + /// floating-point RHS promotes to the float (`s64 + f64` → `f64`); every + /// other pairing — including vectors / structs, whose `isInt` is false — + /// takes the LHS type. Comparison / logical ops never reach here (they + /// are `.bool` at both sites). + pub fn arithResultType(lhs_ty: TypeId, rhs_ty: TypeId) TypeId { + if (isInt(lhs_ty) and isFloat(rhs_ty)) return rhs_ty; + return lhs_ty; + } + fn isInt(ty: TypeId) bool { return switch (ty) { .s8, .s16, .s32, .s64, .u8, .u16, .u32, .u64, .usize, .isize => true,