diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 29db81b579..8eabe8625b 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -385,6 +385,11 @@ const selectedRequireSourcesAt = ( semanticAliasSelectionChoices(current.path[0]!).every( (key) => key === "call" || key === "apply", ); + const nativeGenericMember = + target === "loader" && + genericMember && + t.isIdentifier(node) && + requireLoaderValueAt(node, node, state, factoryBindingIds) !== "local"; const key = JSON.stringify([current.path, loaderSourceContextKey(current)]); const paths = seen.get(node) ?? new Set(); if (paths.has(key)) continue; @@ -401,10 +406,6 @@ const selectedRequireSourcesAt = ( ]), ); } else if (t.isIdentifier(node)) { - if (target === "loader" && genericMember) - nativeMemberAlternative ||= - requireLoaderValueAt(node, node, state, factoryBindingIds) !== - "local"; if (target === "loader" && current.path.length === 0) { const captured = requireLoaderValueAt( node, @@ -455,6 +456,17 @@ const selectedRequireSourcesAt = ( current.readAt ?? node, current.capturedSelections, ); + if (nativeGenericMember) + nativeMemberAlternative ||= + facts === undefined || + facts.sources.length === 0 || + !facts.sources.every(({ node: source }) => { + const value = unwrapJavaScriptExpression(source).node; + return ( + t.isFunctionExpression(value) || + t.isArrowFunctionExpression(value) + ); + }); if (target === "factory" && binding.directOrigins.length > 0) { const native = binding.directOrigins.some((origin) => isCreateRequireOrigin(origin, current.path), @@ -660,6 +672,15 @@ export const semanticRequireCallAt = ( if (semanticModuleRequireCallAt(node, state)) return { loader: "unknown", arguments: undefined, sourceRelative: false }; const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + ["call", "apply"].includes( + semanticStaticPropertyKey(callee.property, callee.computed) ?? "", + ) && + selectedRequireSourcesAt(callee, node, state, factoryBindingIds)?.loader === + "local" + ) + return { loader: "local", arguments: undefined, sourceRelative: false }; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" @@ -670,7 +691,9 @@ export const semanticRequireCallAt = ( state, factoryBindingIds, ); - if (loader !== "local") { + // Bundler factory loaders can share the `require` shape, but their + // `.resolve` member is not Node's module resolver. + if (loader === "commonjs-loader" || loader === "create-require") { const selected = selectedRequireSourcesAt( callee.object, node, diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts index 1e2c5d3405..e65a02a6a7 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -160,6 +160,19 @@ it("retains a mutated require.resolve request as an unknown fact", () => { ).toEqual([expect.objectContaining({ kind: "require", specifier: null })]); }); +it("does not treat bundler factory resolve members as Node resolvers", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (module, exports, loader) { + loader.resolve("./not-a-dependency.cjs"); + } + }]); + `); + expect(analysis.references.filter(({ kind }) => kind === "require")).toEqual( + [], + ); +}); + it.each([ [ "captured wrapper", diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts index 90388435c2..b09764bb7c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts @@ -77,3 +77,36 @@ it.each(cases)( ); }, ); + +it.each(["call", "apply"] as const)( + "does not treat an owned local %s replacement as a loader invocation", + (method) => { + const args = method === "call" ? "null,'node:path'" : "null,['node:path']"; + const analysis = analyzeJavaScriptStaticSource( + `${named}const load=make(import.meta.url);load.${method}=()=>({kind:'local'});load.${method}(${args});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual([]); + }, +); + +it("keeps uncertainty for conditional call-member replacement", () => { + const analysis = analyzeJavaScriptStaticSource( + `${named}const load=make(import.meta.url);if(flag)load.call=()=>({kind:'local'});load.call(null,'node:path');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references.filter(({ kind }) => kind === "require")).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); +}); + +it("uses the restored call member after a local replacement", () => { + const source = `${named}const load=make(import.meta.url);load.call=()=>({kind:'local'});load.call=Function.prototype.call;load.call(null,'node:path');`; + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references.filter(({ kind }) => kind === "require")).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); +});