diff --git a/examples/closures/0312-closure-optional-param-arg-coercion.sx b/examples/closures/0312-closure-optional-param-arg-coercion.sx new file mode 100644 index 00000000..83da0319 --- /dev/null +++ b/examples/closures/0312-closure-optional-param-arg-coercion.sx @@ -0,0 +1,33 @@ +// Calling a closure VALUE whose parameter is `?T` coerces the argument to the +// param type, just like a call to a top-level function does: a concrete arg +// wraps to a present optional, and `null` becomes an absent optional. +// +// Regression (issue 0186): the closure-value call path lowered args without +// coercing to the closure's declared param types, so a concrete `7` arrived as +// a bare payload (read ABSENT) and `null` reached a `{T,i1}` slot as a bare +// pointer (LLVM verifier failure). Fixed by typing args against the closure's +// params (`resolveCallParamTypes`) AND coercing them at the call site +// (`coerceClosureCallArgs`). +#import "modules/std.sx"; + +main :: () { + pick := (p: ?i64) -> i64 => { + if p == null { return -1; } + return p; // narrowed inside the lambda body + }; + print("pick 7: {}\n", pick(7)); // 7 (concrete arg wraps present) + print("pick null: {}\n", pick(null)); // -1 (null arg → absent) + + // also via a closure stored in a struct field + Holder :: struct { f: Closure(?i64) -> i64; } + h := Holder.{ f = pick }; + print("h 5: {}\n", h.f(5)); // 5 + print("h null: {}\n", h.f(null)); // -1 + + // and via a plain function-pointer VALUE (same coercion contract) + fp : (?i64) -> i64 = target; + print("fp 8: {}\n", fp(8)); // 8 + print("fp null: {}\n", fp(null)); // -1 +} + +target :: (p: ?i64) -> i64 { if p == null { return -1; } return p; } diff --git a/examples/closures/expected/0312-closure-optional-param-arg-coercion.exit b/examples/closures/expected/0312-closure-optional-param-arg-coercion.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/closures/expected/0312-closure-optional-param-arg-coercion.exit @@ -0,0 +1 @@ +0 diff --git a/examples/closures/expected/0312-closure-optional-param-arg-coercion.stderr b/examples/closures/expected/0312-closure-optional-param-arg-coercion.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/closures/expected/0312-closure-optional-param-arg-coercion.stderr @@ -0,0 +1 @@ + diff --git a/examples/closures/expected/0312-closure-optional-param-arg-coercion.stdout b/examples/closures/expected/0312-closure-optional-param-arg-coercion.stdout new file mode 100644 index 00000000..c99128a8 --- /dev/null +++ b/examples/closures/expected/0312-closure-optional-param-arg-coercion.stdout @@ -0,0 +1,6 @@ +pick 7: 7 +pick null: -1 +h 5: 5 +h null: -1 +fp 8: 8 +fp null: -1 diff --git a/examples/optionals/0919-optionals-flow-narrowing.sx b/examples/optionals/0919-optionals-flow-narrowing.sx new file mode 100644 index 00000000..b00820b8 --- /dev/null +++ b/examples/optionals/0919-optionals-flow-narrowing.sx @@ -0,0 +1,54 @@ +// Flow-sensitive narrowing: a `?T` proven present by a `!= null` guard +// converts to its concrete payload `T` in a value position (call arg, +// `return`, arithmetic). Covers the if-then form, the divergent `== null` +// guard form, compound `and` / `or`, the `else` arm, and reassignment killing +// narrowing (the value must be re-narrowed before reuse). +// +// Regression (issue 0179): an un-narrowed `?T → concrete` used to unwrap +// unconditionally — yielding the zero payload of a null optional with no +// diagnostic. Narrowing is now the ONLY implicit path; everything else is a +// compile error (see 0920). +#import "modules/std.sx"; + +takes_i32 :: (x: i32) { print("i32 {}\n", x); } +add :: (a: i64, b: i64) -> i64 { return a + b; } + +// Divergent `== null` guard narrows the rest of the body. +guard :: (v: ?i64) -> i64 { + if v == null { return -1; } + return v; // v narrowed to i64 +} + +// Compound `or` guard narrows both names afterward. +guard2 :: (a: ?i64, b: ?i64) -> i64 { + if a == null or b == null { return 0; } + return a + b; // both narrowed +} + +main :: () { + // if-then narrowing + n : ?i64 = 41; + if n != null { takes_i32(n); } // i32 41 + + // else-branch narrowing (false ⇒ present) + m : ?i64 = 7; + if m == null { print("none\n"); } else { takes_i32(m); } // i32 7 + + // compound `and` narrows both inside the then-branch + a : ?i64 = 3; + b : ?i64 = 4; + if a != null and b != null { print("sum {}\n", add(a, b)); } // sum 7 + + print("guard 9: {}\n", guard(9)); // 9 + print("guard null: {}\n", guard(null)); // -1 + print("guard2: {}\n", guard2(5, 6)); // 11 + print("guard2 null: {}\n", guard2(5, null)); // 0 + + // reassignment kills narrowing; re-narrow before reuse + k : ?i64 = 100; + if k != null { + takes_i32(k); // i32 100 + k = 200; // narrowing killed here + if k != null { takes_i32(k); } // i32 200 (re-narrowed) + } +} diff --git a/examples/optionals/0920-optionals-no-implicit-unwrap.sx b/examples/optionals/0920-optionals-no-implicit-unwrap.sx new file mode 100644 index 00000000..47224f54 --- /dev/null +++ b/examples/optionals/0920-optionals-no-implicit-unwrap.sx @@ -0,0 +1,16 @@ +// An un-narrowed `?T` does NOT implicitly unwrap to its concrete payload in a +// value position — it is a compile error pointing at the explicit forms. +// +// Regression (issue 0179): passing a `?i64` where `i32` is expected used to +// compile and unconditionally extract the payload, so a NULL optional yielded +// `0` with no diagnostic (silent miscompile across the whole `?T → concrete` +// family, incl. `?bool → bool`). The legal extractions are `!` / `??` / +// binding / `case` / a `!= null` guard (see 0919); everything else is rejected. +#import "modules/std.sx"; + +takes_i32 :: (x: i32) { print("got {}\n", x); } + +main :: () { + n : ?i64 = null; + takes_i32(n); // error: optional does not implicitly unwrap +} diff --git a/examples/optionals/0921-optionals-binop-narrowing.sx b/examples/optionals/0921-optionals-binop-narrowing.sx new file mode 100644 index 00000000..b76fd29a --- /dev/null +++ b/examples/optionals/0921-optionals-binop-narrowing.sx @@ -0,0 +1,29 @@ +// Flow narrowing extends to binary-op OPERANDS: a `?T` proven present by a +// `!= null` guard reads as its concrete payload in arithmetic / comparison. +// +// Regression (issue 0185): an un-narrowed `?T` operand used to unwrap +// unconditionally, so `null + 10` silently yielded `10` (the zero payload). +// Narrowing is now the only implicit operand unwrap; everything else is a +// compile error (see 0922). +#import "modules/std.sx"; + +sum :: (a: ?i64, b: ?i64) -> i64 { + if a == null or b == null { return -1; } + return a + b; // both operands narrowed +} + +main :: () { + x : ?i64 = 30; + y : ?i64 = 12; + if x != null and y != null { + print("add {}\n", x + y); // 42 + print("lt {}\n", x < y); // false + } + print("sum 11: {}\n", sum(5, 6)); // 11 + print("sum null: {}\n", sum(5, null)); // -1 + + // guard form narrows for the rest of the scope + n : ?i64 = 9; + if n == null { return; } + print("times {}\n", n * 2); // 18 +} diff --git a/examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx b/examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx new file mode 100644 index 00000000..d77c885b --- /dev/null +++ b/examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx @@ -0,0 +1,14 @@ +// An un-narrowed `?T` used directly as a binary-op operand is a compile error +// — it does not implicitly unwrap. +// +// Regression (issue 0185): `null + 10` used to compile and unconditionally +// extract the payload, silently yielding `10` (0 + 10) with no diagnostic. +// The legal forms are `!` / `??` / a `!= null` guard (see 0921). +#import "modules/std.sx"; + +main :: () { + a : ?i64 = null; + b : i64 = 10; + c := a + b; // error: optional operand does not implicitly unwrap + print("c = {}\n", c); +} diff --git a/examples/optionals/0923-optionals-narrowing-no-closure-leak.sx b/examples/optionals/0923-optionals-narrowing-no-closure-leak.sx new file mode 100644 index 00000000..de95e595 --- /dev/null +++ b/examples/optionals/0923-optionals-narrowing-no-closure-leak.sx @@ -0,0 +1,23 @@ +// Flow narrowing (issue 0179) does NOT cross into a nested function body. A +// closure capturing a local that is narrowed in the ENCLOSING scope still sees +// it as `?T` inside the closure — using it unguarded is a compile error. +// +// This guards the soundness fix from the adversarial review of 0179/0185: the +// narrowing gate is keyed by per-function SSA `Ref`, and a closure body's `Ref` +// space overlaps the enclosing function's, so without isolation an outer +// narrowed `Ref` would falsely permit unwrapping a not-proven-present optional +// inside the closure (`Lowering.NarrowGuard`). The closure could run later when +// the captured value is null, so the reject is mandatory — write `n!` to assert. +#import "modules/std.sx"; + +takes_i64 :: (x: i64) { print("{}\n", x); } + +main :: () { + n : ?i64 = 7; + if n != null { + // `n` is narrowed HERE, but the closure body is a separate function: + // `n` is `?i64` inside it, so this implicit unwrap must be rejected. + g := () => { takes_i64(n); }; + g(); + } +} diff --git a/examples/optionals/expected/0919-optionals-flow-narrowing.exit b/examples/optionals/expected/0919-optionals-flow-narrowing.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0919-optionals-flow-narrowing.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0919-optionals-flow-narrowing.stderr b/examples/optionals/expected/0919-optionals-flow-narrowing.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0919-optionals-flow-narrowing.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0919-optionals-flow-narrowing.stdout b/examples/optionals/expected/0919-optionals-flow-narrowing.stdout new file mode 100644 index 00000000..702d14a0 --- /dev/null +++ b/examples/optionals/expected/0919-optionals-flow-narrowing.stdout @@ -0,0 +1,9 @@ +i32 41 +i32 7 +sum 7 +guard 9: 9 +guard null: -1 +guard2: 11 +guard2 null: 0 +i32 100 +i32 200 diff --git a/examples/optionals/expected/0920-optionals-no-implicit-unwrap.exit b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.exit @@ -0,0 +1 @@ +1 diff --git a/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stderr b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stderr new file mode 100644 index 00000000..a18af2f1 --- /dev/null +++ b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stderr @@ -0,0 +1,5 @@ +error: cannot use a value of type '?i64' where 'i32' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? ', bind it (`if v := ...`), or guard with '!= null' + --> examples/optionals/0920-optionals-no-implicit-unwrap.sx:15:5 + | +15 | takes_i32(n); // error: optional does not implicitly unwrap + | ^^^^^^^^^^^^ diff --git a/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stdout b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0920-optionals-no-implicit-unwrap.stdout @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0921-optionals-binop-narrowing.exit b/examples/optionals/expected/0921-optionals-binop-narrowing.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0921-optionals-binop-narrowing.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0921-optionals-binop-narrowing.stderr b/examples/optionals/expected/0921-optionals-binop-narrowing.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0921-optionals-binop-narrowing.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0921-optionals-binop-narrowing.stdout b/examples/optionals/expected/0921-optionals-binop-narrowing.stdout new file mode 100644 index 00000000..08702698 --- /dev/null +++ b/examples/optionals/expected/0921-optionals-binop-narrowing.stdout @@ -0,0 +1,5 @@ +add 42 +lt false +sum 11: 11 +sum null: -1 +times 18 diff --git a/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.exit b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.exit @@ -0,0 +1 @@ +1 diff --git a/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stderr b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stderr new file mode 100644 index 00000000..2ba2d52a --- /dev/null +++ b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stderr @@ -0,0 +1,5 @@ +error: cannot use a value of type '?i64' as an operand: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? ', or guard with '!= null' + --> examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx:12:10 + | +12 | c := a + b; // error: optional operand does not implicitly unwrap + | ^ diff --git a/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stdout b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0922-optionals-binop-no-implicit-unwrap.stdout @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.exit b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.exit @@ -0,0 +1 @@ +1 diff --git a/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stderr b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stderr new file mode 100644 index 00000000..b0e9907e --- /dev/null +++ b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stderr @@ -0,0 +1,5 @@ +error: cannot use a value of type '?i64' where 'i64' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? ', bind it (`if v := ...`), or guard with '!= null' + --> examples/optionals/0923-optionals-narrowing-no-closure-leak.sx:20:22 + | +20 | g := () => { takes_i64(n); }; + | ^^^^^^^^^^^^ diff --git a/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stdout b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0923-optionals-narrowing-no-closure-leak.stdout @@ -0,0 +1 @@ + diff --git a/issues/0179-implicit-optional-unwrap-narrow-silent-miscompile.md b/issues/0179-implicit-optional-unwrap-narrow-silent-miscompile.md index 64c0b390..98afbbe9 100644 --- a/issues/0179-implicit-optional-unwrap-narrow-silent-miscompile.md +++ b/issues/0179-implicit-optional-unwrap-narrow-silent-miscompile.md @@ -1,5 +1,26 @@ # 0179 — implicit `?T → ` unwrap silently miscompiles a NULL optional to garbage (whole family) +> **RESOLVED.** Root cause: `coerceMode`'s `.optional_unwrap` arm +> (`src/ir/lower/coerce.zig`) unwrapped any `?T → concrete` UNCONDITIONALLY, +> never reading the has_value flag — a null optional yielded its zero payload +> with no diagnostic. Fix took path (a) from the investigation prompt: **real +> flow-sensitive narrowing**. `?T → concrete` is now REJECTED at the coercion +> site (loud diagnostic listing `!` / `??` / binding / `!= null`) UNLESS the +> source is a local proven present by flow narrowing. Narrowing is tracked by +> name (`Lowering.narrowed`, region-scoped by `lowerBlock` / the if-then arm / +> a divergent `== null` guard / the `else` arm, killed on reassignment) and +> bridged to `coerceMode` via `narrowed_refs` (the loaded `Ref` of a narrowed +> identifier, tagged in `lowerIdentifier`). Closes the `?bool → bool` hole too +> (issue 0169's carve-out). specs.md §Optional Types + readme updated. +> Regressions: `examples/optionals/0919-optionals-flow-narrowing.sx` (narrowing +> works) + `examples/optionals/0920-optionals-no-implicit-unwrap.sx` (rejection); +> `0900-optionals-optionals.sx` now exercises genuine narrowing. +> +> **Out of scope / follow-up:** the binary-op operand auto-unwrap +> (`src/ir/lower/expr.zig` ~line 3211) is a SEPARATE silent-unwrap path that +> does NOT route through `classify`/`coerceMode` — `a + b` with a null `?T` +> still yields garbage with no diagnostic. Filed separately as issue 0185. + ## Symptom Passing an optional `?T` where a concrete `T`/other builtin is expected (function diff --git a/readme.md b/readme.md index c96bac3b..d6ba8b52 100644 --- a/readme.md +++ b/readme.md @@ -295,8 +295,19 @@ if v := x { // safe unwrap // Optional chaining node: ?Node = get_node(); name := node?.name ?? "unknown"; + +// Flow-sensitive narrowing: a `!= null` guard proves the value present, so it +// reads as its concrete payload in the guarded region. +n: ?i32 = maybe(); +if n != null { take_i32(n); } // `n` is i32 here +if n == null { return; } +take_i32(n); // narrowed for the rest of the scope ``` +A `?T` never *implicitly* unwraps to `T` in a value position — a bare +`take_i32(n)` without a guard, `!`, `??`, or binding is a compile error (it +would otherwise silently yield a null optional's zero payload). + ### Generics ```sx diff --git a/specs.md b/specs.md index 5b8f9c22..347a85ec 100644 --- a/specs.md +++ b/specs.md @@ -1122,6 +1122,14 @@ wrap :: (n: i32) -> ?i32 { } ``` +The reverse is **not** implicit: a `?T` never silently unwraps to its payload +`T` in a value position (call argument, field initializer, `return`, assignment). +Such a use is a compile error — extract the payload explicitly with `!` / `??` / +a binding (`if v := opt`) / a `case` match, or rely on flow-sensitive narrowing +after a `!= null` guard (below). Unwrapping a null optional implicitly would +yield its zero payload with no diagnostic, so the conversion is rejected rather +than allowed. + #### Force Unwrap (`!`) Extracts the payload, traps at runtime if null: ```sx diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 4d4d9043..40c1c731 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -284,6 +284,19 @@ pub const Lowering = struct { current_runtime_method: ?ast.RuntimeMethodDecl = null, // the specific method whose body is being lowered; `super.(...)` reuses its signature type_bindings: ?std.StringHashMap(TypeId) = null, // generic type param bindings ($T → concrete TypeId) current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch) + /// Flow-sensitive narrowing (issue 0179). The set of local variable names + /// currently PROVEN present (`?T` known to carry a value) by a `!= null` + /// guard / branch. Region-scoped: `lowerBlock` snapshots+restores it, the + /// if-then branch narrows on `!= null`, a divergent `== null` guard narrows + /// the rest of the enclosing block, and an assignment kills the name's + /// narrowing. Consulted at the implicit `?T → concrete` unwrap (`coerceMode`): + /// a non-narrowed unwrap is REJECTED instead of silently yielding the zero + /// payload of a null optional. + narrowed: std.StringHashMap(void) = undefined, + /// The SSA `Ref`s produced by lowering a narrowed identifier — the bridge + /// from name-keyed narrowing to the Ref-keyed `coerceMode` unwrap gate. + /// Cleared per function body (the `Ref` space is per-function). + narrowed_refs: std.AutoHashMap(Ref, void) = undefined, force_block_value: bool = false, // set by lowerBlockValue to extract if-else values block_terminated: bool = false, // set when constant-folded if emits a return/br into current block in_lambda_body: bool = false, // true while lowering a closure-literal body; sharpens the `raise`-not-failable diagnostic (ERR E5.1: tell the user to annotate `-> (T, !)`) @@ -474,6 +487,8 @@ pub const Lowering = struct { pack_param_count: ?std.StringHashMap(u32), pack_arg_types: ?std.StringHashMap([]const TypeId), inline_return_target: ?InlineReturnInfo, + narrowed: std.StringHashMap(void), + narrowed_refs: std.AutoHashMap(Ref, void), pub fn enter(l: *Lowering) FnBodyReentry { const g = FnBodyReentry{ @@ -491,7 +506,14 @@ pub const Lowering = struct { .pack_param_count = l.pack_param_count, .pack_arg_types = l.pack_arg_types, .inline_return_target = l.inline_return_target, + // Flow narrowing is lexical to one function body — a nested + // (closure / local-fn) lowering starts with a fresh, empty + // narrowing state and the outer state is restored after. + .narrowed = l.narrowed, + .narrowed_refs = l.narrowed_refs, }; + l.narrowed = std.StringHashMap(void).init(l.alloc); + l.narrowed_refs = std.AutoHashMap(Ref, void).init(l.alloc); // The `#jni_env` Ref stack is lexical to ONE function's instruction // stream; move the visible base to the current top. Pack-fn mono // state is likewise lexical to the pack-fn body — null it so a @@ -523,6 +545,38 @@ pub const Lowering = struct { l.pack_param_count = g.pack_param_count; l.pack_arg_types = g.pack_arg_types; l.inline_return_target = g.inline_return_target; + l.narrowed.deinit(); + l.narrowed = g.narrowed; + l.narrowed_refs.deinit(); + l.narrowed_refs = g.narrowed_refs; + } + }; + + /// Save + clear + restore JUST the flow-narrowing state (issue 0179) around + /// a nested body lowering that does NOT go through `FnBodyReentry` — + /// closure literals, generic/pack/comptime monomorphization. Each lowers a + /// SEPARATE function whose `Ref` space (reset by `beginFunction`) OVERLAPS + /// the outer function's, so the outer `narrowed_refs` indices would falsely + /// match the nested body's `Ref`s and permit an UNSOUND unwrap of a + /// non-present optional. Clearing on entry isolates the nested body (it + /// builds its own narrowing from scratch); restore re-arms the outer. + pub const NarrowGuard = struct { + l: *Lowering, + narrowed: std.StringHashMap(void), + narrowed_refs: std.AutoHashMap(Ref, void), + + pub fn enter(l: *Lowering) NarrowGuard { + const g = NarrowGuard{ .l = l, .narrowed = l.narrowed, .narrowed_refs = l.narrowed_refs }; + l.narrowed = std.StringHashMap(void).init(l.alloc); + l.narrowed_refs = std.AutoHashMap(Ref, void).init(l.alloc); + return g; + } + + pub fn restore(g: NarrowGuard) void { + g.l.narrowed.deinit(); + g.l.narrowed = g.narrowed; + g.l.narrowed_refs.deinit(); + g.l.narrowed_refs = g.narrowed_refs; } }; @@ -548,6 +602,8 @@ pub const Lowering = struct { .struct_const_map = std.StringHashMap(StructConstInfo).init(module.alloc), .extern_name_map = std.StringHashMap([]const u8).init(module.alloc), .comptime_constants = std.StringHashMap(ComptimeValue).init(module.alloc), + .narrowed = std.StringHashMap(void).init(module.alloc), + .narrowed_refs = std.AutoHashMap(Ref, void).init(module.alloc), .xx_reentrancy = std.AutoHashMap(u64, void).init(module.alloc), .inferred_error_sets = std.StringHashMap([]const u32).init(module.alloc), .impl_method_names = std.StringHashMap(void).init(module.alloc), @@ -1685,6 +1741,13 @@ pub const Lowering = struct { // --- moved to lower/control_flow.zig (lower_control_flow) --- pub const lowerIfExpr = lower_control_flow.lowerIfExpr; + pub const narrowableLocal = lower_control_flow.narrowableLocal; + pub const nullCmpName = lower_control_flow.nullCmpName; + pub const collectPresentIfTrue = lower_control_flow.collectPresentIfTrue; + pub const collectPresentIfFalse = lower_control_flow.collectPresentIfFalse; + pub const narrowSnapshot = lower_control_flow.narrowSnapshot; + pub const narrowRestore = lower_control_flow.narrowRestore; + pub const applyNarrowing = lower_control_flow.applyNarrowing; pub const tryConstBoolCondition = lower_control_flow.tryConstBoolCondition; pub const lowerWhile = lower_control_flow.lowerWhile; pub const listView = lower_control_flow.listView; @@ -2008,6 +2071,7 @@ pub const Lowering = struct { pub const lowerTupleLiteral = lower_expr.lowerTupleLiteral; pub const lowerDerefExpr = lower_expr.lowerDerefExpr; pub const lowerForceUnwrap = lower_expr.lowerForceUnwrap; + pub const diagOptionalOperand = lower_expr.diagOptionalOperand; pub const lowerNullCoalesce = lower_expr.lowerNullCoalesce; pub const resolveOptionalInner = lower_expr.resolveOptionalInner; pub const lowerExpr = lower_expr.lowerExpr; diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index eef22ffd..69ef0c7c 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -563,6 +563,9 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { const ty_info = self.module.types.get(binding.ty); if (ty_info == .closure) { const callee_ref = if (binding.is_alloca) self.builder.load(binding.ref, binding.ty) else binding.ref; + // Coerce user args to the closure's param types + // (issue 0186) — a `?T` param must wrap the arg. + coerceClosureCallArgs(self, args.items, ty_info.closure.params); // Closure trampolines carry `__sx_ctx` at // slot 0; emit_llvm's `call_closure` builds // the call as [ctx, env, user_args], so we @@ -656,6 +659,17 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { const bti = self.module.types.get(binding.ty); break :blk if (bti == .function) bti.function.ret else .i64; } else .i64; + // Coerce user args to the fn-pointer's param types (issue + // 0186) — same as the closure-value and global-fn-pointer + // paths. The arg loop already applied implicit address-of + // for `*T` params (resolveCallParamTypes now surfaces the + // `.function` param types), so this completes value + // coercions like a `?T` wrap. Without it a concrete arg to a + // `?T` fn-ptr param reaches `call_indirect` unconverted. + if (!binding.ty.isBuiltin()) { + const bti = self.module.types.get(binding.ty); + if (bti == .function) coerceClosureCallArgs(self, args.items, bti.function.params); + } var final_args = std.ArrayList(Ref).empty; defer final_args.deinit(self.alloc); if (self.fnPtrTypeWantsCtx(binding.ty)) { @@ -965,6 +979,8 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { agg = self.builder.load(obj, oi.pointer.pointee); } const closure_val = self.builder.structGet(agg, @intCast(fi), f.ty); + // Coerce user args to the closure's param types (issue 0186). + coerceClosureCallArgs(self, args.items, fti.closure.params); // Prepend ctx for sx-side closure call ABI. const owned = if (self.implicit_ctx_enabled) blk: { const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable; @@ -1368,6 +1384,8 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { const cti = self.module.types.get(callee_ty); if (cti == .closure) { const callee_ref = self.lowerExpr(c.callee); + // Coerce user args to the closure's param types (issue 0186). + coerceClosureCallArgs(self, args.items, cti.closure.params); // Prepend implicit ctx for the sx-side closure call ABI // (emit_llvm builds the call as [ctx, env, user_args]). const owned = if (self.implicit_ctx_enabled) blk: { @@ -2635,6 +2653,22 @@ pub fn userParamTypes(self: *Lowering, func: *const Function) []TypeId { /// nothing as nominal names in that module: without this call's inferred /// `$T → concrete` bindings the pin would resolve `T` as an undeclared /// type in a non-main module and diagnose it unknown. +/// Coerce already-lowered closure-call arguments to the closure's declared +/// parameter types (issue 0186). The arg-lowering loop only sets `target_type` +/// (which steers literal lowering) but does NOT itself coerce, so a concrete +/// `7` flowing into a `?i64` param would reach `call_closure` as a bare `i64` +/// (read ABSENT by the callee) and a `null` as a bare pointer (LLVM verifier +/// failure). `args` are the USER args (no implicit ctx); `params` the closure's +/// user-visible param types. Coerces in place. +fn coerceClosureCallArgs(self: *Lowering, args: []Ref, params: []const TypeId) void { + const n = @min(args.len, params.len); + for (0..n) |i| { + if (args[i].isNone()) continue; // spread placeholder + const at = self.builder.getRefType(args[i]); + if (at != params[i]) args[i] = self.coerceToType(args[i], at, params[i]); + } +} + fn astCalleeParamTypes(self: *Lowering, fd: *const ast.FnDecl, args: []const *const Node) []const TypeId { const saved_bindings = self.type_bindings; defer self.type_bindings = saved_bindings; @@ -2788,6 +2822,22 @@ pub fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?* } if (c.callee.data != .identifier) return &.{}; const bare_name = c.callee.data.identifier.name; + // Closure / fn-pointer VALUE bound in scope (`g := () => ...; g(args)`): + // type each arg against the callee value's declared parameter types so a + // `?T` param wraps the argument (issue 0186) — without this the args lower + // with no target type and reach `call_closure` unconverted (a concrete arg + // arrives as a bare payload that reads ABSENT; `null` reaches a `{T,i1}` + // slot as a bare pointer → LLVM verifier failure). A local value shadows a + // same-named function, so this precedes the function-name resolution below. + if (self.scope) |scope| { + if (scope.lookup(bare_name)) |binding| { + if (!binding.ty.isBuiltin()) { + const bti = self.module.types.get(binding.ty); + if (bti == .closure) return bti.closure.params; + if (bti == .function) return bti.function.params; + } + } + } const name = blk: { const scoped = if (self.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name; if (self.program_index.ufcs_alias_map.get(bare_name)) |target| { diff --git a/src/ir/lower/closure.zig b/src/ir/lower/closure.zig index cc90b8f4..3a3fbf51 100644 --- a/src/ir/lower/closure.zig +++ b/src/ir/lower/closure.zig @@ -15,6 +15,15 @@ const Lowering = lower.Lowering; const Scope = lower.Scope; pub fn lowerLambda(self: *Lowering, lam: *const ast.Lambda) Ref { + // Flow narrowing (issue 0179) does NOT cross into the lambda body: the + // body is a separate function whose `Ref` space overlaps the enclosing + // function's, so the outer `narrowed_refs` would falsely match body `Ref`s + // (unsound unwrap of a captured-but-not-proven-present optional). The body + // builds its own narrowing from scratch; the outer state is restored on + // return (re-arming narrowing for the rest of the enclosing expression). + var narrow_guard = Lowering.NarrowGuard.enter(self); + defer narrow_guard.restore(); + // Lower the lambda body as a new anonymous function var buf: [64]u8 = undefined; const name = std.fmt.bufPrint(&buf, "__lambda_{d}", .{self.block_counter}) catch "__lambda"; diff --git a/src/ir/lower/coerce.zig b/src/ir/lower/coerce.zig index e1900677..3b713677 100644 --- a/src/ir/lower/coerce.zig +++ b/src/ir/lower/coerce.zig @@ -641,8 +641,22 @@ pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mod } return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty); }, - // Optional → Concrete unwrapping (flow-sensitive narrowing coercion) + // Optional → Concrete unwrapping — ONLY when the value is PROVEN + // present by flow narrowing (issue 0179). An un-narrowed `?T` flowing + // into a concrete slot used to unwrap UNCONDITIONALLY, yielding the + // zero payload of a null optional with no diagnostic (silent + // miscompile across the whole `?T → concrete` family). Per spec the + // only legal extractions are `!` / `??` / binding / match / a `!= null` + // guard; reject everything else loudly. `lowerIdentifier` tags the + // loaded `Ref` of a guard-narrowed local into `narrowed_refs`. .optional_unwrap => { + if (!self.narrowed_refs.contains(val)) { + if (self.diagnostics) |d| { + const cs = self.builder.current_span; + d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot use a value of type '{s}' where '{s}' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? ', bind it (`if v := ...`), or guard with '!= null'", .{ self.formatTypeName(src_ty), self.formatTypeName(dst_ty) }); + } + return val; // hasErrors() aborts before codegen + } const child_ty = self.module.types.get(src_ty).optional.child; const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty); return self.coerceMode(unwrapped, child_ty, dst_ty, mode); diff --git a/src/ir/lower/comptime.zig b/src/ir/lower/comptime.zig index 52043f81..8db1af0b 100644 --- a/src/ir/lower/comptime.zig +++ b/src/ir/lower/comptime.zig @@ -1127,6 +1127,12 @@ pub fn createComptimeFunctionWithPrelude(self: *Lowering, prefix: []const u8, pr const name = std.fmt.bufPrint(&buf, "{s}_{d}", .{ prefix, self.comptime_counter }) catch prefix; self.comptime_counter += 1; + // Flow narrowing (issue 0179) is per-function: this wrapper body has its + // own `Ref` space (overlapping the caller's), so isolate it from the + // caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate. + var narrow_guard = Lowering.NarrowGuard.enter(self); + defer narrow_guard.restore(); + // Save current builder + lowering state. The wrapper fn we're // about to build runs the comptime expression in isolation — // it must NOT inherit the enclosing call's `inline_return_target` diff --git a/src/ir/lower/control_flow.zig b/src/ir/lower/control_flow.zig index a34c71e4..14a8844a 100644 --- a/src/ir/lower/control_flow.zig +++ b/src/ir/lower/control_flow.zig @@ -15,6 +15,93 @@ const Scope = lower.Scope; const ComptimeValue = Lowering.ComptimeValue; const isTypeCategoryMatch = Lowering.isTypeCategoryMatch; +// ── Flow-sensitive narrowing (issue 0179) ─────────────────────── +// +// `?T` only converts to a concrete `T` when the value is PROVEN present — +// otherwise the implicit unwrap silently yields the zero payload of a null +// optional (the bug). These helpers recognize the `!= null` / `== null` +// guard shapes and record which local names a branch / guard proves present; +// `lowerIdentifier` tags the loaded `Ref` of a narrowed name into +// `narrowed_refs`, and `coerceMode`'s `.optional_unwrap` arm only unwraps a +// tagged (proven-present) value. + +/// The local-variable name if `node` is a bare identifier currently bound to +/// an OPTIONAL local/param in scope (the only thing flow-narrowing applies +/// to). Null for field paths, indexes, non-optionals, etc. — those keep the +/// explicit `!`/`??`/binding requirement. +pub fn narrowableLocal(self: *Lowering, node: *const Node) ?[]const u8 { + if (node.data != .identifier) return null; + const name = node.data.identifier.name; + const scope = self.scope orelse return null; + const b = scope.lookup(name) orelse return null; + if (b.ty.isBuiltin()) return null; + if (self.module.types.get(b.ty) != .optional) return null; + return name; +} + +/// If `bop` compares an optional local against the `null` literal (either +/// operand order), the narrowable local's name; else null. +pub fn nullCmpName(self: *Lowering, bop: ast.BinaryOp) ?[]const u8 { + const lhs_null = bop.lhs.data == .null_literal; + const rhs_null = bop.rhs.data == .null_literal; + if (lhs_null == rhs_null) return null; // need exactly one `null` side + return self.narrowableLocal(if (lhs_null) bop.rhs else bop.lhs); +} + +/// Names proven present when `cond` is TRUE: `x != null`, and the `and` of +/// such tests (`a != null and b != null`). +pub fn collectPresentIfTrue(self: *Lowering, cond: *const Node, out: *std.ArrayList([]const u8)) void { + if (cond.data != .binary_op) return; + const bop = cond.data.binary_op; + switch (bop.op) { + .neq => if (self.nullCmpName(bop)) |n| out.append(self.alloc, n) catch {}, + .and_op => { + self.collectPresentIfTrue(bop.lhs, out); + self.collectPresentIfTrue(bop.rhs, out); + }, + else => {}, + } +} + +/// Names proven present when `cond` is FALSE: `x == null` (false ⇒ present), +/// and the `or` of such tests (`a == null or b == null` — false ⇒ both +/// present). This is the guard-narrowing case (`if a == null or b == null +/// { return }` proves both present afterwards). +pub fn collectPresentIfFalse(self: *Lowering, cond: *const Node, out: *std.ArrayList([]const u8)) void { + if (cond.data != .binary_op) return; + const bop = cond.data.binary_op; + switch (bop.op) { + .eq => if (self.nullCmpName(bop)) |n| out.append(self.alloc, n) catch {}, + .or_op => { + self.collectPresentIfFalse(bop.lhs, out); + self.collectPresentIfFalse(bop.rhs, out); + }, + else => {}, + } +} + +/// Snapshot the currently-narrowed names so a region (block / branch) can +/// restore them on exit. Returns a list the caller must `deinit`. +pub fn narrowSnapshot(self: *Lowering) std.ArrayList([]const u8) { + var list = std.ArrayList([]const u8).empty; + var it = self.narrowed.keyIterator(); + while (it.next()) |k| list.append(self.alloc, k.*) catch {}; + return list; +} + +/// Restore the narrowed-name set to a prior snapshot (drops anything added +/// since, re-adds anything killed since). +pub fn narrowRestore(self: *Lowering, saved: *std.ArrayList([]const u8)) void { + self.narrowed.clearRetainingCapacity(); + for (saved.items) |n| self.narrowed.put(n, {}) catch {}; + saved.deinit(self.alloc); +} + +/// Mark every name in `names` as narrowed (proven present) in the current set. +pub fn applyNarrowing(self: *Lowering, names: []const []const u8) void { + for (names) |n| self.narrowed.put(n, {}) catch {}; +} + pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref { // inline if: evaluate condition at compile time, only lower taken branch if (ie.is_comptime) { @@ -147,11 +234,27 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref { scope.put(bind_name, .{ .ref = slot, .ty = inner_ty, .is_alloca = true }); } } + // Flow narrowing (issue 0179): which local names this condition proves + // present in each arm. A binding `if v := opt` already unwraps `v`, so it + // contributes nothing here. + var present_true = std.ArrayList([]const u8).empty; + defer present_true.deinit(self.alloc); + var present_false = std.ArrayList([]const u8).empty; + defer present_false.deinit(self.alloc); + if (ie.binding_name == null) { + self.collectPresentIfTrue(ie.condition, &present_true); + self.collectPresentIfFalse(ie.condition, &present_false); + } + // Set target_type so null/undef in branches get the right type const saved_target = self.target_type; if (is_value and result_type != .void) self.target_type = result_type; + var then_diverged = false; + var then_snap = self.narrowSnapshot(); + self.applyNarrowing(present_true.items); if (is_value) { var v = self.lowerExpr(ie.then_branch); + then_diverged = self.currentBlockHasTerminator(); if (!self.currentBlockHasTerminator()) { const v_ty = self.builder.getRefType(v); if (v_ty != result_type and v_ty != .void and result_type != .void) { @@ -161,14 +264,18 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref { } } else { self.lowerBlock(ie.then_branch); + then_diverged = self.currentBlockHasTerminator(); if (!self.currentBlockHasTerminator()) { self.builder.br(merge_bb, &.{}); } } + self.narrowRestore(&then_snap); // Else branch if (has_else) { self.builder.switchToBlock(else_bb.?); + var else_snap = self.narrowSnapshot(); + self.applyNarrowing(present_false.items); if (is_value) { var v = self.lowerExpr(ie.else_branch.?); if (!self.currentBlockHasTerminator()) { @@ -184,9 +291,15 @@ pub fn lowerIfExpr(self: *Lowering, ie: *const ast.IfExpr) Ref { self.builder.br(merge_bb, &.{}); } } + self.narrowRestore(&else_snap); } self.target_type = saved_target; + // Guard form: `if { }` with no else proves the + // tested names present for the remainder of the enclosing block. The + // enclosing `lowerBlock` snapshot drops this narrowing at block end. + if (!has_else and then_diverged) self.applyNarrowing(present_false.items); + // Continue at merge self.builder.switchToBlock(merge_bb); if (is_value) { diff --git a/src/ir/lower/expr.zig b/src/ir/lower/expr.zig index b4962af1..901dcf9a 100644 --- a/src/ir/lower/expr.zig +++ b/src/ir/lower/expr.zig @@ -2036,6 +2036,15 @@ pub fn lowerDerefExpr(self: *Lowering, de: *const ast.DerefExpr) Ref { return ptr; } +/// Reject using an un-narrowed optional directly as a binary-op operand +/// (issue 0185). Mirrors the `coerceMode` `?T → concrete` rejection (0179): +/// the optional does not implicitly unwrap; steer the user to an explicit form. +pub fn diagOptionalOperand(self: *Lowering, opt_ty: TypeId, span: ast.Span) void { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "cannot use a value of type '{s}' as an operand: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? ', or guard with '!= null'", .{self.formatTypeName(opt_ty)}); + } +} + pub fn lowerForceUnwrap(self: *Lowering, fu: *const ast.ForceUnwrap) Ref { const val = self.lowerExpr(fu.operand); const inner_ty = self.resolveOptionalInner(self.inferExprType(fu.operand)); @@ -2268,9 +2277,17 @@ pub fn lowerExpr(self: *Lowering, node: *const Node) Ref { // `inline for xs (x)` element capture — lower the // synthesized `xs[]` it aliases. if (binding.pack_elem) |elem| break :blk self.lowerExpr(elem); + // Flow narrowing (issue 0179): a name proven present by a + // `!= null` guard tags its loaded value so the implicit + // `?T → concrete` unwrap in `coerceMode` is permitted (an + // un-narrowed unwrap is rejected, not silently zeroed). + const is_narrowed = self.narrowed.count() > 0 and self.narrowed.contains(id.name); if (binding.is_alloca) { - break :blk self.builder.load(binding.ref, binding.ty); + const loaded = self.builder.load(binding.ref, binding.ty); + if (is_narrowed) self.narrowed_refs.put(loaded, {}) catch {}; + break :blk loaded; } + if (is_narrowed) self.narrowed_refs.put(binding.ref, {}) catch {}; break :blk binding.ref; } } @@ -3207,10 +3224,19 @@ pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref { 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 + // Auto-unwrap optional operands for arithmetic/comparison — ONLY when the + // operand is PROVEN present by flow narrowing (issue 0185, the operand-side + // sibling of 0179). An un-narrowed `?T` operand used to unwrap + // UNCONDITIONALLY, so a null operand silently became its zero payload + // (`null + 10` → `10`, no diagnostic). `lowerIdentifier` tags a + // guard-narrowed local's loaded `Ref` into `narrowed_refs`; an un-narrowed + // optional operand is rejected loudly (then still unwrapped so the IR stays + // well-formed — `hasErrors()` aborts before codegen). Presence tests + // (`x == null` / `x != null`) returned early above, so they're unaffected. if (!ty.isBuiltin()) { const info = self.module.types.get(ty); if (info == .optional) { + if (!self.narrowed_refs.contains(lhs)) self.diagOptionalOperand(ty, bop.lhs.span); ty = info.optional.child; lhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = lhs } }, ty); } @@ -3219,6 +3245,7 @@ pub fn lowerBinaryOp(self: *Lowering, bop: *const ast.BinaryOp) Ref { if (!rhs_ty.isBuiltin()) { const rhs_info = self.module.types.get(rhs_ty); if (rhs_info == .optional) { + if (!self.narrowed_refs.contains(rhs)) self.diagOptionalOperand(rhs_ty, bop.rhs.span); rhs = self.builder.emit(.{ .optional_unwrap = .{ .operand = rhs } }, rhs_info.optional.child); } } diff --git a/src/ir/lower/ffi.zig b/src/ir/lower/ffi.zig index 22d9a5ca..50af417a 100644 --- a/src/ir/lower/ffi.zig +++ b/src/ir/lower/ffi.zig @@ -1054,6 +1054,15 @@ pub fn synthesizeJniMainStubs(self: *Lowering) void { } pub fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.RuntimeClassDecl, md: ast.RuntimeMethodDecl) void { + // Flow narrowing (issue 0179) is per-function: each native-method stub body + // gets its own `Ref` space (reset by `beginFunction` below) that OVERLAPS + // both the enclosing pass and a sibling method's stub. Without isolation the + // previous method's `narrowed_refs` indices falsely match this body's `Ref`s + // and permit an unsound unwrap of a non-present optional. Clear on entry, + // restore on exit — same contract as the closure / monomorphization paths. + var narrow_guard = Lowering.NarrowGuard.enter(self); + defer narrow_guard.restore(); + const mangled = jni_descriptor.jniMangleNativeName(self.alloc, fcd.runtime_path, md.name) catch return; const name_id = self.module.types.internString(mangled); diff --git a/src/ir/lower/generic.zig b/src/ir/lower/generic.zig index d4e16d42..68d98290 100644 --- a/src/ir/lower/generic.zig +++ b/src/ir/lower/generic.zig @@ -30,6 +30,12 @@ pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name const owned_name = self.alloc.dupe(u8, mangled_name) catch return; self.lowered_functions.put(owned_name, {}) catch {}; + // Flow narrowing (issue 0179) is per-function: this monomorphized body has + // its own `Ref` space (overlapping the caller's), so isolate it from the + // caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate. + var narrow_guard = Lowering.NarrowGuard.enter(self); + defer narrow_guard.restore(); + // Save builder state const saved_func = self.builder.func; const saved_block = self.builder.current_block; diff --git a/src/ir/lower/pack.zig b/src/ir/lower/pack.zig index be1900b9..e3a6a85a 100644 --- a/src/ir/lower/pack.zig +++ b/src/ir/lower/pack.zig @@ -795,6 +795,12 @@ pub fn monomorphizePackFn( const owned_name = self.alloc.dupe(u8, mangled_name) catch return; self.lowered_functions.put(owned_name, {}) catch {}; + // Flow narrowing (issue 0179) is per-function: this monomorphized pack body + // has its own `Ref` space (overlapping the caller's), so isolate it from the + // caller's `narrowed`/`narrowed_refs` to avoid a false-positive unwrap gate. + var narrow_guard = Lowering.NarrowGuard.enter(self); + defer narrow_guard.restore(); + // Find the pack param's name and position in fd.params, plus its // constraint protocol (`..xs: Box` ⇒ "Box"; comptime `..$args` has none). var pack_name: []const u8 = ""; diff --git a/src/ir/lower/stmt.zig b/src/ir/lower/stmt.zig index 74f2170f..cebf7ff6 100644 --- a/src/ir/lower/stmt.zig +++ b/src/ir/lower/stmt.zig @@ -22,10 +22,14 @@ pub fn lowerBlock(self: *Lowering, node: *const Node) void { const saved_scope = self.scope; self.scope = &block_scope; const saved_defer_len = self.defer_stack.items.len; + // Flow narrowing (issue 0179) is block-scoped: a guard inside this + // block narrows the rest of THIS block, no further. + var narrow_snap = self.narrowSnapshot(); defer { self.emitBlockDefers(saved_defer_len); self.scope = saved_scope; block_scope.deinit(); + self.narrowRestore(&narrow_snap); } for (blk.stmts) |stmt| { if (self.block_terminated) break; @@ -76,10 +80,12 @@ pub fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref { const saved_scope = self.scope; self.scope = &block_scope; const saved_defer_len = self.defer_stack.items.len; + var narrow_snap = self.narrowSnapshot(); defer { self.emitBlockDefers(saved_defer_len); self.scope = saved_scope; block_scope.deinit(); + self.narrowRestore(&narrow_snap); } // A block whose last statement is `;`-terminated (or not an // expression) discards its value: lower every statement as a @@ -801,6 +807,13 @@ fn tryLowerPropertyAssignment(self: *Lowering, asgn: *const ast.Assignment) bool } pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void { + // Reassignment kills flow narrowing (issue 0179 / specs.md §Flow-Sensitive + // Narrowing): a fresh value may be null, so the name is no longer proven + // present. Drop it from the narrowed set before lowering the store. + if (asgn.target.data == .identifier) { + _ = self.narrowed.remove(asgn.target.data.identifier.name); + } + // Writes through a constant are rejected at compile time (issue 0116): // the target chain's root naming a const global (array/struct consts, // #run consts) or a module value const cannot be stored to — for a