From 5f169ed6fe49040fac26cc104c1ce856e3f0df80 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:16:01 -0700 Subject: [PATCH] Complete function-value arguments in optional calls The `f?.()` branch of the optional-chain lowering forwarded a spread argument list through completeFuncValueArgs but mapped a plain argument list itself, so an omitted optional parameter was never padded with the callee's undefined arm. The lib-boundary arity fence then refused the call ("2 arguments where its lowered signature takes 3"), which is what `callbacks?.onRetryFinished?.(false, attempt)` hit: an unguarded call completes its arguments, a guarded one did not. Run both spellings through the same completion. It evaluates inside the guard, so a nullish callee still evaluates no argument, and a typed rest now packs from the guarded call too. Corpus: optional-chained callbacks with a trailing optional parameter and with a typed rest, through a present, absent, and undefined receiver. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f574073e2392ec24cd2cfca61651759d3359e603) --- .../lowering/expressions/optional-chains.ts | 33 +++++------ .../function-value-arity-completion.ts.json | 6 ++ .../corpus/function-value-arity-completion.ts | 57 +++++++++++++++++++ .../function-value-arity-completion.ts.json | 1 + 4 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json create mode 100644 tests/corpus/function-value-arity-completion.ts create mode 100644 tests/harness/corpus-costs/function-value-arity-completion.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 104d85a80..717fd8c66 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -1,5 +1,4 @@ import { isNavigatorGlobal } from "../builtins/navigator.js"; -import { isSafeToDiscard } from "./evaluation-safety.js"; import { completeFuncValueArgs } from "../call-arguments.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; @@ -449,24 +448,20 @@ export function lowerOptionalChain( // directly (no member dispatch exists to re-enter). if (dotNode === expr && ts.isCallExpression(expr)) { if (narrowed.kind !== "func") lowerer.badType(recvNode, lowerer.typeOf(recvNode)); - const params = narrowed.params; - // Forwarded fixed tuples (`f?.(...args)`) complete into the slots. - if (expr.arguments.nodes.some(ts.isSpreadElement)) { - const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc); - const call: IrExpr = { - kind: "callValue", - callee: recvRef, - args: completed, - type: narrowed.ret, - loc, - }; - return lowerer.finishOptionalChain(expr, id, receiver, call, loc); - } - const args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, params[i])); - // Effect-free surplus arguments (a dropped `void` slot) are ignored. - while (narrowed.rest !== true && args.length > params.length && isSafeToDiscard(args.at(-1)!)) - args.pop(); - const body: IrExpr = { kind: "callValue", callee: recvRef, args, type: narrowed.ret, loc }; + // The same completed function-value ABI an unguarded call uses: an + // omitted optional argument still reaches the callee as undefined + // (`cb?.onRetryFinished?.(false, attempt)` must see its third + // parameter), a forwarded tuple completes into the slots, and an + // effect-free surplus argument is ignored. The completion runs INSIDE + // the guard, so a nullish callee still evaluates no argument. + const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc); + const body: IrExpr = { + kind: "callValue", + callee: recvRef, + args: completed, + type: narrowed.ret, + loc, + }; return lowerer.finishOptionalChain(expr, id, receiver, body, loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json new file mode 100644 index 000000000..5f4eb9487 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/function-value-arity-completion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/function-value-arity-completion.ts" + ], + "diags": [] +} diff --git a/tests/corpus/function-value-arity-completion.ts b/tests/corpus/function-value-arity-completion.ts new file mode 100644 index 000000000..b2dddaa06 --- /dev/null +++ b/tests/corpus/function-value-arity-completion.ts @@ -0,0 +1,57 @@ +// Calling a function value short is legal TypeScript whenever the omitted +// suffix is optional: the native callee still receives every parameter, so +// the call completes the missing slots with undefined exactly as the JS call +// would. +// +// The same holds for a call through an optional-chained property +// (`cb?.onRetryFinished?.(false, attempt)`), which evaluates the callee and +// its arguments once, in order. + +interface RetryCallbacks { + onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void; + onAttempt?: (attempt: number, ...notes: string[]) => string; +} + +function finish(callbacks: RetryCallbacks | undefined, ok: boolean, attempt: number): string[] { + const log: string[] = []; + const sink: RetryCallbacks = { + onRetryFinished: (success, n, finalError) => { + log.push(`finished ${success ? "ok" : "no"} ${n} ${finalError === undefined ? "none" : finalError}`); + }, + onAttempt: (n, ...notes) => `${n}:${notes.join("+")}`, + }; + sink.onRetryFinished?.(ok, attempt); + sink.onRetryFinished?.(ok, attempt, "boom"); + callbacks?.onRetryFinished?.(ok, attempt + 1); + callbacks?.onAttempt?.(attempt, "a", "b"); + return log; +} + +console.log(finish(undefined, true, 1).join("; ")); +console.log( + finish( + { + onRetryFinished: (success, attempt, finalError) => { + console.log(`callback ${success} ${attempt} ${finalError ?? "-"}`); + }, + onAttempt: (attempt, ...notes) => { + console.log(`attempt ${attempt} ${notes.length}`); + return "ignored"; + }, + }, + false, + 2, + ).join("; "), +); + +// An omitted trailing argument for a callback whose parameter is optional in +// the middle of a longer signature. +type Reporter = (message: string, code?: number, detail?: string) => string; + +function report(to: Reporter): void { + console.log(to("first")); + console.log(to("second", 42)); + console.log(to("third", undefined, "why")); +} + +report((message, code, detail) => `${message}/${code ?? 0}/${detail ?? "-"}`); diff --git a/tests/harness/corpus-costs/function-value-arity-completion.ts.json b/tests/harness/corpus-costs/function-value-arity-completion.ts.json new file mode 100644 index 000000000..23a63e608 --- /dev/null +++ b/tests/harness/corpus-costs/function-value-arity-completion.ts.json @@ -0,0 +1 @@ +{ "plain": 5094, "san": 6883 }