diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index e343367d33..2074f952a4 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -323,6 +323,76 @@ describe("Electron module interop helper results", () => { `); expect(analysis.electron.browser_windows).toHaveLength(3); }); + + it("preserves BrowserWindow findings through lexical aliases of proven interop results", () => { + const analysis = analyzeJavaScriptStaticSource(` + function identity(mod) { return mod; } + const electron = identity(require("electron")); + const api = electron; + new api.BrowserWindow({webPreferences:{preload:"./p.js"}}); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./p.js" }, + ]); + }); + + it("preserves BrowserWindow and IPC findings through aliases of Rolldown namespace conversions", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + const electron = __toESM(require("electron"), 1); + const api = electron; + new api.BrowserWindow({webPreferences:{preload:"./p.js"}}); + api.ipcMain.handle("ping", () => "pong"); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./p.js" }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { channel: "ping", side: "main" }, + ]); + }); +}); + +describe("Electron module interop alias resolution", () => { + it("does not infer Electron identity through an alias when the target binding was modified", () => { + const analysis = analyzeJavaScriptStaticSource(` + ${ROLLDOWN_MODULE_INTEROP} + function identity(mod) { return mod; } + let electron = identity(require("electron")); + eval("electron = { BrowserWindow: class {} }"); + const api = electron; + new api.BrowserWindow({webPreferences:{preload:"./p.js"}}); + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("retains possible member writes through uncertain aliases of interop results", () => { + const analysis = analyzeJavaScriptStaticSource(` + ${ROLLDOWN_MODULE_INTEROP} + function identity(mod) { return mod; } + const original = identity(require("electron")); + let electron = original; + eval("noop()"); + const api = electron; + api.BrowserWindow = Fake; + new original.BrowserWindow({webPreferences:{preload:"./p.js"}}); + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("resolves Electron identity across long alias chains without stack overflow", () => { + const lines = [ + ROLLDOWN_MODULE_INTEROP, + 'const a0 = __toESM(require("electron"), 1);', + ]; + for (let i = 1; i <= 2000; i++) { + lines.push(`const a${i} = a${i - 1};`); + } + lines.push('new a2000.BrowserWindow({webPreferences:{preload:"./p.js"}});'); + const analysis = analyzeJavaScriptStaticSource(lines.join("\n")); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./p.js" }, + ]); + }); }); describe("Electron module interop pattern writes", () => { diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 795e780b98..3a5cbc0831 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -30,6 +30,7 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticKnownArrayIterationAt } from "./javascriptSemanticReferenceSources.js"; +import { semanticOpaqueBindingReadMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; // Match the complete Rolldown runtime operations, including its live getters. // Names and formatting are irrelevant; lexical roles, operators and keys are not. @@ -87,6 +88,11 @@ export const createSemanticModuleInterop = ( semanticKnownArrayIterationAt(array, state); const helpers = new Map(); const origins = new Map(); + const aliasTargets = new Map< + string, + { targetBinding: JavaScriptSemanticBindingState; node: t.Node } + >(); + const aliasChangedCache = new Map(); const calls = new WeakMap(); const intrinsicBindings = new Map(); const writes: { @@ -517,40 +523,9 @@ export const createSemanticModuleInterop = ( return result; }; - const origin = ( + const twoInitializerOrigin = ( binding: JavaScriptSemanticBindingState, ): InteropOrigin | undefined => { - const single = semanticStableInitializer(binding, state); - if (single !== undefined) { - if ( - !single.projection.every( - (key): key is string => typeof key === "string", - ) - ) - return undefined; - const converted = callOrigin(single.node); - if ( - converted?.origin.importedPath.length === 0 && - single.projection[0] === "default" && - !converted.defaultIsModule - ) - return undefined; - const result = - converted === undefined - ? undefined - : { - ...converted, - origin: { - ...converted.origin, - importedPath: [ - ...converted.origin.importedPath, - ...single.projection, - ], - }, - }; - if (result !== undefined) origins.set(binding.bindingId, result); - return result; - } const [initial, converted] = binding.initializers; if ( binding.definitions.length !== 2 || @@ -600,13 +575,126 @@ export const createSemanticModuleInterop = ( if (required === undefined || required.importedPath.length !== 0) return undefined; if (convertedCall === undefined) return undefined; - const result = { + return { origin: required, dependencies: convertedCall.dependencies, defaultIsModule: convertedCall.defaultIsModule, }; - origins.set(binding.bindingId, result); - return result; + }; + + const origin = ( + binding: JavaScriptSemanticBindingState, + ): InteropOrigin | undefined => { + const cached = origins.get(binding.bindingId); + if (cached !== undefined) return cached; + + let currentBinding: JavaScriptSemanticBindingState | undefined = binding; + const visited = new Set(); + const steps: { + binding: JavaScriptSemanticBindingState; + segments: readonly string[]; + }[] = []; + let baseOrigin: InteropOrigin | undefined; + + while (currentBinding !== undefined) { + const alreadyCached = origins.get(currentBinding.bindingId); + if (alreadyCached !== undefined) { + baseOrigin = alreadyCached; + break; + } + if (visited.has(currentBinding.bindingId)) return undefined; + visited.add(currentBinding.bindingId); + + const single = semanticStableInitializer(currentBinding, state); + if (single !== undefined) { + if ( + !single.projection.every( + (key): key is string => typeof key === "string", + ) + ) + return undefined; + const converted = callOrigin(single.node); + if (converted !== undefined) { + if ( + converted.origin.importedPath.length === 0 && + single.projection[0] === "default" && + !converted.defaultIsModule + ) + return undefined; + baseOrigin = { + ...converted, + origin: { + ...converted.origin, + importedPath: [ + ...converted.origin.importedPath, + ...(single.projection as readonly string[]), + ], + }, + }; + break; + } + const { root, members } = electronMemberPath(single.node); + if ( + !t.isIdentifier(root) || + !members.every((member): member is string => member !== null) + ) + return undefined; + const targetBinding = resolveSemanticBindingState( + state, + root, + root.name, + ); + if (targetBinding === undefined || targetBinding === currentBinding) + return undefined; + aliasTargets.set(currentBinding.bindingId, { + targetBinding, + node: root, + }); + steps.push({ + binding: currentBinding, + segments: [ + ...(members as readonly string[]), + ...(single.projection as readonly string[]), + ], + }); + currentBinding = targetBinding; + continue; + } + + const twoInit = twoInitializerOrigin(currentBinding); + if (twoInit !== undefined) { + baseOrigin = twoInit; + break; + } + return undefined; + } + + if (baseOrigin === undefined) return undefined; + origins.set(currentBinding!.bindingId, baseOrigin); + + let currentOrigin = baseOrigin; + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]!; + const firstSegment = step.segments[0]; + if ( + currentOrigin.origin.importedPath.length === 0 && + firstSegment === "default" && + !currentOrigin.defaultIsModule + ) + return undefined; + currentOrigin = { + ...currentOrigin, + origin: { + ...currentOrigin.origin, + importedPath: [ + ...currentOrigin.origin.importedPath, + ...step.segments, + ], + }, + }; + origins.set(step.binding.bindingId, currentOrigin); + } + return currentOrigin; }; const invalidatedOrigin = (record: InteropOrigin | undefined): boolean => record?.dependencies.some(({ path, capturedAt }) => { @@ -667,6 +755,34 @@ export const createSemanticModuleInterop = ( invalidatedCalls.set(node, result); return result; }, + aliasMayHaveChanged: (binding: JavaScriptSemanticBindingState): boolean => { + if (state.opaqueEffects.byScope.size === 0) return false; + const cached = aliasChangedCache.get(binding.bindingId); + if (cached !== undefined) return cached; + origin(binding); + let current: JavaScriptSemanticBindingState | undefined = binding; + const seen = new Set(); + let changed = false; + while (current !== undefined) { + if (seen.has(current.bindingId)) break; + seen.add(current.bindingId); + const next = aliasTargets.get(current.bindingId); + if (next === undefined) break; + if ( + semanticOpaqueBindingReadMayHaveChanged( + next.targetBinding, + next.node, + state, + ) + ) { + changed = true; + break; + } + current = next.targetBinding; + } + aliasChangedCache.set(binding.bindingId, changed); + return changed; + }, }; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index c3ba1098fa..1e98d51d1d 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -895,7 +895,8 @@ export function* indexJavaScriptElectronIdentitySteps( const binding = resolveSemanticBindingState(state, root, root.name); if ( binding !== undefined && - semanticOpaqueBindingReadMayHaveChanged(binding, root, state) + (semanticOpaqueBindingReadMayHaveChanged(binding, root, state) || + interop.aliasMayHaveChanged(binding)) ) return undefined; }