Files
sx/issues/0055-binary-arith-no-operand-type-check.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

5.6 KiB
Raw Permalink Blame History

0055 — binary arithmetic accepts mismatched operand types (i64 + string)

FIXED (examples/214-binop-operand-type-check.sx). lowerBinaryOp in src/ir/lower.zig now checks operand-type compatibility for every scalar binary-op group before emitting, via three predicates that pass .unresolved through (so a type we couldn't infer is never falsely diagnosed) but reject a concretely incompatible operand:

  • arithmetic + - * / %isArithOperand (numeric / vector / pointer). Without it i64 + string lowered as add : i64 and reinterpreted the string's bytes — garbage.
  • ordering < <= > >=isOrderingOperand (numeric / enum / pointer / bool / vector). Without it i64 < string fed mismatched LLVM types to icmp and tripped the verifier.
  • bitwise / shift & | ^ << >>isBitwiseOperand (integer / enum / bool / vector). Without it i64 & string reinterpreted the bytes.

On mismatch it emits cannot apply '<op>' to operands of type '<lhs>' and '<rhs>' and returns a placeholder sentinel instead of the corrupting op. The existing optional-unwrap and int×float promotion are applied before the check. Legitimate mixes — flags-enum bitwise (.read | .write, perm & .read), enum/pointer comparison, int literals — are unaffected (covered by examples/50-smoke.sx). Regression test: examples/214 (rejects + * < & << against a string operand).

Still NOT covered (left deliberately): equality == / !=, whose path is heavily special-cased (string str_eq, Any unbox, optional == null). i64 == string still slips through. Folding a compatibility check into that path without regressing the special cases is a separate change — open a fresh issue if it bites.

Symptom

Binary arithmetic operators (+, -, *, /, %) perform no operand-type compatibility check. i64 + string compiles cleanly and runs, reinterpreting the string operand's bytes (pointer/len) as an integer.

  • Observed: a + c where a: i64, c: string compiles and prints a garbage number (e.g. 434610283240 + <string data pointer>).
  • Expected: a type-error diagnostic, e.g. cannot apply '+' to operands of type 'i64' and 'string'.

Surfaced in examples/213-canonical-map.sx: a third source v3: StrCell (VL(string)) was added so the mapper became (a, b, c) => a + b + c with a, b: i64 and c: string. The canonical map infers the closure params from the projected pack element types, so a + b + c is i64 + i64 + string — which should reject. Instead r.get() prints garbage (4312977714).

Note: the working-tree copy of examples/213 also contains a separate typo — Closure(..sources.Target) instead of ..sources.T. .Target is an unknown projection name and produces "cannot infer type of lambda parameter" errors that mask this bug. Restoring .T exposes the real hole. The two are independent; this issue is about the missing arithmetic operand-type check, reproducible standalone with no pack machinery.

Reproduction

Minimal, standalone (only modules/std.sx):

#import "modules/std.sx";

main :: () -> i32 {
    a : i64 = 40;
    c : string = "it should error";
    r := a + c;          // expected: type error (i64 + string)
    print("{}\n", r);    // actual: prints garbage, e.g. 4346102832
    0;
}
$ ./zig-out/bin/sx run repro.sx
4346102832          # should be a compile error, not a number

Investigation prompt

Binary arithmetic in the sx compiler does no operand-type compatibility check. lowerBinaryOp in src/ir/lower.zig (starts ~L2545) computes the result type ty purely from the LHS operand (var ty = lhs_ty; ~L2680), then at the final switch (bop.op) (~L2735) emits .add => self.builder.add(lhs, rhs, ty) (and .sub/.mul/.div/.mod) with that LHS-derived ty — never verifying rhs_ty is compatible. For i64 + string, ty = .i64 and the string rhs Ref is fed to an add : i64, reinterpreting its bytes as an integer.

The fix: before the arithmetic switch arms, for the arithmetic ops (add/sub/mul/div/mod) check that lhs_ty and rhs_ty are arithmetic-compatible (both numeric — int/float — modulo the existing int×float promotion at ~L2682, or otherwise an exact match). When they are not, emit a diagnostic via the existing if (self.diagnostics) |diags| diags.addFmt(.err, <span>, "cannot apply '{s}' to operands of type '{...}' and '{...}'", .{...}) pattern (see the diagnostics calls already in this file, e.g. ~L2175, ~L2193) and return a non-corrupting sentinel rather than a silently-wrong add.

Watch the legitimate non-numeric + paths so the check doesn't false-positive: tuple ops (lowerTupleOp, handled earlier ~L2718), string ==/!= (handled ~L2710), optional auto-unwrap (~L2693), and the int×float promotion (~L2682). Decide whether string concatenation via + is intended to be supported at all — if not, string + string should also reject (currently untested here).

Span: confirm how to get the operator/expression span for the diagnostic — lowerBinaryOp takes bop: *const ast.BinaryOp; check whether ast.BinaryOp carries a span or whether the enclosing node's span must be threaded in (the other addFmt sites use node.span).

Verify: re-run the repro above — expect a type-error diagnostic and a non-zero exit instead of a printed integer. Then restore examples/213's .Target.T and confirm (a, b, c) => a + b + c with a string third source now errors at the + rather than printing garbage. Run bash tests/run_examples.sh to confirm no legitimate arithmetic regressed.