fix: reject implicit ?T -> bool coercion instead of silent false (issue 0169)

The Optional->Concrete unwrap classify rule treated ?i64 -> bool as
unwrap+narrow (both builtin), silently yielding false for every optional
(present or null). specs.md defines no implicit optional->bool
conversion. Reject it: conversions.zig adds an optional_to_bool_reject
plan (dst == bool, child != bool); coerce.zig emits a located diagnostic
suggesting '!= null'. Covers arg/field-init/return via the shared
coerceMode. The if-opt presence test (issue 0164) is a separate path,
untouched.

Regression: examples/diagnostics/1199-diagnostics-optional-to-bool.sx +
conversions.test.zig unit test. Verified by 3 adversarial reviews, suite
789/0. Filed adjacent issue 0179 (whole implicit ?T->concrete unwrap
family silently miscompiles a null optional; design-touching).
This commit is contained in:
agra
2026-06-23 02:47:51 +03:00
parent 3c738695dc
commit e5b682e622
9 changed files with 143 additions and 0 deletions

View File

@@ -48,6 +48,13 @@ test "conversions: classify covers the built-in coercion ladder" {
try std.testing.expectEqual(Plan.optional_unwrap, cr.classify(opt_i64, .i64));
try std.testing.expectEqual(Plan.void_to_optional, cr.classify(.void, opt_i64));
// `?T → bool` is NOT an unwrap-then-narrow presence test (issue 0169):
// it must reject, never silently produce `false`. But `?bool → bool`
// is a genuine unwrap of a bool payload.
try std.testing.expectEqual(Plan.optional_to_bool_reject, cr.classify(opt_i64, .bool));
const opt_bool = tt.optionalOf(.bool);
try std.testing.expectEqual(Plan.optional_unwrap, cr.classify(opt_bool, .bool));
// Tuple → tuple, same arity.
const t_ss = tt.intern(.{ .tuple = .{ .fields = &[_]TypeId{ .i64, .i64 }, .names = null } });
const t_ii = tt.intern(.{ .tuple = .{ .fields = &[_]TypeId{ .i32, .i32 }, .names = null } });

View File

@@ -34,6 +34,7 @@ pub const CoercionResolver = struct {
closure_to_fn_reject, // closure value → bare fn-ptr (diagnostic, returns operand)
tuple_elementwise, // (A,B) → (C,D), same arity
optional_unwrap, // ?T → concrete (narrowing)
optional_to_bool_reject, // ?T → bool (no presence-test coercion; diagnostic)
void_to_optional, // void (null literal) → ?T
optional_wrap, // concrete → ?T
erase_protocol, // concrete → protocol value
@@ -99,6 +100,17 @@ pub const CoercionResolver = struct {
const src_info = self.l.module.types.get(src_ty);
if (src_info == .optional) {
const child_ty = src_info.optional.child;
// `?T → bool` is NOT a presence test. The unwrap-then-narrow
// ladder below would extract the payload and narrow it to `i1`,
// which silently yields `false` for every optional (issue 0169).
// There is no implicit optional→bool coercion in the language
// (only `T → ?T` wrapping and flow-sensitive narrowing); a bool
// position wants an explicit presence test. Reject loudly unless
// the payload is itself a bool (`?bool → bool` is a genuine
// unwrap of a bool payload, handled by the same arm below).
if (dst_ty == .bool and child_ty != .bool) {
return .optional_to_bool_reject;
}
if (child_ty == dst_ty or (dst_ty.isBuiltin() and child_ty.isBuiltin())) {
return .optional_unwrap;
}

View File

@@ -647,6 +647,16 @@ pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mod
const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
return self.coerceMode(unwrapped, child_ty, dst_ty, mode);
},
// Optional → bool: there is no implicit presence-test coercion. The
// old unwrap-then-narrow ladder silently produced `false` for every
// optional (issue 0169). Reject with a fix-it pointing at `!= null`.
.optional_to_bool_reject => {
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 'bool' is expected; test presence explicitly with '!= null'", .{self.formatTypeName(src_ty)});
}
return val;
},
// string → cstring: ONLY a string LITERAL coerces implicitly — its
// bytes are a terminated constant (Odin's literal blessing). Any
// other string may be an unterminated view, so it must materialize