fix(0111): unannotated decl literals no longer adopt the fn return type

lowerVarDecl (unannotated) and lowerDestructureDecl now clear target_type
around the initializer lowering: a declaration without annotation provides
no target, so int/float literals take their spec defaults (s64/f64) instead
of the enclosing function's implicit-return type (x := 0 in a -> s8 fn was
s8; big := 3000000000 in -> s32 silently wrapped to -1294967296).

Regression: examples/0173-types-int-literal-default-s64.sx. The remaining
explicit-annotation wrap (x : s8 = 300) is filed as issue 0112.
This commit is contained in:
agra
2026-06-10 17:21:44 +03:00
parent 2b8041a828
commit e81780e32e
8 changed files with 273 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
// Integer literals default to s64 regardless of context: an unannotated
// `x := <int literal>` local stays s64 even inside a function whose return
// type is a narrower integer (the implicit-return target must not type the
// body's declarations), and a large literal initializer keeps its value.
// Also covers destructure decls (`a, b := ...`), which share the same rule.
// Regression (issue 0111): these locals adopted the enclosing fn's return
// type (s32/s8), silently wrapping `big := 3000000000` to -1294967296.
#import "modules/std.sx";
f :: () -> s32 {
x := 0;
print("f.x: {}\n", type_name(type_of(x)));
0
}
g :: () -> s8 {
x := 0;
print("g.x: {}\n", type_name(type_of(x)));
0
}
big_host :: () -> s32 {
big := 3000000000;
print("big: {} = {}\n", type_name(type_of(big)), big);
0
}
d_host :: () -> s32 {
a, b := (1, 2);
print("a: {} b: {}\n", type_name(type_of(a)), type_name(type_of(b)));
0
}
main :: () {
f();
g();
big_host();
d_host();
x := 0;
print("main.x: {}\n", type_name(type_of(x)));
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,5 @@
f.x: s64
g.x: s64
big: s64 = 3000000000
a: s64 b: s64
main.x: s64

View File

@@ -0,0 +1,139 @@
# RESOLVED — 0111: unannotated int-literal locals adopt the enclosing fn's return type
**Root cause:** `lowerFunctionBodyInto` sets `self.target_type = ret_ty` for the whole
body (for the implicit trailing return), the `.int_literal` arm adopts any integer
`target_type`, and the unannotated paths of `lowerVarDecl` / `lowerDestructureDecl`
lowered their initializer without clearing it — so `x := 0` in a `-> s32`/`-> s8`
function was typed s32/s8 and `big := 3000000000` silently wrapped.
**Fix:** both decl paths now save/clear/restore `target_type` around the initializer
`lowerExpr` (`src/ir/lower/stmt.zig`); a declaration without annotation provides no
target, so literals take their spec defaults (s64/f64). Trailing-expression and
`return` coercion to the return type are untouched.
**Regression test:** `examples/0173-types-int-literal-default-s64.sx` (f.x/g.x/main.x,
destructured a/b all print `s64`; `big` prints `3000000000`).
**Note (not fixed here):** the `.int_literal` arm still wraps a literal that does not
fit an explicitly-annotated integer target (`x : s8 = 300`) with no diagnostic —
filed separately as issue 0112.
---
# 0111 — unannotated int-literal locals adopt the enclosing fn's return type (silent narrowing + wraparound)
**Symptom.** A local declared `x := <int literal>` (no annotation) inside a
function whose return type is an integer type T gets typed as **T**, not the
spec'd default. `f :: () -> s32 { x := 0; ... }` gives `x: s32`;
`g :: () -> s8 { x := 0; ... }` gives `x: s8`. Expected (specs.md §"Integer
literals default to `s64`", lines 240 / 1428): `s64` in all of these — the
declaration has no target type. Inside a `-> void` function the same decl
correctly infers `s64`.
Consequences are silent and severe:
- All arithmetic through such locals wraps at the narrowed width:
`x := 0; x += 3000000000;` inside `main :: () -> s32` prints `-1294967296`.
- A large literal initializer truncates with **no diagnostic**:
`big := 3000000000;` inside a `-> s32` fn binds `big` to s32 = `-1294967296`.
- Blast radius: every `main :: () -> s32 { ... }` in the corpus types every
unannotated int-literal local as s32 — long-running counters/accumulators
in such functions are one overflow away from wrong results. Discovered
because issue 0109's verification loop (`sum` over 1M iterations in a
`-> s32` main) printed the 32-bit-wrapped sum after the segfault was fixed.
## Reproduction
```sx
#import "modules/std.sx";
f :: () -> s32 {
x := 0;
print("f.x: {}\n", type_name(type_of(x)));
0
}
g :: () -> s8 {
x := 0;
print("g.x: {}\n", type_name(type_of(x)));
0
}
big_host :: () -> s32 {
big := 3000000000;
print("big: {} = {}\n", type_name(type_of(big)), big);
0
}
main :: () {
f();
g();
big_host();
x := 0;
print("main.x: {}\n", type_name(type_of(x)));
}
```
- **Observed** (current master): `f.x: s32`, `g.x: s8`,
`big: s32 = -1294967296`, `main.x: s64`.
- **Expected**: `s64` for all four; `big` prints `3000000000`.
Repro co-located: `issues/0111-int-literal-local-adopts-fn-return-type.sx`
(standalone version of the above; unpinned until fixed).
## Root cause (traced)
Three-link chain, all confirmed by reading:
1. `src/ir/lower/decl.zig` ~2149 (`lowerFunctionBodyInto`): before lowering
the body, `self.target_type = ret_ty` is set for the WHOLE body — intended
for the implicit trailing-return coercion — and only restored after the
body finishes.
2. `src/ir/lower/expr.zig` ~1499 (`.int_literal` arm): an int literal adopts
`self.target_type` whenever it `isIntEx` — with no fits-check, so a
too-big literal wraps silently.
3. `src/ir/lower/stmt.zig` ~335-348 (`lowerVarDecl`, unannotated path):
lowers the initializer with whatever `target_type` is in scope and takes
the decl's type from the lowered ref. The annotated path overwrites
`target_type` with the annotation (which is why `y : s64 = 0` is immune);
the unannotated path inherits the function-return context it should never
see.
## Investigation prompt (paste into a fresh session)
> Fix issue 0111: unannotated int-literal locals adopt the enclosing
> function's integer return type instead of the s64 default. Root cause chain
> is in the issue (decl.zig ~2149 body-wide `target_type = ret_ty`;
> expr.zig ~1499 `.int_literal` adopting it; stmt.zig ~335 unannotated
> `lowerVarDecl` not clearing it). Fix shape: in `lowerVarDecl`'s
> unannotated path, save `self.target_type`, set it to `null` around the
> `lowerExpr(val)` of the initializer, restore after — a declaration without
> annotation provides no target. Audit the sibling statement positions that
> also shouldn't inherit the return-type context (e.g. expression statements,
> `lowerMultiAssign` / `lowerDestructureDecl` unannotated paths) and apply the
> same clear where applicable. Do NOT touch the trailing-expression /
> `return` paths — `f :: () -> s32 { 0 }` must keep coercing the tail to the
> return type.
>
> Separately consider (same fix or follow-up per scope): the `.int_literal`
> arm wraps a literal that doesn't fit the adopted integer target with no
> diagnostic — add a fits-check that errors like the float-narrowing rule
> (`floatToIntExact` precedent) instead of silently truncating.
>
> Verify: run `issues/0111-int-literal-local-adopts-fn-return-type.sx` —
> expect `s64` for f.x / g.x / big / main.x and `big = 3000000000`. Then
> `zig build && zig build test && bash tests/run_examples.sh` against
> EXISTING snapshots: any diff means an example was silently relying on
> narrowed locals — review each (the change is user-visible only via
> type_name/overflow behavior). Promote the repro per the resolution flow
> (`examples/01xx-types-...` block) and mark this issue RESOLVED.
>
> Context: this bug BLOCKS the in-flight fix session for issues 0108/0109/
> 0110 (for-loop codegen resource bugs). The 0109 emitter change (entry-block
> alloca hoisting) is already applied in the working tree, builds, and fixes
> both 0109 repros — but its regression example (1M-iteration `sum`
> accumulation in `main :: () -> s32`) cannot produce the documented expected
> output (`sum=499999500000`) until locals stop narrowing to s32, and
> suite/.ir snapshot regen must not run on a compiler with this live
> miscompile. After 0111 lands, resume: finalize 0109 (suite + .ir review +
> regression example + commit), then 0110, then 0108 per their issue files.

View File

@@ -0,0 +1,66 @@
# 0112 — out-of-range int literal silently wraps into a narrower annotated target
**Symptom.** An integer literal that does not fit its explicitly-annotated
integer target truncates with no diagnostic: `x : s8 = 300;` binds 44,
`y : u8 = 256;` binds 0. Expected: a compile-time error (the value is known
exactly at compile time; this is the integer analogue of the float→int
narrowing rule, which errors on non-exact `y : s64 = 1.5`).
Split from issue 0111 (whose fix removed the *implicit* narrowing — an
unannotated `x := 0` no longer adopts the fn return type — but the explicit
annotation path keeps wrapping).
## Reproduction
```sx
#import "modules/std.sx";
main :: () {
x : s8 = 300;
print("x: {}\n", x);
y : u8 = 256;
print("y: {}\n", y);
}
```
- **Observed** (current master): prints `x: 44` / `y: 0`, exit 0, no
diagnostic.
- **Expected**: compile error per literal, e.g.
`integer literal 300 does not fit in s8 (range -128..127)`, and the analog
for `256` / `u8 (range 0..255)`.
Repro co-located: `issues/0112-int-literal-out-of-range-silent-wrap.sx`
(unpinned until fixed).
## Root cause (suspected area)
`src/ir/lower/expr.zig` `.int_literal` arm (~1499): when `target_type` is an
integer type, it emits `constInt(lit.value, tt)` with no fits-check — the
value truncates at LLVM emission width. The annotated-decl path
(`lowerVarDecl` with `type_annotation`, `src/ir/lower/stmt.zig` ~255) sets
`target_type` to the annotation before lowering the initializer, so every
annotated narrow decl funnels through this arm. Assignments to narrow
lvalues (`b = 300` where `b: s8`) reach the same arm via `lowerAssignment`'s
LHS-derived target and likely need the same check.
## Investigation prompt (paste into a fresh session)
> Fix issue 0112: an int literal that does not fit its integer target type
> silently wraps. In the `.int_literal` arm of `lowerExpr`
> (`src/ir/lower/expr.zig` ~1499), before adopting an integer `target_type`,
> range-check `lit.value` against the target's signedness/width (the type
> table knows both; mirror the bounds logic used by
> `TypeResolver.integerLimitFor`). On overflow emit a diagnostic via
> `self.diagnostics.addFmt(.err, node.span, ...)` naming the literal, the
> type, and its range — do NOT silently fall back to s64 (REJECTED PATTERNS:
> no silent fallback defaults); still return a `constInt` of the target type
> so lowering continues to surface further errors. Audit sibling literal
> sinks that bypass this arm (comptime folds, `lowerStructConstant`, global
> initializers) for the same check.
>
> Verify: `issues/0112-int-literal-out-of-range-silent-wrap.sx` errors with
> two diagnostics (s8/300, u8/256); boundary values still compile
> (`x : s8 = -128` / `127`, `y : u8 = 0` / `255`, `m : u64` large literals).
> `zig build && zig build test && bash tests/run_examples.sh` — any example
> that relied on silent wrapping must be reviewed individually. Promote the
> repro per the resolution flow (likely `examples/11xx-diagnostics-...`).

View File

@@ -0,0 +1,8 @@
#import "modules/std.sx";
main :: () {
x : s8 = 300;
print("x: {}\n", x);
y : u8 = 256;
print("y: {}\n", y);
}

View File

@@ -337,9 +337,15 @@ pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
// This is critical for generic calls where the return type is only
// known after monomorphization.
const saved_fbv = self.force_block_value;
const saved_target = self.target_type;
self.force_block_value = true;
// An unannotated decl provides no target type: clear the ambient one
// (the enclosing fn's implicit-return target) so literal initializers
// take their spec defaults (s64/f64) instead of adopting it.
self.target_type = null;
const ref = self.lowerExpr(val);
self.force_block_value = saved_fbv;
self.target_type = saved_target;
const ty = self.builder.getRefType(ref);
const slot = self.builder.alloca(ty);
self.builder.store(slot, ref);
@@ -1189,9 +1195,14 @@ pub fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
pub fn lowerDestructureDecl(self: *Lowering, dd: *const ast.DestructureDecl) void {
// Lower the RHS expression (must produce a tuple)
const saved_fbv = self.force_block_value;
const saved_target = self.target_type;
self.force_block_value = true;
// Same as the unannotated var-decl path: the destructure declares new
// bindings, so the ambient target type must not type the RHS literals.
self.target_type = null;
const ref = self.lowerExpr(dd.value);
self.force_block_value = saved_fbv;
self.target_type = saved_target;
const ty = self.builder.getRefType(ref);
// Get tuple field info