Files
sx/issues/0101-postfix-bang-field-miscompile.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00

3.2 KiB

0101 — postfix ! chained with .field miscompiles

RESOLVED. A postfix optional force-unwrap chained directly with a member access — opt!.field — read garbage instead of the field, while the bind-first form (v := opt!; v.field) was correct. Sibling chains shared the bug: opt!.method() failed to resolve the method at all (error: unresolved '<method>'), and opt!.a.b / opt![i] were affected the same way.

Symptom — observed vs expected:

#import "modules/std.sx";
S :: struct { id: string; n: i64; }
mk :: () -> ?S { return S.{ id = "hello", n = 42 }; }
main :: () {
    print("chained: {}\n", mk()!.id);   // observed: garbage (e.g. 8362783136)
    v := mk()!; print("bind: {}\n", v.id);  // observed: "hello"  (correct)
}

Expected: mk()!.id prints hello (same as the bind-first form).

Root cause (src/ir/expr_typer.zig, ExprTyper.inferType): the AST-level type-inference switch had no .force_unwrap arm, so mk()! typed as .unresolved (the else fallback). lowerForceUnwrap lowers the unwrap to a correctly-typed value, so the bind form works — v := mk()! stores that typed Ref into a slot and v.field reads it back. But the chained form never materializes a slot: lowerFieldAccess re-derives the receiver type via inferExprType(fa.object) (= inferExprType(mk()!)), got .unresolved, and the struct-field lookup on .unresolved failed — mk()!.id was typed .unresolved/i64 and its value emitted as undef (the print monomorphized pack_i64 with i64 undef, surfacing as a stale stack address). The method chain failed for the same reason: receiver typing returned .unresolved, so method resolution found nothing.

Fix (src/ir/expr_typer.zig:92): add a .force_unwrap arm to ExprTyper.inferType that resolves the operand's optional child type (mirrors lowerForceUnwrap's resolveOptionalInner):

.force_unwrap => |fu| blk: {
    const opt_ty = self.l.inferExprType(fu.operand);
    if (!opt_ty.isBuiltin()) {
        const info = self.l.module.types.get(opt_ty);
        if (info == .optional) break :blk info.optional.child;
    }
    break :blk .unresolved;
},

This is the single root cause for every chained form — field, nested field, method call, and index all route their receiver type through inferExprType. One arm fixes all of them.

Reproduction (standalone, std-only):

#import "modules/std.sx";
Inner :: struct { tag: string; k: i64; }
S :: struct {
    id: string; n: i64; inner: Inner;
    greet :: (self: *S) -> string { return self.id; }
}
mk :: () -> ?S { return S.{ id = "hello", n = 42, inner = Inner.{ tag = "deep", k = 7 } }; }
main :: () {
    print("{}\n", mk()!.id);          // pre-fix: garbage; post-fix: hello
    print("{}\n", mk()!.n);           // pre-fix: garbage; post-fix: 42
    print("{}\n", mk()!.greet());     // pre-fix: error unresolved 'greet'; post-fix: hello
    print("{}\n", mk()!.inner.tag);   // post-fix: deep
}

Regression: examples/0905-optionals-unwrap-field-chain.sx exercises opt!.field (string + int field, chained vs bind-first), opt!.method() (pointer + value receiver), nested opt!.a.b, and opt![i]. Garbage / compile error on pre-fix code; all correct after.