Files
sx/issues/0123-call-arity-unchecked.md
agra b9cfe2554f refactor(ffi-linkage): Phase 9.3/9.4 — purge 'foreign' from issues/*.md; GATE PASS
Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern,
foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl,
findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→
dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the
renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed
issues/0043-…-foreign-class-…→…-runtime-class-….

PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/
issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant
SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party).
Suite green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:18:35 +03:00

4.9 KiB

0123 — wrong arg counts to fixed-arity fns reach LLVM emission

RESOLVED (2026-06-12). Root cause: no dispatch path in lowerCall ever compared the supplied arg count against the callee's declared params (coerceCallArgs iterates @min(args.len, params.len), so a mismatch sailed through to the LLVM verifier). Fix: a shared checkCallArity (src/ir/lower/call.zig) computes min (params without trailing defaults) / max (params.len, unbounded past a variadic) from the AST decl and rejects with a source-located diagnostic at the five plain dispatch sites — bare selected-author + lazy, namespace alias-gate + qualified, struct-method, ufcs. Pack / comptime / generic / #compiler / #builtin callees are exempt (own dispatch). The method/ufcs sites also gained the appendDefaultArgs fill the generic-instance leg already had — trailing defaults on dot-calls previously emitted under-arity calls (same verifier failure). Flushed out en route: lowerStmt's .fn_decl => |fd| ... (&fd) registered a STACK address in fn_ast_map, so every local fn's map entry aliased the most recently lowered one — pointer capture (|*fd|) fixes it. Regressions: examples/1167-diagnostics-call-arity-mismatch.sx (too many / too few, bare + stdlib + method + ufcs) and examples/0054-basic-dot-call-default-args.sx (dot-call defaults, variadic, #caller_location). Gates: zig build test 426/426, suite 590/590 (fix in isolation), distribution repo 14/14.

Symptom

Calling a fixed-arity function with the wrong number of arguments is not rejected by the frontend — the mismatched argument list flows all the way to LLVM emission, which fails verification instead of a source-located diagnostic.

  • Observed: LLVM verification failed: Incorrect number of arguments passed to called function! plus the raw IR call line — no file/line/snippet, no callee name in user terms.
  • Expected: a compile error at the call site naming the callee and its declared arity (matching the style of existing diagnostics, e.g. the "unresolved ..." errors with source snippets).

Both directions are broken, on every plain dispatch path probed:

  • too MANY args, bare call: concat("a", "b", "c") (std's concat takes 2 strings) → LLVM verifier failure.
  • too FEW args, bare call: add2(1) with add2 :: (a: i64, b: i64) → same.
  • methods / ufcs dot-calls: same shape, receiver included. Worse: a trailing-default param on a plain struct method or a ufcs fn is never filled on the dot-call path (p.scaled() with scaled :: (self: Point, k: i64 = 2) emits a 1-arg call to a 2-param fn — bare calls fill defaults via expandCallDefaults, the method/ufcs sites never run appendDefaultArgs).

Legitimate flexible shapes must keep working: slice variadics (..xs: []T — no upper bound), comptime/protocol packs (..$args / ..xs: P — own dispatch), default-valued params (incl. loc: Source_Location = #caller_location), generic $T fns (explicit vs inferred type args make the count flexible), extern C variadics, #compiler / #builtin bodies.

Reproduction

#import "modules/std.sx";

main :: () -> i32 {
    s := concat("a", "b", "c");   // concat takes (a: string, b: string)
    out(s);
    return 0;
}

Observed at master b3b78e2: compiles past resolution, dies at LLVM verification with the verifier message above.

Investigation prompt

Call argument binding never compares the supplied arg count against the callee's declared parameter list. Suspected area: the plain direct-dispatch sites in src/ir/lower/call.zig (lowerCall) — the bare-identifier selected-author and lazy-lower legs, the namespace-qualified legs, the qualified struct-method leg, and the ufcs leg. All of them run packVariadicCallArgs / coerceCallArgs and emit builder.call without an arity check; coerceCallArgs iterates @min(args.len, params.len) so a mismatch sails through to the emitter.

The fix likely needs a shared checkCallArity(fd, name, supplied, has_receiver, span) helper consulted at each plain dispatch site, computing min (params without trailing defaults) / max (fd.params.len, unbounded when a variadic param exists) from the AST decl and emitting via self.diagnostics.addFmt(.err, span, ...) on violation. Pack (isPackFn), comptime (hasComptimeParams), generic (type_params.len > 0), #compiler, and #builtin callees bind args through their own dispatch and must be exempt. The method/ufcs sites also need appendDefaultArgs (the generic instance-method leg already runs it) so trailing defaults fill before the count is meaningful.

Verification: the repro errors with a source-located diagnostic naming concat and its 2-arg signature; too-few errors likewise; variadic / pack / default / ufcs / generic calls keep compiling (print, format, List.append, #caller_location defaults). zig build && zig build test, bash tests/run_examples.sh all green; pin the repro as a diagnostics example per CLAUDE.md.