From 3640f015126a97f1d60fa9821301b1cd8d90f127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:36:45 +0800 Subject: [PATCH] fix(javascript): emit no promise for a literal non-callable executor (#8264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(javascript): emit no promise for a literal non-callable executor Node's `Promise` constructor calls the executor synchronously and throws a `TypeError` when it is not callable, before any promise object exists. The analysis reported a `constructor` promise operation with a hardcoded `sourceResolution: "complete"`, which the graph projects as `creates-promise` `resolved`. Measured on Node 24.22.1: new Promise() -> THREW TypeError new Promise(1) / (-1) -> THREW TypeError new Promise("work") / (`work`) -> THREW TypeError new Promise(true) / (!0) -> THREW TypeError new Promise(null) / (void 1) -> THREW TypeError new Promise(1n) / (/re/) -> THREW TypeError new Promise([]) / ({}) -> THREW TypeError --- accepted --- new Promise(() => {}) -> OK Promise new Promise(async () => {}) -> OK Promise new Promise(function () {}) -> OK Promise new Promise(class {}) -> OK Promise A class passes Node's own `typeof` check, so it keeps its operation. An identifier, a call result or a spread may still supply a function and also keeps its operation. The filter gates the candidate push rather than `promiseCandidateDetails`, which also decides whether an `await` becomes an `awaited-expression`: gating there made `await new Promise(1)` emit a spurious task fact, whereas gating at the push site makes it emit nothing, which is the honest answer. * chore: retrigger title and readiness checks after the title was corrected * fix(javascript): reject a non-finite executor, not only a literal greptile caught that `new Promise(1e309)` still emitted a promise. Babel stores that literal as `Infinity`, and `readExactJavaScriptLiteral` refuses to price a non-finite number, so the guard missed it. Measured on Node 24.22.1 — every one throws before a promise exists: new Promise(1e309) -> THREW TypeError new Promise(1e400) -> THREW TypeError new Promise(Infinity) -> THREW TypeError new Promise(-Infinity) -> THREW TypeError new Promise(NaN) -> THREW TypeError A numeric literal is now read directly rather than through the exact-value reader. `Infinity` and `NaN` are identifiers rather than literals, so they are decided through `isUnmodifiedGlobalReference`; a shadowed binding keeps its promise, which is the same scoping rule the timer fix uses. --- ...Analysis.rejected_promise_executor.test.ts | 90 +++++++++++++++++++ .../javascript/javascriptSemanticPromises.ts | 47 +++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts new file mode 100644 index 0000000000..df055f1261 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts @@ -0,0 +1,90 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Node's `Promise` constructor calls the executor synchronously and throws a + * `TypeError` when it is not callable — before any promise object exists. A + * provable literal that cannot be a function, or a missing argument, is + * therefore not a promise construction. + * + * Measured on Node 24.22.1: + * + * new Promise() / (1) / ("work") / (true) / (null) / (void 0) -> THREW TypeError + * new Promise(1n) / (/re/) / ([]) / ({}) -> THREW TypeError + * new Promise(() => {}) / (function () {}) / (class {}) -> OK Promise + */ +const promisesFor = (source: string) => + analyzeJavaScriptSemantics(source).promiseOperations.map( + ({ kind, method, sourceResolution }) => ({ + kind, + method, + sourceResolution, + }), + ); + +it.each([ + ["no argument", `new Promise()`], + ["a numeric executor", `new Promise(1)`], + ["a negative numeric executor", `new Promise(-1)`], + ["a string executor", `new Promise("work")`], + ["a template-literal executor", "new Promise(`work`)"], + ["a boolean executor", `new Promise(true)`], + ["a negated boolean executor", `new Promise(!0)`], + ["a null executor", `new Promise(null)`], + ["a void executor", `new Promise(void 1)`], + ["a BigInt executor", `new Promise(1n)`], + ["a RegExp executor", `new Promise(/re/)`], + // Babel stores an overflowing numeric literal as `Infinity`, which is not + // callable either. + ["an overflowing numeric executor", `new Promise(1e309)`], + ["an Infinity executor", `new Promise(Infinity)`], + ["a NaN executor", `new Promise(NaN)`], + ["an array executor", `new Promise([])`], + ["an object executor", `new Promise({})`], +] as const)("emits no promise for %s", (_name, source) => { + expect(promisesFor(source + ";")).toEqual([]); +}); + +it.each([ + ["an arrow executor", `new Promise(() => {})`], + ["an async arrow executor", `new Promise(async () => {})`], + ["a function expression executor", `new Promise(function () {})`], + ["a class expression executor", `new Promise(class {})`], +] as const)("keeps the promise for %s", (_name, source) => { + expect(promisesFor(source + ";")).toHaveLength(1); +}); + +it("keeps a promise whose executor is an identifier", () => { + expect(promisesFor(`const p = new Promise(run);`)).toEqual([ + { kind: "constructor", method: "new", sourceResolution: "complete" }, + ]); +}); + +it("keeps a promise whose executor is a call result", () => { + expect(promisesFor(`const p = new Promise(makeExecutor());`)).toHaveLength(1); +}); + +it("keeps a promise whose executor is a spread", () => { + expect(promisesFor(`const p = new Promise(...list);`)).toHaveLength(1); +}); + +/** + * Filtering happens at the candidate-push site rather than inside + * `promiseCandidateDetails`, which also decides whether `await` becomes an + * `awaited-expression`. A rejected executor must produce neither fact. + */ +it("emits no awaited-expression task for an awaited rejected executor", () => { + expect(promisesFor(`async function f() { await new Promise(1); }`)).toEqual( + [], + ); +}); + +it("keeps the awaited ownership for a callable executor under await", () => { + const operations = analyzeJavaScriptSemantics( + `async function f() { await new Promise(() => {}); }`, + ).promiseOperations; + expect(operations).toHaveLength(1); + expect(operations[0]?.kind).toBe("constructor"); + expect(operations[0]?.ownership).toBe("awaited"); +}); diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index 250285e458..08515e4e43 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -8,6 +8,7 @@ import type { import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { + readExactJavaScriptLiteral, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -100,6 +101,50 @@ export const collectJavaScriptSemanticPromises = ( }); }; +/** + * Node's `Promise` constructor calls the executor synchronously and throws a + * `TypeError` when it is not callable, before any promise object exists. An + * exactly readable literal that cannot be a function — or a missing argument — + * is therefore not a promise construction, while an identifier, a call result or + * a spread may still supply a function and keeps its operation. + * + * A class expression is callable enough to pass Node's own check, so it is kept. + */ +const rejectsPromiseExecutor = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isNewExpression(node)) return false; + const executor = node.arguments[0]; + if (executor === undefined) return true; + if (t.isSpreadElement(executor)) return false; + const value = unwrapJavaScriptExpression(executor).node; + if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) + return false; + if (t.isClassExpression(value)) return false; + // `NaN` and `Infinity` are unshadowed globals rather than literals, and neither + // is callable. + if ( + t.isIdentifier(value) && + (value.name === "NaN" || value.name === "Infinity") && + isUnmodifiedGlobalReference(value, state, value.name) + ) + return true; + // Every unary operator yields a primitive, and a primitive is never callable. + // A numeric literal is read directly rather than through the exact-value + // reader, which refuses to price a non-finite literal such as `1e309`. + return ( + t.isUnaryExpression(value) || + t.isNumericLiteral(value) || + readExactJavaScriptLiteral(value).found || + t.isTemplateLiteral(value) || + t.isBigIntLiteral(value) || + t.isRegExpLiteral(value) || + t.isArrayExpression(value) || + t.isObjectExpression(value) + ); +}; + const collectCandidates = ( program: t.Program, state: JavaScriptSemanticAnalysisState, @@ -115,7 +160,7 @@ const collectCandidates = ( enter: (node, parent) => { callableStack.enter(node, parent); const details = promiseCandidateDetails(node, state); - if (details !== null) { + if (details !== null && !rejectsPromiseExecutor(node, state)) { output.push({ node, ...details,