From 744decc6a18210f522c024ed2ac6ffed2306426a Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 2 Jun 2026 15:51:04 +0300 Subject: [PATCH] fix(ir): reject non-type elements in tuple-literal-as-type (issue 0067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `size_of((s32, 1))` treated the tuple literal as a tuple TYPE: for the non-type element `1` it emitted a `std.debug.print` and substituted `.s64` for that field, then compiled and printed a bogus size — a silent fabricated type (the forbidden silent-fallback pattern). Fix: - type_bridge.resolveTupleLiteralAsType: a non-type element now yields `.unresolved` (no `.s64`, no debug print) — it refuses to fabricate a tuple. type_bridge is stateless, so this is the binding-free backstop. - New stateful Lowering.resolveTupleLiteralTypeArg validates each element via isTypeShapedAstNode, emits a user-facing diagnostic at the offending element's span, and returns `.unresolved`. Wired into resolveTypeArg (size_of/align_of/…) and the resolveTypeWithBindings name-fallback; type_bridge builds the tuple only after validation passes. Regression: examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx (exit 1 + diagnostic). Valid `(s32, s32)` still works (0115). Gate: zig build, zig build test, run_examples 351/0. --- ...ics-tuple-type-nontype-element-rejected.sx | 13 +++ ...s-tuple-type-nontype-element-rejected.exit | 1 + ...tuple-type-nontype-element-rejected.stderr | 5 + ...tuple-type-nontype-element-rejected.stdout | 1 + ...067-tuple-literal-type-nontype-fallback.md | 92 +++++++++++++++++++ src/ir/lower.zig | 25 ++++- src/ir/type_bridge.zig | 19 ++-- 7 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx create mode 100644 examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.exit create mode 100644 examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stderr create mode 100644 examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stdout create mode 100644 issues/0067-tuple-literal-type-nontype-fallback.md diff --git a/examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx b/examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx new file mode 100644 index 0000000..e77e686 --- /dev/null +++ b/examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx @@ -0,0 +1,13 @@ +// A tuple literal used in a type position (`(s32, s32)` reinterpreted as a tuple +// type at a type-demanding site like `size_of`) must list only types. A non-type +// element — here the `1` in `(s32, 1)` — is rejected with a user-facing +// diagnostic instead of silently fabricating an `s64` field for that slot. +// Regression (issue 0067). +// Expected: a clean "tuple type element is not a type" error at the `1`; exit 1. + +#import "modules/std.sx"; + +main :: () -> s32 { + print("bad tuple type size = {}\n", size_of((s32, 1))); + 0 +} diff --git a/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.exit b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stderr b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stderr new file mode 100644 index 0000000..4c0516e --- /dev/null +++ b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stderr @@ -0,0 +1,5 @@ +error: tuple type element is not a type (found `int_literal`); a tuple used as a type must list only types, e.g. `(s32, s32)` + --> examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx:11:55 + | +11 | print("bad tuple type size = {}\n", size_of((s32, 1))); + | ^ diff --git a/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stdout b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1116-diagnostics-tuple-type-nontype-element-rejected.stdout @@ -0,0 +1 @@ + diff --git a/issues/0067-tuple-literal-type-nontype-fallback.md b/issues/0067-tuple-literal-type-nontype-fallback.md new file mode 100644 index 0000000..66435ba --- /dev/null +++ b/issues/0067-tuple-literal-type-nontype-fallback.md @@ -0,0 +1,92 @@ +# 0067 — tuple literal used as a type silently accepts non-type elements + +> **RESOLVED** (2026-06-02). +> **Root cause:** `type_bridge.resolveTupleLiteralAsType` treated a tuple literal +> as a tuple TYPE and, for any element that wasn't type-shaped, emitted a +> `std.debug.print` and substituted `.s64` for that field — a silent fabricated +> type (the forbidden silent-fallback pattern). The stateful caller +> (`Lowering.resolveTypeArg`, used by `size_of`) delegated `.tuple_literal` +> straight to that path, so `size_of((s32, 1))` compiled and printed `16`. +> **Fix:** +> - `type_bridge.resolveTupleLiteralAsType` now returns `.unresolved` (no `.s64`, +> no debug print) when any element is not type-shaped — it refuses to fabricate +> a tuple. (type_bridge is stateless, so this is the binding-free backstop.) +> - New stateful `Lowering.resolveTupleLiteralTypeArg` validates each element via +> `type_bridge.isTypeShapedAstNode`, emits a user-facing diagnostic at the +> offending element's span, and returns `.unresolved`. It is wired into BOTH +> `resolveTypeArg` (size_of/align_of/…) and the `resolveTypeWithBindings` +> name-fallback; type_bridge builds the tuple only after validation passes. +> **Regression test:** `examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx` +> (exit 1 + diagnostic). Valid `(s32, s32)` still works +> (`examples/0115-types-compound-type-in-expression.sx`). Suite 351/0. + +## Symptom + +`size_of((s32, 1))` treats the tuple literal as a tuple TYPE even though `1` is +not a type. The compiler prints an internal `type_bridge` debug line, then +silently substitutes `.s64` for that slot and compiles successfully. + +Observed: + +```text +type_bridge: tuple literal element is not a type (tag=int_literal) — cannot use as tuple type +bad tuple type size = 16 +``` + +Expected: a user-facing compiler diagnostic rejecting the non-type tuple element, +with no fabricated tuple type and no successful run. + +## Reproduction + +```sx +#import "modules/std.sx"; + +main :: () -> s32 { + print("bad tuple type size = {}\n", size_of((s32, 1))); + 0 +} +``` + +Run: + +```sh +./zig-out/bin/sx run .sx-tmp/probe-tuple-literal-type-fallback.sx +``` + +The repro is standalone; the inline source above is sufficient to recreate the +scratch file under `.sx-tmp/`. + +## Investigation prompt + +Fix issue 0067: tuple literals reinterpreted as tuple types must reject non-type +elements instead of silently fabricating `.s64` fields. + +Suspected area: +- `src/ir/type_bridge.zig`, `resolveTupleLiteralAsType` +- The current non-type branch does `std.debug.print(...)` and + `field_ids.append(alloc, .s64)`, which violates the compiler fallback rules. +- Related callers: `type_bridge.resolveAstType` for `.tuple_literal`, and + `Lowering.resolveTypeWithBindings` fallback paths that reach `type_bridge`. + +Likely fix: +- Replace the `.s64` substitution with a real diagnostic path and an + unmistakable failure result (`.unresolved`, or a nullable/result return that + forces callers to handle the failure). +- Make the diagnostic user-facing via the lowering diagnostics plumbing, not + `std.debug.print`. +- Preserve the valid behavior pinned by `examples/0115-types-compound-type-in-expression.sx`, + where `(s32, s32)` in a type-demanding site resolves as a tuple type. + +Verification: +- Add a focused diagnostics example in the `11xx` block for + `size_of((s32, 1))` expecting exit 1 and a clear diagnostic. +- Run: + +```sh +zig build +zig build test +bash tests/run_examples.sh +``` + +Expected result: the new invalid tuple-type repro fails with a diagnostic, the +valid `0115` tuple-type example still passes, and the full suite remains green. diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 26f48d4..4e6fd06 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -11586,6 +11586,25 @@ pub const Lowering = struct { } } + /// Resolve a tuple LITERAL used in a type position (`(s32, s32)` reinterpreted + /// as a tuple type at a type-demanding site such as `size_of`). Every element + /// must itself denote a type; a non-type element — e.g. the `1` in + /// `(s32, 1)` — is a user error. Emit a diagnostic pointing at the offending + /// element and return `.unresolved`; never fabricate a tuple with a bogus + /// field (issue 0067). type_bridge.resolveAstType builds the tuple only after + /// this validation passes. + fn resolveTupleLiteralTypeArg(self: *Lowering, node: *const Node) TypeId { + for (node.data.tuple_literal.elements) |el| { + if (!type_bridge.isTypeShapedAstNode(el.value, &self.module.types)) { + if (self.diagnostics) |diags| { + diags.addFmt(.err, el.value.span, "tuple type element is not a type (found `{s}`); a tuple used as a type must list only types, e.g. `(s32, s32)`", .{@tagName(el.value.data)}); + } + return .unresolved; + } + } + return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + } + fn resolveTypeArg(self: *Lowering, node: *const Node) TypeId { // Pack-index access in a type-arg slot (e.g. `type_name($args[0])` // or `type_eq($args[i], s64)`). Same shape as the @@ -11662,13 +11681,13 @@ pub const Lowering = struct { // Handle type constructor calls: size_of(Sx(f32)), size_of(Complex(u32)) return self.resolveTypeCallWithBindings(&cl); }, + .tuple_literal => return self.resolveTupleLiteralTypeArg(node), .pointer_type_expr, .many_pointer_type_expr, .array_type_expr, .slice_type_expr, .optional_type_expr, .function_type_expr, - .tuple_literal, => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map), else => return .unresolved, } @@ -12854,6 +12873,10 @@ pub const Lowering = struct { switch (node.data) { .type_expr => |te| return self.typeResolver().resolveName(te.name), .identifier => |id| return self.typeResolver().resolveName(id.name), + // A non-spread tuple literal in a type position is a tuple-type + // literal (`(s32, s32)`); validate its elements are types and reject + // non-type elements loudly (issue 0067). + .tuple_literal => return self.resolveTupleLiteralTypeArg(node), else => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map), } } diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 48d1771..c5662c5 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -276,20 +276,23 @@ fn resolveTupleSpreadShape(tt: *const ast.TupleTypeExpr, table: *TypeTable, alia } // Treat a tuple value literal as the corresponding tuple TYPE — valid only when -// every element is itself a type expression. Non-type elements report a clear -// diagnostic and degrade to .s64 for that slot (which the snapshot will catch). +// every element is itself a type expression. A non-type element (e.g. the `1` +// in `(s32, 1)`) means this literal is NOT a type: refuse to fabricate a tuple +// and return the `.unresolved` sentinel (never `.s64`, which would silently lie +// about the size — issue 0067). type_bridge is stateless and has no diagnostics; +// the user-facing diagnostic is emitted by the stateful caller +// (`Lowering.resolveTupleLiteralTypeArg`), which validates before delegating +// here, so the valid path below builds the tuple and the invalid path never +// reaches it from lowering. The sentinel is the backstop for any other +// (binding-free) caller. fn resolveTupleLiteralAsType(tl: *const ast.TupleLiteral, table: *TypeTable, alias_map: AliasMap) TypeId { const alloc = table.alloc; var field_ids = std.ArrayList(TypeId).empty; var name_ids_list = std.ArrayList(StringId).empty; var any_named = false; for (tl.elements) |el| { - if (!isTypeShapedAstNode(el.value, table)) { - std.debug.print("type_bridge: tuple literal element is not a type (tag={s}) — cannot use as tuple type\n", .{@tagName(el.value.data)}); - field_ids.append(alloc, .s64) catch unreachable; - } else { - field_ids.append(alloc, resolveAstType(el.value, table, alias_map)) catch unreachable; - } + if (!isTypeShapedAstNode(el.value, table)) return .unresolved; + field_ids.append(alloc, resolveAstType(el.value, table, alias_map)) catch unreachable; if (el.name) |n| { any_named = true; name_ids_list.append(alloc, table.internString(n)) catch unreachable;