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

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