diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 099d8865e9..fea8fc7bc4 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -12,6 +12,7 @@ import { semanticMutableBuiltinOrigin, semanticRequireExportsIdentity, semanticRequireOrigin, + semanticRequireLoaderAt, hasSemanticCreateRequireFactoryOrigin, } from "./javascriptSemanticRequireOrigin.js"; @@ -840,6 +841,17 @@ export function* collectSemanticMemberMutationsSteps( } const markReceiver = (callee: t.Node, mutation: t.Node): void => { const expression = unwrapJavaScriptExpression(callee).node; + if ( + (t.isCallExpression(mutation) || t.isOptionalCallExpression(mutation)) && + (t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression)) && + semanticStaticPropertyKey(expression.property, expression.computed) === + "resolve" && + ["commonjs-loader", "create-require"].includes( + semanticRequireLoaderAt(mutation, state, new Set()), + ) + ) + return; if ( t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 59909e391a..29db81b579 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -289,12 +289,16 @@ const createRequireFactoryCallAt = ( "factory", ); -const sourceRelativeCreateRequirePath = ( +type SourceRelativeCreateRequireBase = + | { readonly kind: "source-file" } + | { readonly kind: "source-relative-file"; readonly path: string }; + +const sourceRelativeCreateRequireBaseAt = ( value: t.Node | undefined, state: JavaScriptSemanticAnalysisState, -): string | undefined => { +): SourceRelativeCreateRequireBase | undefined => { if (value === undefined) return undefined; - if (isUnmodifiedImportMetaUrl(value, state)) return "."; + if (isUnmodifiedImportMetaUrl(value, state)) return { kind: "source-file" }; if (!t.isNewExpression(value) || value.arguments.length !== 2) return undefined; const callee = unwrapJavaScriptExpression(value.callee).node; @@ -312,14 +316,14 @@ const sourceRelativeCreateRequirePath = ( urlPath.directory || urlPath.path.startsWith("/") ? undefined - : urlPath.path; + : { kind: "source-relative-file", path: urlPath.path }; }; /** Eligibility for observable loader slots; an unknown factory is not invocation proof. */ export const hasSemanticCreateRequireFactoryOrigin = ( initializer: t.Node | undefined, state: JavaScriptSemanticAnalysisState, -): initializer is t.CallExpression => { +): boolean => { const factory = createRequireFactoryCallAt(initializer, state); return factory !== undefined && factory.loader !== "local"; }; @@ -372,7 +376,7 @@ const selectedRequireSourcesAt = ( let local = false; let nativeMemberAlternative = false; let sourceRelative = true; - let sourceRelativePath: string | undefined; + let sourceRelativeBase: SourceRelativeCreateRequireBase | undefined; while (pending.length > 0) { const current = pending.pop()!; const node = unwrapJavaScriptExpression(current.node).node; @@ -558,16 +562,20 @@ const selectedRequireSourcesAt = ( selectedLoader !== "create-require"); selectedLoader ??= "create-require"; unknown ||= state.conditionallySkippedExpressions.has(node); - const factoryPath = sourceRelativeCreateRequirePath( + const factoryBase = sourceRelativeCreateRequireBaseAt( node.arguments[0], state, ); - if (factoryPath === undefined) sourceRelative = false; - else if (sourceRelativePath === undefined) - sourceRelativePath = factoryPath; - else if (sourceRelativePath !== factoryPath) { + if (factoryBase === undefined) sourceRelative = false; + else if (sourceRelativeBase === undefined) + sourceRelativeBase = factoryBase; + else if ( + sourceRelativeBase.kind !== factoryBase.kind || + (sourceRelativeBase.kind === "source-relative-file" && + factoryBase.kind === "source-relative-file" && + sourceRelativeBase.path !== factoryBase.path) + ) { sourceRelative = false; - sourceRelativePath = undefined; } } } else if (factory?.loader === "local") local = true; @@ -594,8 +602,11 @@ const selectedRequireSourcesAt = ( ? (selectedLoader ?? "unknown") : "local", sourceRelative: found && sourceRelative && !unknown, - ...(found && !unknown && sourceRelativePath !== undefined - ? { sourceRelativePath } + ...(found && + !unknown && + sourceRelative && + sourceRelativeBase?.kind === "source-relative-file" + ? { sourceRelativePath: sourceRelativeBase.path } : {}), } : undefined; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 2c7635dd35..0aa7ccee2e 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -124,7 +124,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( yield* finishElectronBrowserWindowsSteps(findings, index); if (accumulator.pendingModuleRequireCalls.length > 0) { yield* index.indexMemberMutationFactsSteps(); - finishModuleRequireCalls(findings, bindings); + finishModuleRequireCalls(findings, index); } if ( accumulator.pendingNetworkCalls.length > 0 || diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 0276128449..07bfd5a92c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -23,6 +23,7 @@ import { stringValue, } from "./javascriptStaticAnalysisHelpers.js"; import { + semanticStaticPropertyKey, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -50,6 +51,7 @@ import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutati import { semanticFactoryLoaderMemberAt, semanticRequireCallAt, + semanticRequireLoaderAt, semanticModuleRequireCallAt, semanticModuleRequireReceiver, } from "./javascriptSemanticRequireOrigin.js"; @@ -69,9 +71,17 @@ export const inspectCall = ( const { accumulator } = context; const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); + const deferredRequireResolve = + isRequireResolveCall(node) && + semanticRequireLoaderAt( + node, + bindings, + accumulator.factoryRequireBindingIds, + ) !== "local"; const deferredModuleRequire = semanticModuleRequireReceiver(node) !== undefined || - semanticModuleRequireCallAt(node, bindings, false); + semanticModuleRequireCallAt(node, bindings, false) || + deferredRequireResolve; if (deferredModuleRequire) accumulator.pendingModuleRequireCalls.push(node); const requireCall = deferredModuleRequire ? null @@ -254,6 +264,7 @@ const requireCallSpecifier = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, context: FindingContext, + resolveMemberChanged = false, ): { readonly specifier: string | undefined } | null => { const call = semanticRequireCallAt( node, @@ -261,7 +272,11 @@ const requireCallSpecifier = ( context.accumulator.factoryRequireBindingIds, ); if (call === undefined || call.loader === "local") return null; - if (call.loader === "unknown" || !call.sourceRelative) { + if ( + resolveMemberChanged || + call.loader === "unknown" || + !call.sourceRelative + ) { context.accumulator.unknownFindings += 1; return { specifier: undefined }; } @@ -271,10 +286,26 @@ const requireCallSpecifier = ( /** Examine member-loader receivers only after shared alias mutation facts settle. */ export const finishModuleRequireCalls = ( context: FindingContext, - bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptBindingIndex, ): void => { for (const node of context.accumulator.pendingModuleRequireCalls) { - const call = requireCallSpecifier(node, bindings, context); + const resolveMemberChanged = + isRequireResolveCall(node) && + (t.isMemberExpression(node.callee) || + t.isOptionalMemberExpression(node.callee)) && + index + .effectsForReferenceAt(node, node.callee.object, ["resolve"]) + .effects.some( + ({ kind, timing }) => + timing !== "after" && + (kind === "member-write" || kind === "escape"), + ); + const call = requireCallSpecifier( + node, + index.bindings, + context, + resolveMemberChanged, + ); if (call !== null) addReference(context, { node, @@ -288,6 +319,15 @@ export const finishModuleRequireCalls = ( ); }; +const isRequireResolveCall = ( + node: JavaScriptCallLike, +): node is t.CallExpression | t.OptionalCallExpression => + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (t.isMemberExpression(node.callee) || + t.isOptionalMemberExpression(node.callee)) && + semanticStaticPropertyKey(node.callee.property, node.callee.computed) === + "resolve"; + const chunkLoadSpecifier = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts index 5f2d88d8d9..ca6c1cbad5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -6,6 +6,7 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { referenceSourceRequireCallBases } from "../referenceSourceRequireBindings.js"; +import { parseReferenceSourceImports } from "../referenceSourceImportParsing.js"; it.each(["require", "require.resolve", "require.main"])( "distinguishes invoked loading from reference association for %s", @@ -32,7 +33,9 @@ it.each(["require", "require.resolve", "require.main"])( ); const parsed = parseJavaScriptSource(source); if (parsed === null) throw new Error("Expected loader association fixture"); - expect(referenceSourceRequireCallBases(parsed, "entry.cjs").size).toBe(1); + expect(referenceSourceRequireCallBases(parsed, "entry.cjs").size).toBe( + callee === "require.main" ? 0 : 1, + ); }, ); @@ -80,6 +83,84 @@ it("associates a renamed createRequire resolve method with search options", () = expect(referenceSourceRequireCallBases(parsed, "entry.mjs").size).toBe(1); }); +it("keeps current-file and mixed createRequire bases distinct", () => { + const currentFile = parseJavaScriptSource( + `${imported}const load=make(import.meta.url);load('./dep.cjs');`, + ); + if (currentFile === null) + throw new Error("Expected current-file base fixture"); + const currentCall = [ + ...referenceSourceRequireCallBases(currentFile, "sub/main.mjs").keys(), + ][0]; + expect( + referenceSourceRequireCallBases(currentFile, "sub/main.mjs").get( + currentCall!, + )?.resolutionBase, + ).toBe("source-file"); + + const mixed = parseJavaScriptSource( + `${imported}const load=make(flag?import.meta.url:new URL('./sub/entry.cjs',import.meta.url));load('./dep.cjs');`, + ); + if (mixed === null) throw new Error("Expected mixed-base fixture"); + const mixedBases = referenceSourceRequireCallBases(mixed, "main.mjs"); + expect( + [...mixedBases.values()].map(({ resolutionBase }) => resolutionBase), + ).toEqual(["unknown"]); +}); + +it("keeps literal CommonJS resolve calls in historical source parsing", () => { + const source = [ + 'require.resolve("./dep.cjs");', + 'require.resolve("./same-path.cjs", { paths: [__dirname] });', + 'require.resolve("./dep.cjs", { paths: [__dirname + "/sub"] });', + 'require.resolve("./dep.cjs", { paths: [] });', + 'function shadow(__dirname) { require.resolve("./shadow.cjs", { paths: [__dirname] }); }', + ].join("\n"); + const result = parseReferenceSourceImports( + "main.cjs", + new TextEncoder().encode(source), + "JavaScript", + ); + expect( + result.relationships.map(({ to, resolution, parse_state }) => ({ + to, + resolution, + parse_state, + })), + ).toEqual([ + { to: "./dep.cjs", resolution: "internal", parse_state: "parsed" }, + { to: "./same-path.cjs", resolution: "internal", parse_state: "parsed" }, + { to: "./dep.cjs", resolution: "unknown", parse_state: "partial" }, + { to: "./dep.cjs", resolution: "unknown", parse_state: "partial" }, + { to: "./shadow.cjs", resolution: "unknown", parse_state: "partial" }, + ]); +}); + +it("retains a mutated require.resolve request as an unknown fact", () => { + const source = `${imported}const load=make(import.meta.url);load.resolve=custom;load.resolve("./dep.cjs");`; + const result = parseReferenceSourceImports( + "main.mjs", + new TextEncoder().encode(source), + "JavaScript", + ); + expect( + result.relationships.filter(({ kind }) => kind === "requires"), + ).toEqual([ + expect.objectContaining({ + from_path: "main.mjs", + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }), + ]); + expect( + analyzeJavaScriptStaticSource(source).references.filter( + ({ kind }) => kind === "require", + ), + ).toEqual([expect.objectContaining({ kind: "require", specifier: null })]); +}); + it.each([ [ "captured wrapper", diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 0ff5b9a2ab..0962e1fe11 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -11,7 +11,10 @@ import { parseRecoveredJavaScript, parserPluginsForPath, } from "./javascript/javascriptSourceParser.js"; -import { referenceSourceRequireCallBases } from "./referenceSourceRequireBindings.js"; +import { + referenceSourceRequireCallBases, + type ReferenceSourceRequireCall, +} from "./referenceSourceRequireBindings.js"; import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js"; import { isCallExpression, @@ -242,12 +245,7 @@ const extractRequireAndDynamicImports = ( from_path: string, relationships: ReferenceSourceImportRelationship[], limitations: string[], - requireCallBases: ReadonlyMap< - CallExpression, - | "source-file" - | { readonly kind: "source-relative-file"; readonly path: string } - | "unknown" - >, + requireCallBases: ReadonlyMap, ): void => { // Single traversal owner: iterative, VISITOR_KEYS-gated, no recursion over // loc/comment objects. Survives deeply nested generated member chains that @@ -273,15 +271,14 @@ const extractRequireAndDynamicImports = ( }); continue; } - const first = call.arguments[0]; // The shared exact-literal reader owns argument exactness: it unwraps // runtime-transparent TS wrappers (`as const`, `!`, `satisfies`) and // admits quoted and expression-free template spellings alike. - const resolutionBase = requireCallBases.get(call); - if (resolutionBase !== undefined) { - const result = moduleSpecifierFromExpression(first); + const requireCall = requireCallBases.get(call); + if (requireCall !== undefined) { + const result = moduleSpecifierFromExpression(requireCall.arguments?.[0]); if (result !== undefined) { - const uncertainResolution = resolutionBase === "unknown"; + const uncertainResolution = requireCall.resolutionBase === "unknown"; appendRelationship(relationships, limitations, { fromPath: from_path, to: result.specifier, @@ -291,9 +288,9 @@ const extractRequireAndDynamicImports = ( ...(uncertainResolution ? {} : { resolutionStrategy: "runtime-require" as const }), - ...(typeof resolutionBase === "object" + ...(typeof requireCall.resolutionBase === "object" ? { - resolutionBasePath: resolutionBase.path, + resolutionBasePath: requireCall.resolutionBase.path, } : {}), }); @@ -301,6 +298,7 @@ const extractRequireAndDynamicImports = ( continue; } + const first = call.arguments[0]; if (isImport(call.callee) && isModuleExpression(first)) { const result = moduleSpecifierFromExpression(first); if (result !== undefined) { diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index 9bb05765ef..f4efb6da86 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -11,6 +11,7 @@ import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSem import { semanticRequireCallAt, semanticRequireLoaderAt, + type JavaScriptRequireCallFact, } from "./javascript/javascriptSemanticRequireOrigin.js"; import { completeSemanticSteps, @@ -26,11 +27,16 @@ export type ReferenceSourceRequireResolutionBase = | { readonly kind: "source-relative-file"; readonly path: string } | "unknown"; +export interface ReferenceSourceRequireCall { + readonly arguments: JavaScriptRequireCallFact["arguments"]; + readonly resolutionBase: ReferenceSourceRequireResolutionBase; +} + /** Identify exact calls through a source-owned Node loader and their resolver base. */ export const referenceSourceRequireCallBases = ( file: Pick, path: string, -): ReadonlyMap => { +): ReadonlyMap => { // Unmarked paths depend on package scope or compiler options. Preserve their // candidate CommonJS edges; explicit module paths have no wrapper loader. const wrapperMode = commonJsWrapperMode(path); @@ -44,6 +50,7 @@ export const referenceSourceRequireCallBases = ( const candidates: { readonly call: t.CallExpression; readonly identifier: t.Identifier; + readonly arguments: JavaScriptRequireCallFact["arguments"]; readonly resolutionBase: ReferenceSourceRequireResolutionBase; }[] = []; traverseJavaScriptAst(file.program, { @@ -58,17 +65,15 @@ export const referenceSourceRequireCallBases = ( completeSemanticSteps(index.indexMemberMutationFactsSteps()); fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings); } - if ( - fact !== "create-require" && - (fact !== "commonjs-loader" || wrapperMode === "absent") - ) - return; const callFact = semanticRequireCallAt( node, index.bindings, factoryBindings, ); - const sourceRelativePath = callFact?.sourceRelativePath; + if (callFact === undefined || callFact.loader === "local") return; + if (fact !== "create-require" && fact !== "commonjs-loader") return; + if (fact === "commonjs-loader" && wrapperMode === "absent") return; + const sourceRelativePath = callFact.sourceRelativePath; const normalizedSourceRelativePath = sourceRelativePath === undefined ? undefined @@ -76,9 +81,9 @@ export const referenceSourceRequireCallBases = ( posix.join(posix.dirname(path), sourceRelativePath), ); const base: ReferenceSourceRequireResolutionBase = - fact === "create-require" + callFact.loader === "create-require" ? sourceRelativePath === undefined - ? callFact?.sourceRelative === true + ? callFact.sourceRelative === true ? "source-file" : "unknown" : normalizedSourceRelativePath === undefined || @@ -91,9 +96,12 @@ export const referenceSourceRequireCallBases = ( kind: "source-relative-file", path: normalizedSourceRelativePath, } - : "source-file"; + : fact === "commonjs-loader" && callFact.loader === "commonjs-loader" + ? "source-file" + : "unknown"; const searchBase = requireResolveSearchBase( node, + callFact.arguments, index.bindings, base, wrapperMode === "present", @@ -101,6 +109,7 @@ export const referenceSourceRequireCallBases = ( candidates.push({ call: node, identifier, + arguments: callFact.arguments, resolutionBase: searchBase, }); }, @@ -108,29 +117,35 @@ export const referenceSourceRequireCallBases = ( if (candidates.some(({ call }) => !t.isIdentifier(call.callee))) completeSemanticSteps(index.indexMemberMutationFactsSteps()); return new Map( - candidates - .filter( - ({ call, identifier }) => - t.isIdentifier(call.callee) || - !hasPriorMemberMutation(index, call, identifier), - ) - .map(({ call, resolutionBase }) => [call, resolutionBase] as const), + candidates.map( + ({ call, identifier, arguments: callArguments, resolutionBase }) => + [ + call, + { + arguments: callArguments, + resolutionBase: + !t.isIdentifier(call.callee) && + hasPriorMemberMutation(index, call, identifier) + ? "unknown" + : resolutionBase, + }, + ] as const, + ), ); }; /** `require.resolve` options replace the default search paths unless exact current-directory paths are supplied. */ const requireResolveSearchBase = ( call: t.CallExpression, + callArguments: JavaScriptRequireCallFact["arguments"], bindings: JavaScriptSemanticAnalysisState, loaderBase: ReferenceSourceRequireResolutionBase, hasCommonJsWrapper: boolean, ): ReferenceSourceRequireResolutionBase => { - if (!isRequireResolveCall(call) || call.arguments.length < 2) - return loaderBase; - if (call.arguments.length !== 2) return "unknown"; - const options = t.isExpression(call.arguments[1]) - ? call.arguments[1] - : undefined; + const arguments_ = callArguments ?? []; + if (!isRequireResolveCall(call) || arguments_.length < 2) return loaderBase; + if (arguments_.length !== 2) return "unknown"; + const options = t.isExpression(arguments_[1]) ? arguments_[1] : undefined; if (options === undefined || !t.isObjectExpression(options)) return "unknown"; if (options.properties.length === 0) return loaderBase; if (options.properties.length !== 1) return "unknown";