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,