From 4bad48d87328918782fbc119c6a2f772689421dc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:10:08 +0800 Subject: [PATCH] fix: retain proven createRequire URL bases Fixes #6612 Fixes #6678 --- docs/cli.md | 11 +- .../ReferenceSourceImportEntries.ts | 5 +- .../javascriptSemanticRequireOrigin.ts | 80 ++++++++++++-- .../javascriptStaticAnalysisHelpers.ts | 37 +++++-- src/domain/referenceSourceImportParsing.ts | 18 +++- src/domain/referenceSourceRequireBindings.ts | 102 ++++++++++-------- ...ferenceSourceRequireResolutionBase.test.ts | 38 +++++-- 7 files changed, 219 insertions(+), 72 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index a6957371ce..12226d6e7a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -411,11 +411,12 @@ retain their targets; computed imports remain unknown. TypeScript import-equals dependencies remain `requires` relationships whether or not they are exported. CommonJS `require` relationships retain the requested specifier. REA resolves -them against the source file only when Node's default source-relative loader is -proven; non-default `createRequire` bases and unsupported -`require.resolve` search options remain partial with unknown resolution. An -excluded `package.json` also leaves package-directory selection unknown rather -than treating the manifest as absent and applying an `index` fallback. +them only when the loader base is proven: this includes the exact +`createRequire(new URL("./file.cjs", import.meta.url))` form and +`require.resolve` with `{ paths: [__dirname] }`. Other custom bases and search +options remain partial with unknown resolution. An excluded `package.json` +also leaves package-directory selection unknown rather than treating the +manifest as absent and applying an `index` fallback. Historical-source import requires safe no-follow file opens on Linux or macOS. Native Windows returns `unsupported_host`; use Linux REA inside WSL diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 2c22edb8a2..8b1002ebc1 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -351,7 +351,8 @@ const appendFile = ( if (parsed === undefined) return; output.limitations.push(...parsed.limitations); for (const relationship of parsed.relationships) { - const { resolutionStrategy, ...graphRelationship } = relationship; + const { resolutionStrategy, resolutionBasePath, ...graphRelationship } = + relationship; if (graphRelationship.resolution !== "internal") { output.relationships.push(graphRelationship); continue; @@ -363,7 +364,7 @@ const appendFile = ( ? "source-require" : "source-import"; const resolved = resolveInternalSpecifier( - graphRelationship.from_path, + resolutionBasePath ?? graphRelationship.from_path, graphRelationship.to, context, mode, diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index ef8007eb3d..43c7ba063a 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -26,7 +26,10 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; -import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; +import { + staticSourceRelativeUrlPath, + stringValue, +} from "./javascriptStaticAnalysisHelpers.js"; import { semanticEffectTiming, semanticBindingAtRead, @@ -46,7 +49,10 @@ import { type SemanticAliasSelectionPath, } from "./javascriptSemanticAliasSelections.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; -import { isUnmodifiedImportMetaUrl } from "./javascriptSemanticAmbientWrites.js"; +import { + isUnmodifiedGlobalReference, + isUnmodifiedImportMetaUrl, +} from "./javascriptSemanticAmbientWrites.js"; import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; export interface JavaScriptRequireOrigin { @@ -266,7 +272,12 @@ const createRequireFactoryCallAt = ( initializer: t.Node | undefined, state: JavaScriptSemanticAnalysisState, factoryBindingIds: ReadonlySet = new Set(), -): Pick | undefined => +): + | Pick< + JavaScriptRequireCallFact, + "loader" | "sourceRelative" | "sourceRelativePath" + > + | undefined => !t.isCallExpression(initializer) ? undefined : selectedRequireSourcesAt( @@ -277,6 +288,32 @@ const createRequireFactoryCallAt = ( "factory", ); +const sourceRelativeCreateRequirePath = ( + value: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): string | undefined => { + if (value === undefined) return undefined; + if (isUnmodifiedImportMetaUrl(value, state)) return "."; + if (!t.isNewExpression(value) || value.arguments.length !== 2) + return undefined; + const callee = unwrapJavaScriptExpression(value.callee).node; + if ( + !t.isIdentifier(callee, { name: "URL" }) || + !isUnmodifiedGlobalReference(callee, state, "URL") || + !isUnmodifiedImportMetaUrl( + t.isNode(value.arguments[1]) ? value.arguments[1] : undefined, + state, + ) + ) + return undefined; + const urlPath = staticSourceRelativeUrlPath(value); + return urlPath === undefined || + urlPath.directory || + urlPath.path.startsWith("/") + ? undefined + : urlPath.path; +}; + /** Eligibility for observable loader slots; an unknown factory is not invocation proof. */ export const hasSemanticCreateRequireFactoryOrigin = ( initializer: t.Node | undefined, @@ -318,7 +355,12 @@ const selectedRequireSourcesAt = ( state: JavaScriptSemanticAnalysisState, factoryBindingIds: ReadonlySet, target: "loader" | "factory" = "loader", -): Pick | undefined => { +): + | Pick< + JavaScriptRequireCallFact, + "loader" | "sourceRelative" | "sourceRelativePath" + > + | undefined => { const pending: ModuleObjectSource[] = [{ node: root, path: [] }]; const seen = new WeakMap>(); let count = 0; @@ -329,6 +371,7 @@ const selectedRequireSourcesAt = ( let local = false; let nativeMemberAlternative = false; let sourceRelative = true; + let sourceRelativePath: string | undefined; while (pending.length > 0) { const current = pending.pop()!; const node = unwrapJavaScriptExpression(current.node).node; @@ -514,10 +557,17 @@ const selectedRequireSourcesAt = ( selectedLoader !== "create-require"); selectedLoader ??= "create-require"; unknown ||= state.conditionallySkippedExpressions.has(node); - sourceRelative &&= isUnmodifiedImportMetaUrl( + const factoryPath = sourceRelativeCreateRequirePath( node.arguments[0], state, ); + if (factoryPath === undefined) sourceRelative = false; + else if (sourceRelativePath === undefined) + sourceRelativePath = factoryPath; + else if (sourceRelativePath !== factoryPath) { + sourceRelative = false; + sourceRelativePath = undefined; + } } } else if (factory?.loader === "local") local = true; else unknown = true; @@ -543,6 +593,9 @@ const selectedRequireSourcesAt = ( ? (selectedLoader ?? "unknown") : "local", sourceRelative: found && sourceRelative && !unknown, + ...(found && !unknown && sourceRelativePath !== undefined + ? { sourceRelativePath } + : {}), } : undefined; }; @@ -582,6 +635,8 @@ export interface JavaScriptRequireCallFact { | readonly t.CallExpression["arguments"][number][] | undefined; readonly sourceRelative: boolean; + /** A createRequire filename relative to the current source directory, when proven. */ + readonly sourceRelativePath?: string; } /** Classify the selected loader and normalize invocation arguments once. */ @@ -610,6 +665,9 @@ export const semanticRequireCallAt = ( loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: selected?.sourceRelative ?? loader !== "create-require", + ...(selected?.sourceRelativePath === undefined + ? {} + : { sourceRelativePath: selected.sourceRelativePath }), }; } const interop = loaderInvocationInterop(state); @@ -637,7 +695,13 @@ export const semanticRequireCallAt = ( ]; return interop.intrinsicPath(callee); }); - if (invocation?.path[0] === "require-loader") + if (invocation?.path[0] === "require-loader") { + const selected = selectedRequireSourcesAt( + node.callee, + node, + state, + factoryBindingIds, + ); return { loader: invocation.path[1] === "local" @@ -648,7 +712,11 @@ export const semanticRequireCallAt = ( arguments: invocation.arguments, sourceRelative: invocation.kind === "known" && invocation.path[2] === "relative", + ...(selected?.sourceRelativePath === undefined + ? {} + : { sourceRelativePath: selected.sourceRelativePath }), }; + } } const factory = semanticFactoryRequireLoaderAt( node, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 7a28037d0f..674fef983a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -255,7 +255,20 @@ const staticCallPath = ( return parts.length === 0 ? undefined : posix.join(...parts); }; -const staticFileUrlPath = ( +/** Normalized path facts from a source-relative file URL expression. */ +export interface JavaScriptStaticSourceUrlPath { + readonly path: string; + readonly directory: boolean; +} + +/** Read a literal `new URL(relative, import.meta.url)` shape without evaluation. */ +export const staticSourceRelativeUrlPath = ( + node: t.Node, +): JavaScriptStaticSourceUrlPath | undefined => + isSourceFileUrl(node) ? sourceRelativeFileUrlPath(node) : undefined; + +/** Convert an exact static file URL expression to a POSIX path. */ +export const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { @@ -263,7 +276,8 @@ const staticFileUrlPath = ( const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; - if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument); + if (isSourceFileUrl(argument)) + return sourceRelativeFileUrlPath(argument)?.path; const value = staticPath(argument, nodePathResolve); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( @@ -291,7 +305,7 @@ const staticFileUrlPath = ( /** Project a known source-relative URL through the actual file URL decoder. */ const sourceRelativeFileUrlPath = ( node: t.CallExpression | t.NewExpression, -): string | undefined => { +): JavaScriptStaticSourceUrlPath | undefined => { const value = stringValue(node.arguments[0]); if ( value === undefined || @@ -310,14 +324,19 @@ const sourceRelativeFileUrlPath = ( try { const resolved = new URL(value, `file://${directory}entry.js`); const path = fileURLToPath(resolved, { windows: false }); - if (value.startsWith("/")) return path; + if (value.startsWith("/")) + return { path, directory: resolved.pathname.endsWith("/") }; const relative = posix.relative(directory, path) || "."; // Keep the established explicit source-directory observation spelling. - return value.startsWith("./") && - relative !== "." && - !relative.startsWith("../") - ? `./${relative}` - : relative; + return { + path: + value.startsWith("./") && + relative !== "." && + !relative.startsWith("../") + ? `./${relative}` + : relative, + directory: resolved.pathname.endsWith("/"), + }; } catch (cause: unknown) { // Malformed escapes and non-file URL forms remain unknown. void cause; diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 01306c6878..0ff5b9a2ab 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -48,6 +48,8 @@ interface ReferenceSourceImportRelationship { readonly parse_state: ReferenceSourceImportParseState; /** Distinguishes call-form CommonJS require from TS import-equals `requires`. */ readonly resolutionStrategy?: "runtime-require"; + /** Internal lookup origin for a proven createRequire URL base. */ + readonly resolutionBasePath?: string; } interface ReferenceSourceParseFailure { @@ -126,6 +128,7 @@ const appendRelationship = ( readonly parseState: ReferenceSourceImportParseState; readonly resolution?: ReferenceSourceImportResolution; readonly resolutionStrategy?: "runtime-require"; + readonly resolutionBasePath?: string; }, ): void => { if (input.to === undefined) return; @@ -144,6 +147,9 @@ const appendRelationship = ( ...(input.resolutionStrategy === undefined ? {} : { resolutionStrategy: input.resolutionStrategy }), + ...(input.resolutionBasePath === undefined + ? {} + : { resolutionBasePath: input.resolutionBasePath }), }); }; @@ -236,7 +242,12 @@ const extractRequireAndDynamicImports = ( from_path: string, relationships: ReferenceSourceImportRelationship[], limitations: string[], - requireCallBases: ReadonlyMap, + requireCallBases: ReadonlyMap< + CallExpression, + | "source-file" + | { readonly kind: "source-relative-file"; readonly path: string } + | "unknown" + >, ): void => { // Single traversal owner: iterative, VISITOR_KEYS-gated, no recursion over // loc/comment objects. Survives deeply nested generated member chains that @@ -280,6 +291,11 @@ const extractRequireAndDynamicImports = ( ...(uncertainResolution ? {} : { resolutionStrategy: "runtime-require" as const }), + ...(typeof resolutionBase === "object" + ? { + resolutionBasePath: resolutionBase.path, + } + : {}), }); } continue; diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index ec4c8563e1..9bb05765ef 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { posix } from "node:path"; import { semanticStaticPropertyKey } from "./javascript/javascriptAstValues.js"; import { isUnmodifiedGlobalReference } from "./javascript/javascriptSemanticAmbientWrites.js"; @@ -20,7 +21,10 @@ import { type ParsedJavaScriptSource, } from "./javascript/javascriptSourceParser.js"; -export type ReferenceSourceRequireResolutionBase = "source-file" | "unknown"; +export type ReferenceSourceRequireResolutionBase = + | "source-file" + | { readonly kind: "source-relative-file"; readonly path: string } + | "unknown"; /** Identify exact calls through a source-owned Node loader and their resolver base. */ export const referenceSourceRequireCallBases = ( @@ -45,7 +49,7 @@ export const referenceSourceRequireCallBases = ( traverseJavaScriptAst(file.program, { enter: (node) => { if (!t.isCallExpression(node)) return; - const identifier = requireCalleeIdentifier(node.callee); + const identifier = callReceiverIdentifier(node.callee); if (identifier === undefined) return; let fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings); if (fact === "create-require") { @@ -64,20 +68,40 @@ export const referenceSourceRequireCallBases = ( index.bindings, factoryBindings, ); - const base = + const sourceRelativePath = callFact?.sourceRelativePath; + const normalizedSourceRelativePath = + sourceRelativePath === undefined + ? undefined + : posix.normalize( + posix.join(posix.dirname(path), sourceRelativePath), + ); + const base: ReferenceSourceRequireResolutionBase = fact === "create-require" - ? callFact?.sourceRelative === true - ? "source-file" - : "unknown" + ? sourceRelativePath === undefined + ? callFact?.sourceRelative === true + ? "source-file" + : "unknown" + : normalizedSourceRelativePath === undefined || + normalizedSourceRelativePath === ".." || + normalizedSourceRelativePath.startsWith("../") + ? "unknown" + : normalizedSourceRelativePath === path + ? "source-file" + : { + kind: "source-relative-file", + path: normalizedSourceRelativePath, + } : "source-file"; + const searchBase = requireResolveSearchBase( + node, + index.bindings, + base, + wrapperMode === "present", + ); candidates.push({ call: node, identifier, - resolutionBase: - base === "source-file" && - isDefaultRequireResolveSearch(node, index.bindings) - ? "source-file" - : "unknown", + resolutionBase: searchBase, }); }, }); @@ -95,38 +119,39 @@ export const referenceSourceRequireCallBases = ( }; /** `require.resolve` options replace the default search paths unless exact current-directory paths are supplied. */ -const isDefaultRequireResolveSearch = ( +const requireResolveSearchBase = ( call: t.CallExpression, bindings: JavaScriptSemanticAnalysisState, -): boolean => { - if (!isRequireResolveCall(call) || call.arguments.length < 2) return true; - if (call.arguments.length !== 2) return false; + 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; - if (options === undefined) return false; - if (!t.isObjectExpression(options)) return false; - if (options.properties.length === 0) return true; - if (options.properties.length !== 1) return false; + if (options === undefined || !t.isObjectExpression(options)) return "unknown"; + if (options.properties.length === 0) return loaderBase; + if (options.properties.length !== 1) return "unknown"; const property = options.properties[0]; - if (!t.isObjectProperty(property)) return false; + if (!t.isObjectProperty(property)) return "unknown"; if (semanticStaticPropertyKey(property.key, property.computed) !== "paths") - return false; + return "unknown"; const value = t.isExpression(property.value) ? property.value : undefined; - if (!t.isArrayExpression(value) || value.elements.length !== 1) return false; + if (!t.isArrayExpression(value) || value.elements.length !== 1) + return "unknown"; const directory = value.elements[0]; - return ( + return hasCommonJsWrapper && t.isIdentifier(directory, { name: "__dirname" }) && isUnmodifiedGlobalReference(directory, bindings, "__dirname") - ); + ? "source-file" + : "unknown"; }; const isRequireResolveCall = (call: t.CallExpression): boolean => { const callee = call.callee; - if ( - !t.isMemberExpression(callee) || - !t.isIdentifier(callee.object, { name: "require" }) - ) + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; return ( semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" @@ -164,23 +189,14 @@ const hasPriorMemberMutation = ( ); }; -const requireCalleeIdentifier = ( +const callReceiverIdentifier = ( callee: t.Node | null | undefined, ): t.Identifier | undefined => { - if (t.isIdentifier(callee, { name: "require" })) return callee; + if (t.isIdentifier(callee)) return callee; if ( - !t.isMemberExpression(callee) || - !t.isIdentifier(callee.object, { name: "require" }) || - ((!t.isIdentifier(callee.property) || callee.computed) && - !t.isStringLiteral(callee.property)) + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + t.isIdentifier(callee.object) ) - return undefined; - const propertyName = t.isIdentifier(callee.property) - ? callee.property.name - : t.isStringLiteral(callee.property) - ? callee.property.value - : undefined; - return propertyName === "resolve" || propertyName === "main" - ? callee.object - : undefined; + return callee.object; + return undefined; }; diff --git a/tests/boundary/cli/referenceSourceRequireResolutionBase.test.ts b/tests/boundary/cli/referenceSourceRequireResolutionBase.test.ts index 5d1ab3cada..b7bb45477c 100644 --- a/tests/boundary/cli/referenceSourceRequireResolutionBase.test.ts +++ b/tests/boundary/cli/referenceSourceRequireResolutionBase.test.ts @@ -8,7 +8,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; cliTest( - "preserves unknown CommonJS resolution when Node is given another base", + "resolves a proven createRequire URL base and preserves unsupported alternatives", async ({ cli }) => { const root = await createTestTempDirectory("rea-reference-require-base-"); const config = await createTestTempDirectory( @@ -24,10 +24,12 @@ cliTest( 'custom("./dep.cjs");', "const local = createRequire(import.meta.url);", 'local("./dep.cjs");', + 'local.resolve("./alias-options.cjs", { paths: [] });', ].join("\n"), ), writeFile(join(root, "dep.cjs"), "module.exports = 'root';\n"), writeFile(join(root, "sub", "dep.cjs"), "module.exports = 'sub';\n"), + writeFile(join(root, "sub", "entry.cjs"), "module.exports = {};\n"), ]); const result = await cli.run({ @@ -46,10 +48,10 @@ cliTest( expect.arrayContaining([ { from_path: "main.mjs", - to: "./dep.cjs", + to: "sub/dep.cjs", kind: "requires", - resolution: "unknown", - parse_state: "partial", + resolution: "internal", + parse_state: "parsed", }, { from_path: "main.mjs", @@ -58,6 +60,13 @@ cliTest( resolution: "internal", parse_state: "parsed", }, + { + from_path: "main.mjs", + to: "./alias-options.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }, ]), ); }, @@ -78,13 +87,16 @@ cliTest( join(root, "main.cjs"), [ 'require.resolve("./dep.cjs");', - 'require.resolve("./dep.cjs", { paths: [__dirname] });', + '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"), ), writeFile(join(root, "dep.cjs"), "module.exports = 'root';\n"), + writeFile(join(root, "same-path.cjs"), "module.exports = 'same path';\n"), writeFile(join(root, "sub", "dep.cjs"), "module.exports = 'sub';\n"), + writeFile(join(root, "shadow.cjs"), "module.exports = 'shadow';\n"), ]); const result = await cli.run({ @@ -118,8 +130,22 @@ cliTest( resolution: "unknown", parse_state: "partial", }, + { + from_path: "main.cjs", + to: "same-path.cjs", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }, + { + from_path: "main.cjs", + to: "./shadow.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }, ]), ); - expect(relationships).toHaveLength(2); + expect(relationships).toHaveLength(4); }, );