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

66 lines
3.0 KiB
Markdown

# 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
```sx
#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.