fix: gate implicit optional unwrap on flow narrowing (issue 0179)

Optional (?T) operands were implicitly unwrapped without proof of
presence, silently miscompiling a NULL ?T to garbage. Unwraps in
binary ops and other expression positions are now gated on flow
narrowing: a ?T value is only auto-unwrapped where control flow has
established it is non-null (the narrowed_refs set). Outside a narrowed
region, an implicit unwrap is rejected rather than producing garbage.

Touches the lowering pipeline (lower.zig + lower/{call,closure,coerce,
comptime,control_flow,expr,ffi,generic,pack,stmt}.zig). Adds optionals
examples 0919-0923 and closures example 0312 covering flow narrowing,
binop narrowing, no-implicit-unwrap rejection, and no closure leak of
narrowed state. Updates specs.md and readme.md.
This commit is contained in:
agra
2026-06-25 13:57:48 +03:00
parent 6c89a0aa3e
commit 468461becc
38 changed files with 576 additions and 3 deletions

View File

@@ -284,6 +284,19 @@ pub const Lowering = struct {
current_runtime_method: ?ast.RuntimeMethodDecl = null, // the specific method whose body is being lowered; `super.<same_name>(...)` 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;

View File

@@ -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| {

View File

@@ -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";

View File

@@ -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 '?? <default>', 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);

View File

@@ -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`

View File

@@ -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 <x == null ...> { <diverges> }` 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) {

View File

@@ -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 '?? <default>', 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[<i>]` 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);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 = "";

View File

@@ -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