Accept a wider lambda whose own signature is already its completed ABI

A lambda declaring an optional or defaulted trailing parameter — the
`(a, b, loose?) => …` semver comparator, the sort callback whose surplus
position is a flag — was refused at its birth fence (SC1090) even though
every read of it spells that position as `T | undefined`, the completed
native ABI. The fence compared the slot's mapped type against the
contextual lambda's, which misses two shapes the checker accepts: a
lambda whose own `any` parameter mapType leaves unmapped, and a
*contextually* typed lambda whose OWN signature is wider than the slot
it is written under.

requireExactArityValue now admits a lambda whose own call signature maps
to the slot position for position, and cleanFuncAdaptable accepts a
source with extra trailing parameters when each of them completes with
the undefined value, the same absence a call through the slot passes.

`sort`/`toSorted` separately required a comparator of at most two
parameters; it now adapts the first two and lets the surplus ones
complete as undefined, so the imported three-parameter comparator sorts.

Corpus 8529 pins the surplus-parameter comparators (optional, defaulted,
`any`-typed), the direct calls through them, `toSorted`'s untouched
receiver, and remains byte-exact against Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:10 -07:00
co-authored by Claude Opus 5.5
parent 49b133751c
commit 5afe93093f
6 changed files with 124 additions and 7 deletions
@@ -630,6 +630,41 @@ export function checkDefaultParamBodyType(
}
}
/** Whether a lambda's OWN signature maps to its completed ABI position for
* position — the contract its reads carry (mapType spells `x?: T` as the
* `T | undefined` slot). Two positions can escape the mapped-TYPE
* comparison in requireExactArityValue: a parameter the checker spells
* `any` holds the checked-dynamic value that mapType leaves unmapped when
* its spelling is context-free, and a *contextually* typed lambda's own
* signature need not be the slot's (a comparator with a surplus optional
* parameter under a two-parameter slot). */
function lambdaSpellsCompletedAbi(
lowerer: Lowerer,
lambda: ts.ArrowFunction | ts.FunctionExpression,
funcType: IrType,
): boolean {
if (funcType.kind !== "func" || funcType.rest === true) return false;
const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda));
if (sigs.length !== 1) return false;
const sig = sigs[0]!;
const params = sig.getParameters();
if (params.length !== funcType.params.length) return false;
for (let i = 0; i < params.length; i++) {
const pTs = lowerer.checker.getTypeOfSymbol(params[i]!);
const slot = funcType.params[i]!;
if ((pTs.flags & ts.TypeFlags.Any) !== 0) {
if (slot.kind !== "dyn") return false;
continue;
}
const mappedParam = lowerer.mapTypeOf(pTs);
if (!mappedParam || !typeEquals(mappedParam, slot)) return false;
}
const retTs = lowerer.checker.getReturnTypeOfSignature(sig);
if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn";
const mappedRet = lowerer.mapTypeOf(retTs);
return mappedRet !== null && typeEquals(mappedRet, funcType.ret);
}
/** DECISION (docs/ir.md): function VALUES carry one completed native ABI.
* Optional/default parameters appear as literal `T | undefined` slots;
* typed rest parameters appear as one trailing typed-array slot plus the
@@ -675,6 +710,20 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// A lambda whose OWN signature already spells the completed ABI is a
// value: every read of it types it that way, and its slot converts it
// (the surplus-parameter and undefined-arm adapters). The mapped-type
// comparison above misses two such lambdas — one whose `any` parameter
// mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number`
// beside a two-parameter slot), and one whose signature is wider than
// the contextual slot it is written under.
if (
contextual !== null &&
(ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) &&
lambdaSpellsCompletedAbi(lowerer, contextual, funcType)
) {
return;
}
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver), or a slot whose type maps to a checked-dynamic
// value (a member of a literal lowered with a generic function's type
@@ -114,10 +114,22 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool
return false;
}
/** Whether an omitted parameter position completes with the native
* undefined value: a checked-dynamic slot takes undefined as-is, and an
* undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI
* spelling. */
export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true;
return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0;
}
/** Whether all parameters and the result adapt without a trap-only path.
* Rest signatures are excluded, except a typed-rest source in a slot with
* fixed parameters (the rest packing adapter); extra destination
* parameters are ignored. */
* parameters are ignored. A source with MORE parameters than the slot is
* clean when every extra trailing one is omittable — the adapter passes
* that absent value explicitly (funcCoerceAdapter's omittedParams), the
* same way a call through the slot would. */
export function cleanFuncAdaptable(
lowerer: Lowerer,
src: IrType & { kind: "func" },
@@ -125,8 +137,10 @@ export function cleanFuncAdaptable(
): boolean {
if (restPackingAdapterFits(lowerer, src, dst)) return true;
if (src.rest === true || dst.rest === true) return false;
if (src.params.length > dst.params.length) return false;
for (let i = 0; i < src.params.length; i++) {
for (let i = dst.params.length; i < src.params.length; i++) {
if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
}
for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false;
}
if (dst.ret.kind === "void") return true;
@@ -70,6 +70,7 @@ import {
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
import { omittableTrailingParam } from "../coercions/function-adapters.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
* call of a synthetic module function — one per method + element/result
@@ -2657,11 +2658,15 @@ export function lowerArraySortCall(
let fnArg =
undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT);
// Reusable JS comparators often accept checked values. Adapt the array's
// element ABI just as other array callbacks do before sorting.
// element ABI just as other array callbacks do before sorting. A
// comparator declaring MORE parameters than sort passes (`(a, b, loose?)`,
// the imported semver shape) receives the surplus ones as undefined — the
// completion an ordinary two-argument call performs — so it adapts to the
// (a, b) slot like any other wider signature.
if (
fnArg.type.kind === "func" &&
fnArg.type.params.length <= 2 &&
fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param))
fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) &&
fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param))
) {
// Arithmetic on inferred JS values retains a checked result because
// it can produce BigInt. The sort callback still requires a number.
@@ -2670,7 +2675,7 @@ export function lowerArraySortCall(
? F64
: fnArg.type.ret;
const expected = funcOf(
fnArg.type.params.map(() => elem),
fnArg.type.params.slice(0, 2).map(() => elem),
result,
);
if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8529-sort-comparator-surplus-params.ts"
],
"diags": []
}
@@ -0,0 +1,42 @@
// A comparator may declare more parameters than `sort` passes: the surplus
// positions receive the native undefined value, exactly as in a two-argument
// call. The imported `(a, b, loose) => …` comparators and lambdas with an
// optional or defaulted trailing parameter take this shape, and a lambda
// whose own signature already spells its completed ABI is a value wherever
// its slot converts.
type Comparer = (a: string, b: string, loose?: boolean) => number;
const seen: string[] = [];
const cmp: Comparer = (a, b, loose) => {
seen.push(loose === undefined ? "absent" : String(loose));
return a < b ? -1 : a > b ? 1 : 0;
};
const versions = ["1.9.0", "1.10.0", "1.2.0"];
console.log([...versions].sort(cmp).join(","));
console.log(seen.length > 0 && seen.every((entry) => entry === "absent"));
console.log(versions.join(","));
// `toSorted` adapts the same way and leaves the receiver untouched.
console.log(versions.toSorted(cmp).join(","), versions.join(","));
// Direct calls through the value complete the same parameters.
console.log(cmp("a", "b"), cmp("b", "a"), cmp("a", "a", true));
// An inline comparator with an optional surplus parameter sorts by number.
const nums = [3, 1, 2];
const descending = (a: number, b: number, dir?: boolean): number =>
dir === undefined ? b - a : a - b;
console.log(nums.toSorted(descending).join(","));
console.log([...nums].sort((a, b, dir?: boolean) => (dir ? a - b : b - a)).join(","));
console.log(nums.join(","));
// A defaulted surplus parameter keeps its default when sort omits it.
const withDefault = (a: string, b: string, scale = 1): number =>
(a < b ? -1 : a > b ? 1 : 0) * scale;
console.log(["b", "a", "c"].sort(withDefault).join(""));
console.log(withDefault("a", "b"), withDefault("a", "b", 3));
// A one-parameter comparator is still a prefix of the slot.
console.log(["d", "c", "a"].sort((left) => left.charCodeAt(0) - 97).join(","));
@@ -0,0 +1 @@
{ "plain": 3100, "san": 3700 }