From 46c9cba03a52b8ec382a686cd70ded6128225db8 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:30 -0700 Subject: [PATCH] fix(javascript): expand an escaped receiver's methods once (#1495) (#1517) Since #1471, escaping an object also escapes every method's results. Each call through the object escapes it as the receiver, and each escape re-expanded all of its methods. N methods returning one shared object, each called once, did N times N work: 2,000 calls took 3.0 s of semantic analysis and 4,000 took 12 s. Expansion below references depends only on syntax, so escapes now reuse it per expression and path. Identifiers that resolve to the same binding and path collapse to one, because the escape walk treats them alike. Every escape site is still recorded. The semantic IR is byte-identical on 50 sources, and the issue's case takes 253 ms at 2,000 and 638 ms at 4,000. Closes #1495 Co-authored-by: Claude Opus 5.5 --- ...tSemanticAnalysis.member_mutations.test.ts | 38 ++++++++ .../javascriptSemanticMemberMutations.ts | 87 ++++++++++++++++--- 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index 3450060cbe..bf7d35c47f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -611,3 +611,41 @@ describe("property mutation collection on minified bundles", () => { }); }, 30000); }); + +describe("results of methods on an escaped receiver", () => { + it("makes every method's distinct result uncertain, not only the called one", () => { + expect( + resultValue( + 'const first = { t: "A" }; const second = { t: "B" }; const kept = { t: "C" }; const box = { a() { return first; }, b() { return second; }, c() { return first; } }; box.a(); return [first.t, second.t, kept.t];', + ), + ).toMatchObject({ + status: "array", + items: [ + { value: { status: "unknown" } }, + { value: { status: "unknown" } }, + { value: { status: "literal", value: "C" } }, + ], + }); + }); + + it("expands a receiver's methods once rather than once per call (#1495)", () => { + const count = 2000; + const methods = Array.from( + { length: count }, + (_, index) => `m${index}() { return shared; },`, + ).join(" "); + const calls = Array.from( + { length: count }, + (_, index) => `box.m${index}().p${index} = ${index};`, + ).join(" "); + const start = performance.now(); + const value = resultValue( + `const shared = { token: "TOKEN" }; const box = { ${methods} }; ${calls} return [shared.token];`, + ); + expect(performance.now() - start).toBeLessThan(2000); + expect(value).toMatchObject({ + status: "array", + items: [{ value: { status: "unknown" } }], + }); + }, 30000); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 4c0dfc91d1..352e1c5d75 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -251,19 +251,9 @@ export const collectSemanticMemberMutations = ( originAt: current.originAt, }); } else { - if ( - t.isArrayExpression(expression) && - ((current.path[0] === "__proto__" && - (effect === "escape" || current.path.length > 1)) || - (current.path[0] === "constructor" && - current.path[1] === "prototype")) - ) - arrayIterationUnknown = true; - for (const value of referencedValues( - expression, - current.path, - effect, - )) { + for (const value of effect === "escape" + ? escapedReferenceLeaves(expression, current.path) + : expandedValues(expression, current.path, effect)) { if (value.iterableFallbackPath !== undefined) deferIterable({ node: value.node, @@ -290,6 +280,77 @@ export const collectSemanticMemberMutations = ( mutation: t.Node, path: PropertyPath = [], ): void => markValue(node, path, new Set(), "escape", mutation); + function expandedValues( + expression: t.Node, + path: PropertyPath, + effect: ValueEffect, + ): readonly ReferencedValue[] { + if ( + t.isArrayExpression(expression) && + ((path[0] === "__proto__" && (effect === "escape" || path.length > 1)) || + (path[0] === "constructor" && path[1] === "prototype")) + ) + arrayIterationUnknown = true; + return referencedValues(expression, path, effect); + } + // Expanding a value below its references depends only on syntax, not on the + // escape site. Escaping an object reaches every method's results, so + // re-expanding it at each call through that object made N calls over N + // methods quadratic. Identifiers that resolve to the same binding and path + // are processed identically, so one stands for all of them. + const escapedLeaves = new WeakMap< + t.Node, + Map + >(); + function escapedReferenceLeaves( + root: t.Node, + rootPath: PropertyPath, + ): readonly ReferencedValue[] { + // Parser recovery can leave an argument hole, which expands to nothing. + if (!t.isNode(root)) return []; + const rootKey = JSON.stringify(rootPath); + const cached = escapedLeaves.get(root)?.get(rootKey); + if (cached !== undefined) return cached; + const leaves: ReferencedValue[] = []; + const leafIdentities = new Set(); + const expanded = new WeakMap>(); + const pending = [...expandedValues(root, rootPath, "escape")]; + for (let cursor = 0; cursor < pending.length; cursor++) { + const value = pending[cursor]; + if (value === undefined) break; + const node = unwrapJavaScriptExpression(value.node).node; + if (!t.isNode(node)) continue; + const pathKey = JSON.stringify(value.path); + if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) { + const binding = resolveSemanticBindingState(state, node, node.name); + const identity = JSON.stringify([ + binding === undefined ? null : binding.bindingId, + binding === undefined ? node.name : null, + pathKey, + ]); + if (leafIdentities.has(identity)) continue; + leafIdentities.add(identity); + leaves.push(value); + } else if ( + value.iterableFallbackPath !== undefined || + t.isIdentifier(node) || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) + leaves.push(value); + else { + const paths = expanded.get(node) ?? new Set(); + if (paths.has(pathKey)) continue; + paths.add(pathKey); + expanded.set(node, paths); + pending.push(...expandedValues(node, value.path, "escape")); + } + } + const byPath = escapedLeaves.get(root) ?? new Map(); + byPath.set(rootKey, leaves); + escapedLeaves.set(root, byPath); + return leaves; + } const markReceiver = (callee: t.Node, mutation: t.Node): void => { const expression = unwrapJavaScriptExpression(callee).node; if (