diff --git a/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts index 409151ec87..8e83747b13 100644 --- a/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts +++ b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts @@ -3,6 +3,7 @@ import { runInNewContext } from "node:vm"; import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; const cases = [ [ @@ -134,8 +135,37 @@ it("retains native source uncertainty when an array iterator is overwritten", () }); runInNewContext("change.call(app,'./lib');", module.exports); expect(changed).toBe(0); - // The static owner does not execute this custom iterator; it must retain the - // native origin still present in the source rather than silently drop it. + runInNewContext("app.setAppPath('./lib');", module.exports); + expect(changed).toBe(1); + // The local change function is harmless, but the published app still permits + // a caller to select the native setter. Unknown iteration retains that + // possibility without identifying a yielded value or an exact export link. const analysis = analyzeJavaScriptStaticSource(defining); expect((analysis.electron.app_path_changes ?? []).length).toBeGreaterThan(0); + const ir = analyzeJavaScriptSemantics(defining); + expect( + ir.moduleLinks.filter(({ kind }) => kind === "commonjs-export"), + ).toEqual([ + expect.objectContaining({ + exportedName: "*", + specifier: null, + importedName: null, + callableId: null, + }), + ]); +}); + +it.each([ + [ + "opaque iteration without an Electron source", + "const sources=[{change(){}}];sources[Symbol.iterator]=opaqueIterator;Object.assign(exports,...sources);", + ], + [ + "unresolved iteration into a detached export receiver", + "const{app}=require('electron');const sources=[{change:app.setAppPath,app}];sources[Symbol.iterator]=opaqueIterator;module.exports={};Object.assign(exports,...sources);", + ], +])("does not invent a public app-path effect from %s", (_name, source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.app_path_changes ?? []).toEqual([]); }); diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 4f98ec043b..7815935e48 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -248,7 +248,13 @@ const commonJsAssignedExports = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): - | { readonly receiver: t.Node; readonly sources: readonly (t.Node | null)[] } + | { + readonly receiver: t.Node; + readonly sources: readonly { + readonly value: t.Node | null; + readonly iterationSource?: t.Node; + }[]; + } | undefined => { if (!t.isCallExpression(node)) return undefined; const invocation = commonJsExportInvocation(node, state); @@ -270,11 +276,14 @@ const commonJsAssignedExports = ( return undefined; return { receiver, - sources: sources.map((source) => + sources: sources.map((source) => ({ // The invocation owner expands only proven argument positions. An // unresolved spread is a sequence of sources, not one array source. - t.isExpression(source) ? source : null, - ), + value: t.isExpression(source) ? source : null, + ...(t.isSpreadElement(source) + ? { iterationSource: source.argument } + : {}), + })), }; }; @@ -285,6 +294,8 @@ export const commonJsRuntimeExportValues = ( ): readonly { readonly exportedName: string; readonly value: t.Node | null; + /** Retained input to unresolved iteration; never a selected yielded value. */ + readonly iterationSource?: t.Node; /** Own enumerable fields copied at this source, after later-source overwrites. */ readonly projection?: JavaScriptSemanticPropertyPath; readonly ownEnumerable?: true; @@ -301,9 +312,13 @@ export const commonJsRuntimeExportValues = ( []; const overwritten = new Set(); for (let index = assigned.sources.length - 1; index >= 0; index--) { - const value = assigned.sources[index]!; + const { value, iterationSource } = assigned.sources[index]!; if (value === null) { - exported.push({ exportedName: "*", value: null }); + exported.push({ + exportedName: "*", + value: null, + ...(iterationSource === undefined ? {} : { iterationSource }), + }); continue; } const fields = copiedOwnFields(value, state); diff --git a/src/domain/javascript/javascriptElectronAppPathChanges.ts b/src/domain/javascript/javascriptElectronAppPathChanges.ts index e88b5b9237..99e32759ce 100644 --- a/src/domain/javascript/javascriptElectronAppPathChanges.ts +++ b/src/domain/javascript/javascriptElectronAppPathChanges.ts @@ -360,7 +360,10 @@ export const createSemanticElectronAppPathChangeQuery = ( return true; const exported = commonJsRuntimeExportValues(node, state); if ( - !exported.some(({ value }) => value !== null) || + !exported.some( + ({ value, iterationSource }) => + value !== null || iterationSource !== undefined, + ) || (!t.isAssignmentExpression(node) && !t.isCallExpression(node)) ) return false; @@ -368,11 +371,15 @@ export const createSemanticElectronAppPathChangeQuery = ( indexCommonJsExportReceiversSteps(state), )); return exported.some( - ({ exportedName, value, projection, ownEnumerable }) => + ({ exportedName, value, projection, ownEnumerable, iterationSource }) => commonJsExportApplicability(receivers.get(node), exportedName) !== "detached" && - value !== null && - possiblePath(value, true, projection, ownEnumerable), + (value !== null + ? possiblePath(value, true, projection, ownEnumerable) + : iterationSource !== undefined && + // Unknown iteration does not identify any yielded slot. Retain + // only source reachability through the existing escape query. + possiblePath(iterationSource, true)), ); }; };