From 97787329c2c91f246004a8bb83ee08808d09b499 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 22:43:19 -0500 Subject: [PATCH] Dispatch a table exhaustively only from calls that name the function A key parameter took no fallback arm when every call edge of its function passed a string literal key of the table. A call through `call` or `apply` passes the this value first, so `f.call("a", "c")` looked like a call with the key "a", and the dispatch for "c" called the table's last member where Node throws a TypeError. A builtin that calls the function back chooses its own arguments too. Each call now has to name the function itself: its callee is a binding, or a constant object's member, whose values are only that function. --- .../shape-inference/static-objects.ts | 27 ++++++++++++++++++- .../test/static-object-guards.test.ts | 19 ++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index daf862a53c..bf8835b190 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -1648,8 +1648,33 @@ export function planStaticObjectRewrites( if (touched) return false; const edges = analysis.calls.filter((c) => c.callee.node === fn); if (edges.length === 0) return false; + // A call lines its arguments up with the parameters only when its callee + // is the function itself: a binding, or a constant object's member, that + // holds nothing else. `call` and `apply` pass the this value first, and + // a builtin that calls the function back chooses its own arguments. + const isFn = (a: Atom): boolean => a.kind === "function" && a.node === fn; + const onlyFn = (values: ReadonlySet): boolean => + values.size > 0 && [...values].every(isFn); + const callsFn = (callee: ts.Expression): boolean => { + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + if (ts.isIdentifier(callee)) { + const d = declOf(callee); + return d !== undefined && onlyFn(analysis.declValues(d)); + } + if (!ts.isPropertyAccessExpression(callee)) return false; + const op = readOps.get(callee); + if (op === undefined || op.name === null) return false; + const recv = analysis.nodes[op.recv]!.atoms; + if (recv.size === 0) return false; + for (const a of recv) { + if (!constantAtoms.has(a)) return false; + const field = analysis.fields.get(a)?.get(op.name); + if (field === undefined || !onlyFn(analysis.nodes[field]!.atoms)) return false; + } + return true; + }; return edges.every((e) => { - if (!ts.isCallExpression(e.site)) return false; + if (!ts.isCallExpression(e.site) || !callsFn(e.site.expression)) return false; const args = e.site.arguments.nodes; if (args.slice(0, index + 1).some((a) => ts.isSpreadElement(a))) return false; const arg = args[index]; diff --git a/packages/compiler/test/static-object-guards.test.ts b/packages/compiler/test/static-object-guards.test.ts index 2aba8dfd67..e14793c9ad 100644 --- a/packages/compiler/test/static-object-guards.test.ts +++ b/packages/compiler/test/static-object-guards.test.ts @@ -7,7 +7,9 @@ * - a table call whose key parameter the function rebinds (destructuring, * a loop target, another parameter's default or a `var` redeclaration) * keeps reading the table for other keys, though every caller passes one - * of the table's keys. + * of the table's keys; + * - so does one whose function is called through `call` or `apply`, where + * the first argument is the this value and not the key. * The checks read the rewritten package text SCRIPTC_DEBUG_SHAPE_OVERLAY * prints. */ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -59,6 +61,12 @@ t.redeclared = function (op, v) { var op = "c"; return t.ops[op](v); }; +t.viaCall = function (op, v) { + return t.ops[op](v); +}; +t.viaApply = function (op, v) { + return t.ops[op](v); +}; module.exports = { late, quiet, t }; `; @@ -74,6 +82,7 @@ const tryCall = (call) => { console.log(t.plain("a", 1), t.plain("b", 2)); console.log(tryCall(() => t.pattern("a", 1)), tryCall(() => t.loop("b", 2))); console.log(tryCall(() => t.initializer("a", 1)), tryCall(() => t.redeclared("b", 2))); +console.log(tryCall(() => t.viaCall.call("a", "c", 1)), tryCall(() => t.viaApply.apply("b", ["c", 2]))); `; let dir = ""; @@ -146,3 +155,11 @@ test("a table call whose key parameter is rebound keeps reading the table", asyn for (const name of ["t_pattern", "t_loop", "t_initializer", "t_redeclared"]) expect(body(text, name), name).toContain("t.ops[op](v)"); }); + +test("a table call whose function is called through call or apply keeps reading the table", async () => { + // The first argument of those calls, a key of the table, is the this + // value; the key each call passes, "c", is not one. + const text = await rewrittenPackage(); + for (const name of ["t_viaCall", "t_viaApply"]) + expect(body(text, name), name).toContain("t.ops[op](v)"); +});