From bba843a43abd18199d5bd3b84ed8b898f5ba2b25 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 02:33:03 -0700 Subject: [PATCH] Name a function by its definition, not by the binding `var a = String; a.name` printed "a". Name inference followed any const binding chain and answered the last identifier, but V8's SetFunctionName runs only for an anonymous function or class definition: every other value keeps the name it already has. The resolution now stops at a named definition, another binding or a member read, and follows only an anonymous definition's binding, which is the binding that named the function when it was created. An import alias keeps that name: `const root = (text) => text` exported from a module and imported as `builder` is still "root". Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 42 +++++++++++++++---- .../corpus/js-function-name-inference.js.json | 6 +++ .../main.js.json | 7 ++++ tests/corpus/js-function-name-inference.js | 33 +++++++++++++++ .../js-function-name-module-alias/main.js | 7 ++++ .../js-function-name-module-alias/names.js | 3 ++ .../package.json | 3 ++ .../js-function-name-inference.js.json | 1 + .../main.js.json | 1 + 9 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-inference.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-module-alias/main.js.json create mode 100644 tests/corpus/js-function-name-inference.js create mode 100644 tests/corpus/js-function-name-module-alias/main.js create mode 100644 tests/corpus/js-function-name-module-alias/names.js create mode 100644 tests/corpus/js-function-name-module-alias/package.json create mode 100644 tests/harness/corpus-costs/js-function-name-inference.js.json create mode 100644 tests/harness/corpus-costs/js-function-name-module-alias/main.js.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb1..d70529cab7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2166,6 +2166,19 @@ function builtinMemberValueName( return builtinModuleFnOf(lowerer, module, member)?.valueName ?? null; } +/** Whether `node` is a function, arrow or class DEFINITION that carries no + * name of its own, so the binding or property it initializes names it + * (V8's SetFunctionName). Every other expression evaluates to a value that + * already has whatever name it has. */ +function isAnonymousCallableDefinition(node: ts.Node): boolean { + if (ts.isArrowFunction(node)) return true; + return (ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name === undefined; +} + +/** The name V8 gives a function value read from this expression: its own + * definition, through parenthesized, rebindable and const bindings and + * property reads (`process.cwd`), or the binding an anonymous definition + * initializes. Null when the source does not determine it. */ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { let n: ts.Node = node; while (ts.isParenthesizedExpression(n)) n = n.expression; @@ -2179,20 +2192,31 @@ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null { n = declaration; break; } - if ( - !declaration || - !ts.isVariableDeclaration(declaration) || - !declaration.initializer || - (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 - ) + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) break; + let value: ts.Node = declaration.initializer; + while (ts.isParenthesizedExpression(value)) value = value.expression; + // Name inference (V8's SetFunctionName) covers an ANONYMOUS function or + // class definition and nothing else: the binding names it, so a + // rebindable binding keeps the binding's spelling below. Any other + // initializer is a value that carries its own name — a named + // definition, a builtin function (`var a = String; a.name` is + // "String") or another binding (`var a = b` keeps b's name) — and + // resolution follows it. + if (isAnonymousCallableDefinition(value)) { + // The binding this definition initializes named it when it was + // created, so answer that binding and not the alias the reference + // reached it through: `import b from "./m"` keeps the "root" of + // `const root = (text) => text;` in `m`. + if (ts.isIdentifier(declaration.name)) return declaration.name.text; break; - n = declaration.initializer; - while (ts.isParenthesizedExpression(n)) n = n.expression; + } + n = value; } if (ts.isIdentifier(n)) return n.text; if (ts.isPropertyAccessExpression(n)) return n.name.text; if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) return n.name.text; - if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) { + if (ts.isClassExpression(n) && n.name) return n.name.text; + if (ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isClassExpression(n)) { // An anonymous function takes the name of the binding or property it // initializes, through parentheses (`const f = (function () {})`). let outer: ts.Node = n; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-inference.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-inference.js.json new file mode 100644 index 0000000000..86b98a4fc2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-inference.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-function-name-inference.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-module-alias/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-module-alias/main.js.json new file mode 100644 index 0000000000..c4615fffd9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-function-name-module-alias/main.js.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/js-function-name-module-alias/names.js", + "/tests/corpus/js-function-name-module-alias/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-function-name-inference.js b/tests/corpus/js-function-name-inference.js new file mode 100644 index 0000000000..bf82ae1b6d --- /dev/null +++ b/tests/corpus/js-function-name-inference.js @@ -0,0 +1,33 @@ +// V8 names a function by its definition: an anonymous function or class +// expression takes the name of the binding or property it initializes, and +// every other value keeps the name it already has. A builtin function bound +// to a variable is therefore still `String`, not the variable's name. +var FromVar = String; +console.log(FromVar.name, FromVar.length, FromVar === String); +let FromLet = Number; +console.log(FromLet.name, FromLet === Number); +const FromConst = Boolean; +console.log(FromConst.name); +var Chained = FromVar; +console.log(Chained.name); +let ThroughParens = (Number); +console.log(ThroughParens.name); +console.log(String.name, Number.name, Boolean.name); + +function declared() {} +var Held = declared; +console.log(Held.name); + +// Anonymous definitions still take the binding's name. +var Anonymous = function () {}; +console.log(Anonymous.name); +let Arrow = () => {}; +console.log(Arrow.name); +const Parenthesized = (function () {}); +console.log(Parenthesized.name); +const Class = class {}; +console.log(Class.name); +var Named = function inner() {}; +console.log(Named.name); +const NamedClass = class InnerClass {}; +console.log(NamedClass.name); diff --git a/tests/corpus/js-function-name-module-alias/main.js b/tests/corpus/js-function-name-module-alias/main.js new file mode 100644 index 0000000000..f1b274db63 --- /dev/null +++ b/tests/corpus/js-function-name-module-alias/main.js @@ -0,0 +1,7 @@ +// A function imported under a different name keeps the name of the binding +// that created it: V8's SetFunctionName runs once, at the definition. +import builder from "./names.js"; + +console.log(builder.name); +const alias = builder; +console.log(alias.name); diff --git a/tests/corpus/js-function-name-module-alias/names.js b/tests/corpus/js-function-name-module-alias/names.js new file mode 100644 index 0000000000..842e8c28b0 --- /dev/null +++ b/tests/corpus/js-function-name-module-alias/names.js @@ -0,0 +1,3 @@ +const root = (text) => text; + +export default root; diff --git a/tests/corpus/js-function-name-module-alias/package.json b/tests/corpus/js-function-name-module-alias/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/tests/corpus/js-function-name-module-alias/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/harness/corpus-costs/js-function-name-inference.js.json b/tests/harness/corpus-costs/js-function-name-inference.js.json new file mode 100644 index 0000000000..0477ef4d5e --- /dev/null +++ b/tests/harness/corpus-costs/js-function-name-inference.js.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 3600 } diff --git a/tests/harness/corpus-costs/js-function-name-module-alias/main.js.json b/tests/harness/corpus-costs/js-function-name-module-alias/main.js.json new file mode 100644 index 0000000000..0c31a05f78 --- /dev/null +++ b/tests/harness/corpus-costs/js-function-name-module-alias/main.js.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4000 }