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.
5.6 KiB
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 iti64 + stringlowered asadd : i64and reinterpreted the string's bytes — garbage. - ordering
< <= > >=→isOrderingOperand(numeric / enum / pointer / bool / vector). Without iti64 < stringfed mismatched LLVM types toicmpand tripped the verifier. - bitwise / shift
& | ^ << >>→isBitwiseOperand(integer / enum / bool / vector). Without iti64 & stringreinterpreted 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 + cwherea: i64,c: stringcompiles and prints a garbage number (e.g.4346102832—40 + <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/213also contains a separate typo —Closure(..sources.Target)instead of..sources.T..Targetis an unknown projection name and produces "cannot infer type of lambda parameter" errors that mask this bug. Restoring.Texposes 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.
lowerBinaryOpin src/ir/lower.zig (starts ~L2545) computes the result typetypurely from the LHS operand (var ty = lhs_ty;~L2680), then at the finalswitch (bop.op)(~L2735) emits.add => self.builder.add(lhs, rhs, ty)(and.sub/.mul/.div/.mod) with that LHS-derivedty— never verifyingrhs_tyis compatible. Fori64 + string,ty = .i64and thestringrhs Ref is fed to anadd : i64, reinterpreting its bytes as an integer.The fix: before the arithmetic
switcharms, for the arithmetic ops (add/sub/mul/div/mod) check thatlhs_tyandrhs_tyare 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 existingif (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-wrongadd.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 + stringshould also reject (currently untested here).Span: confirm how to get the operator/expression span for the diagnostic —
lowerBinaryOptakesbop: *const ast.BinaryOp; check whetherast.BinaryOpcarries a span or whether the enclosing node's span must be threaded in (the otheraddFmtsites usenode.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→.Tand confirm(a, b, c) => a + b + cwith astringthird source now errors at the+rather than printing garbage. Runbash tests/run_examples.shto confirm no legitimate arithmetic regressed.