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.
This commit is contained in:
Chris Tate
2026-10-25 22:43:19 -05:00
parent 14836e0408
commit 97787329c2
2 changed files with 44 additions and 2 deletions
@@ -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<Atom>): 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];
@@ -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)");
});