diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index fdabee8e07..be004588e0 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -30,6 +30,7 @@ import { semanticPropertyPathKeyMatches, } from "./javascriptSemanticPropertyPaths.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { semanticRequireLoaderAt } from "./javascriptSemanticRequireOrigin.js"; import { semanticCopiedReferencePath, semanticReferenceIterationUnknown, @@ -772,7 +773,45 @@ export function* indexJavaScriptAmbientWritesSteps( retainSemanticAmbientEffect(state, "write", resolved, node); } } - for (const { node, target, browserReceiver, projection } of escapes) { + // Settle other ambient effects first. A syntactic resolver call is safe only + // if no write or opaque escape can replace the native member anywhere in + // this analysis; otherwise retain its receiver escape conservatively. + const resolverReceivers: typeof escapes = []; + for (const effect of escapes) { + const { node, target } = effect; + const callee = + t.isCallExpression(node) || t.isOptionalCallExpression(node) + ? unwrapJavaScriptExpression(node.callee).node + : undefined; + if ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + callee.object === target && + t.isIdentifier(target, { name: "require" }) && + isUnshadowedGlobal(target, state, "require") && + semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" + ) { + resolverReceivers.push(effect); + continue; + } + retainEscapeEffect(effect); + } + for (const effect of resolverReceivers) { + const { node } = effect; + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + !state.ambientWrites.covers(["global", "require", "resolve"]) && + !state.ambientEscapes.covers(["global", "require", "resolve"]) && + semanticRequireLoaderAt(node, state, new Set()) === "commonjs-loader" + ) + continue; + retainEscapeEffect(effect); + } + function retainEscapeEffect({ + node, + target, + browserReceiver, + projection, + }: (typeof escapes)[number]): void { const resolved = ambientWrites(target, node, true, projection); if ( browserReceiver && diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts index 023b54aeca..38799b1ed1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -70,6 +70,30 @@ it.each([ ); const imported = "import {createRequire as make} from 'node:module';"; +it("retains consecutive native resolver calls while keeping replaced or escaped loaders uncertain", () => { + const references = (source: string) => + analyzeJavaScriptStaticSource(source).references.map( + ({ operation, specifier }) => ({ operation, specifier }), + ); + expect( + references('require.resolve("./first.js");require.resolve("./second.js");'), + ).toEqual([ + { operation: "resolve", specifier: "./first.js" }, + { operation: "resolve", specifier: "./second.js" }, + ]); + for (const prefix of [ + "require.resolve=custom;", + "opaque(require);", + "const box={r:require};box.r.resolve=custom;", + 'Object.defineProperty(require,"resolve",{value:custom});', + ]) + expect( + references(`${prefix}require.resolve("./second.js");`).at(-1), + ).toEqual({ + operation: "load", + specifier: null, + }); +}); it.each([ [ "proven factory", diff --git a/tests/acceptance/applications/javascriptRepeatedResolve.test.ts b/tests/acceptance/applications/javascriptRepeatedResolve.test.ts new file mode 100644 index 0000000000..4f0deb4973 --- /dev/null +++ b/tests/acceptance/applications/javascriptRepeatedResolve.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains consecutive native resolver relationships through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-repeat-resolve-"); + await writeFixtureFiles(root, { + "main.cjs": + 'require.resolve("./first.js");require.resolve("./second.js");', + "first.js": "module.exports=1;", + "second.js": "module.exports=2;", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const edges = result.graph.edges.filter( + (edge) => edge.properties.operation === "resolve", + ); + expect( + edges.map((edge) => [ + edge.properties.specifier, + edge.properties.resolved_path, + ]), + ).toEqual( + expect.arrayContaining([ + ["./first.js", "first.js"], + ["./second.js", "second.js"], + ]), + ); + }, +);