Files
sx/issues/0188-closure-value-call-arg-validation-gaps.md
agra 820cd62fa1 docs: file issues 0185-0188
File four issue write-ups discovered alongside the 0179 work:
- 0185: binary-op operand auto-unwrap silently miscompiles a NULL ?T
- 0186: closure VALUE call does not coerce arg to ?T parameter
- 0187: lambda with inferred return type + block body with early returns
  mis-infers its return type
- 0188: closure-VALUE calls skip argument validation (arity + tuple spread)
2026-06-25 13:57:56 +03:00

3.0 KiB

0188 — closure-VALUE calls skip argument validation: no arity check + runtime-tuple spread not expanded

Symptom

Calling a closure VALUE (a :=-bound lambda, struct-field closure, fn-pointer value) does NOT validate arguments the way a top-level function call does. Two distinct gaps, both pre-existing (surfaced during the adversarial review of the issue-0186 fix; 0186 fixed only arg COERCION for correctly-counted args):

  1. No arity check. A closure value called with the WRONG number of args compiles and silently drops/ignores extras (or reads garbage for missing ones), exit 0. A top-level fn call diagnoses arity.
  2. Runtime-tuple spread f(..tuple) is never expanded for a closure value. The spread leaves a Ref.none placeholder (call.zig ~line 404) that the call_closure sites emit as undef, so the call passes garbage.

Reproduction

#import "modules/std.sx";
main :: () {
  // (1) arity: extra arg silently dropped
  one := (a: i64) -> i64 => a;
  print("arity: {}\n", one(1, 2));      // prints 1 (no error; `2` dropped)

  // (2) spread into a closure value → garbage
  add := (a: i64, b: i64) -> i64 => a + b;
  pair := (10, 20);
  print("spread: {}\n", add(..pair));   // prints garbage (e.g. -17590042754976), not 30
}

Expected: (1) an arity diagnostic (as for a top-level fn); (2) add(..pair) expands to add(10, 20)30, OR a clear diagnostic that spread into a closure value is unsupported (never silent garbage).

Root cause (hypothesis)

The closure-value call paths in src/ir/lower/call.zig (the three call_closure emission sites + the local call_indirect fn-pointer path) build the arg list and emit directly without (a) an arity check against closure.params.len / function.params.len, and (b) without running the runtime-slice/tuple spread expansion that the normal call path uses (packVariadicCallArgs / the Ref.none spread placeholder is never resolved for closures). The pack-spread ..xs path (packSpreadRefs) handles comptime packs but not a runtime tuple value spread into a closure.

Investigation prompt

In src/ir/lower/call.zig, for each closure-value / fn-pointer-value call site (grep call_closure and the local call_indirect path ~line 655):

  1. Add an arity check against the callee value's closure.params / function.params length (mirror checkCallArity used for top-level fns), accounting for the implicit __sx_ctx slot.
  2. Either expand a runtime-tuple/slice spread argument into positional args for closure values (as the normal call path does), or emit a located diagnostic that spread into a callable value is unsupported — never emit the Ref.none placeholder as undef.
  3. Regression: extend examples/closures/0312-... or add examples/closures/03xx-closure-value-arity.sx covering both the arity diagnostic and the spread behavior.

Unrelated to the arg-COERCION fix (issue 0186, already landed) — that fix correctly coerces a correctly-COUNTED arg; these gaps are about COUNT and spread expansion.