Files
sx/issues/0179-implicit-optional-unwrap-narrow-silent-miscompile.md
agra e5b682e622 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).
2026-06-23 02:47:51 +03:00

3.7 KiB

0179 — implicit ?T → <concrete> unwrap silently miscompiles a NULL optional to garbage (whole family)

Symptom

Passing an optional ?T where a concrete T/other builtin is expected (function arg, field initializer, -> T return) — WITHOUT any explicit !/??/binding — compiles silently and unconditionally unwraps the payload. For a PRESENT optional it yields the value; for a NULL optional it yields the zero/garbage payload with NO diagnostic. Silent miscompile across the whole ?T → concrete family. (Issue 0169 fixed only the ?T → bool cell where child != bool; the rest of the family — and the ?bool → bool cell — remain.)

Per specs.md §Optional Types, the ONLY legal ways to extract T from ?T are ! (force unwrap), ?? (coalesce), if v := opt / while-binding, pattern match, and flow-sensitive narrowing after a != null guard. There is NO implicit unwrap-at-a-value-position. The T → ?T direction is the only implicit optional conversion sanctioned.

Reproduction

#import "modules/std.sx";
takes_i32 :: (x: i32) { print("got {}\n", x); }
main :: () {
  n : ?i64 = null;
  takes_i32(n);    // prints "got 0", exit 0 — silent miscompile (no diagnostic)
}

Confirmed silently wrong for the NULL case across: ?i64 → i32, ?i64 → f64, ?f64 → i64, ?i64 → u8, ?i32 → i64 (widen), ?i64 → i64 (same type), and ?bool → bool (yields false). Present optionals unwrap the payload; null optionals yield 0/garbage. (Found during adversarial review of issue 0169.)

Root cause

src/ir/conversions.zig (CoercionResolver.classify, the "Optional → Concrete unwrap" rule, ~line 114): if (child_ty == dst_ty or (dst_ty.isBuiltin() and child_ty.isBuiltin())) classifies any ?builtin → builtin (and ?T → T) as .optional_unwrap. The emitter emitOptionalUnwrap (src/backend/llvm/ops.zig ~2212) does an UNCONDITIONAL ExtractValue of the payload field — it never reads the has_value flag — so a null optional yields its zero payload. (The comptime VM's optHas path traps, so runtime and comptime diverge.)

Investigation prompt — NOTE: this is design-touching, resolve the semantics first

Per spec, implicit ?T → concrete should be REJECTED entirely (require explicit !/??/binding). Broadening issue 0169's optional_to_bool_reject to all ?T → concrete is the mechanical change (in classify), BUT it interacts with flow-sensitive narrowing:

  1. Flow narrowing is not actually implemented as type refinement. The non-binding if x != null { use(x) } spec example (specs.md §Flow-Sensitive Narrowing) currently "works" only because the broken unconditional unwrap fires everywhere, guard or not — there is no real narrowed type. The genuine narrowing mechanism is the BINDING form if v := opt {} (src/ir/lower/control_flow.zig), which emits an explicit optional_unwrap into a fresh inner_ty scope binding and does NOT route through classify. So a broad reject would reject the non-binding if x != null { use(x) } pattern. Decide: (a) implement real flow-sensitive type refinement so x is genuinely T inside the guarded branch (then the broad reject is safe), or (b) require the binding form / ! / ?? and update specs.md §Flow-Sensitive Narrowing accordingly.
  2. Close the ?bool → bool hole — issue 0169's child_ty != .bool carve-out still silently unwraps a null ?bool to false; per spec there's no implicit ?bool → bool either.

Whichever path, the NULL case must never silently yield garbage. Verify the present cases still work via the legal explicit forms; add regressions. This likely warrants its own focused session given the flow-narrowing decision.