Files
sx/issues/0197-annotated-assignment-type-mismatch-no-check.md
agra b322dcfe61 fix: type-safe stores + Any unbox/eq; finish multi-return deferrals
Type-checking gaps (segfault/corruption → compile errors):

- 0197: reject a store into an annotated slot whose value has no modeled
  coercion AND a different byte width (a 16-byte string into a 4-byte i32
  overran the slot and segfaulted). New checkAssignable / noneReinterpretIsUnsafe
  (coerce.zig, width via the LLVM-accurate typeSizeBytes) wired into every store
  site: var/const-decl, single + multi assignment (identifier/field/index/
  element/deref), named-return defaults. Same-width reinterpretations (*T→[*]T,
  i64→isize, fn-ref) and explicit xx/cast stay allowed; cascades suppressed via
  externalErrorsExist. Examples 1205, 1206.
- 0198: an implicit `Any → T` unbox is now a compile error (it blindly
  reinterpreted the boxed payload — silent garbage for a wrong scalar, a segfault
  for an aggregate). xx and compiler-generated match/pack unboxes are unaffected.
  Example 1207.
- 0199: `Any == <concrete>` (one operand Any) aborted the LLVM verifier — the
  comparison arm now fires when either operand is Any, boxing the concrete side
  first. Example 0654.

Multi-return deferrals (PLAN-MULTIRET #6 + named-order + D3 + generic):

- Reorder named return elements by name instead of requiring slot order; error on
  unknown/duplicate/missing (value-only AND full-failable-tuple forms). Examples
  0210, 0214.
- Reject a bare-paren (A, B) multi-return signature in generic-arg position
  (return-position-only). Example 0215.
- Multi-return closure types / lambda literals work via the reused tuple
  machinery (destructure, single-bind+field, lambda arg). Example 0216.
- Generic multi-return: positional works (0217); 0200: the named-slot
  implicit-return form now works for generic free fns + struct methods —
  monomorphizeFunction now calls bindNamedReturnSlots. Example 0218.

readme.md documents the annotated-store coercion rule; CHECKPOINT-MULTIRET.md
updated. Full corpus green (850/0).
2026-06-27 17:28:27 +03:00

4.5 KiB

RESOLVED (2026-06-27). Root cause: a value whose type has NO modeled coercion to the destination slot (classify == .none) was passed through the coerceMode .no_op, .none => return val arm UNCHANGED — a raw reinterpreting store. When the value's byte width differed from the slot's (a 16-byte string into a 4-byte i32), the store overran the slot and corrupted memory / SIGSEGV'd.

Fix: a shared guard checkAssignable / noneReinterpretIsUnsafe (src/ir/lower/coerce.zig) rejects a .none store ONLY when the byte WIDTHS differ (typeSizeBytes, the LLVM-accurate ABI size — NOT the field-padded sizeOf). A same-width .none is a legitimate bit-compatible reinterpretation (*T → [*]T, i64 → isize, a bare fn-ref into a function slot) and stays allowed; an explicit xx/cast always passes (the escape hatch). Cascades are suppressed via externalErrorsExist() (the guard tallies its own diagnostics, so a pre-lowering error — an unknown annotation type — or a failed initializer doesn't trigger a pile-on, while independent mismatches each still report). Wired into EVERY annotated-slot store site: var-decl, body-local const-decl, scalar reassignment (local + global), struct/tuple field, array/slice/pointer element, pointer deref, multi-assignment targets, and named-return defaults. (destructure-decl infers target types from the RHS, so it has no annotation to mismatch.) Regression tests: examples/diagnostics/1205 (var/const/reassign)

  • examples/diagnostics/1206 (field/element/deref/multi-assign width overrun).

NOTE: a sibling runtime-safety gap surfaced during the fix's adversarial review — unboxing an Any to a mismatched type is unchecked (silent-wrong / segfault). That is a DIFFERENT code path (unbox_any, not the .none passthrough) and is filed separately as issue 0198.

0197 — annotated assignment with an incompatible type is unchecked (segfaults)

Symptom — A variable / constant declared with an explicit type annotation and an initializer of an INCOMPATIBLE type is accepted with no diagnostic; the value is passed through unchanged (a .none coercion plan), bit-mangling the slot and segfaulting at run time.

  • Observed: x : i32 = "hi"; compiles, then crashes (Segmentation fault).
  • Expected: a compile-time diagnostic — cannot initialize 'x' of type 'i32' with a value of type 'string' (or similar), exit code 1, no crash.

This is a GENERAL type-checking gap, not specific to any one feature. It was surfaced while reviewing the multi-return feature (a named-return slot default -> (sum: i32 = "hi", …) hit the same path; that site now has its own guard, but the underlying annotated-assignment hole remains).

Reproduction

#import "modules/std.sx";

main :: () -> i64 {
    x : i32 = "hi";        // string initializer for an i32 slot — no diagnostic
    print("{}\n", x);      // garbage, then SIGSEGV
    return 0;
}

./zig-out/bin/sx run repro.sx → prints garbage then Segmentation fault. ./zig-out/bin/sx ir repro.sx does NOT crash (it lowers fine) — the bad coercion blows up only at run time.

Investigation prompt

The annotated var/const-decl lowering stores the initializer into the slot WITHOUT checking that the initializer's type can actually reach the annotated type. The store goes through coerceToTypecoerceMode (src/ir/lower/coerce.zig:596,606), whose classifier (coercionResolver().classify, src/ir/conversions.zig:54) returns .none for an incompatible pair — and coerceMode's .no_op, .none => return val arm (coerce.zig ~614) then passes the value through unchanged, so a 16-byte string lands in a 4-byte i32 slot (and vice-versa), corrupting memory.

The fix likely belongs at the annotated var-decl / const-decl store sites (src/ir/lower/stmt.zig lowerVarDecl ~line 450, and the const-decl path) and anywhere else a value is stored into an explicitly-annotated slot: when classify(src_ty, dst_ty) == .none and src_ty != dst_ty, emit a diagnostic (self.diagnostics.addFmt(.err, span, "...", ...)) instead of silently coercing. (The multi-return default site already does exactly this — see the coercionResolver().classify(...) == .none guard in bindNamedReturnSlots, src/ir/lower/stmt.zig — that pattern can be lifted to a shared helper and reused at the assignment sites.)

Verification: ./zig-out/bin/sx run repro.sx should print a type-mismatch diagnostic and exit non-zero, NOT segfault. Add a examples/diagnostics/ or examples/types/ negative example once fixed.