diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 97715243d7..6eef8ced78 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -387,7 +387,392 @@ describe("contextual JavaScript package path resolution", () => { ).toMatchObject({ resolution_status: "external", resolved_path: null, - limitations: [expect.stringContaining("subpaths remain unresolved")], + limitations: [ + expect.stringContaining("declares no exports target for subpath"), + ], + }); + }); +}); + +describe("package exports subpath resolution", () => { + it("resolves exact subpath keys through import and require conditions", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ + exports: { + ".": "./index.cjs", + "./parser": { + import: "./parser.mjs", + require: "./parser.cjs", + default: "./fallback.js", + }, + }, + }), + ), + file("app/node_modules/fixture/index.cjs", "root"), + file("app/node_modules/fixture/parser.mjs", "root"), + file("app/node_modules/fixture/parser.cjs", "root"), + file("app/node_modules/fixture/fallback.js", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/parser.mjs", + }); + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/parser.cjs", + }); + }); + + it("resolves array subpath targets at their first string entry", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ + exports: { + "./parser": [{ import: "./parser.mjs" }, "./parser.cjs"], + }, + }), + ), + file("app/node_modules/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/parser.cjs", + }); + }); + + it("resolves subpaths of scoped packages", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/@scope/fixture/package.json", + "root", + JSON.stringify({ + exports: { "./parser": "./parser.cjs" }, + }), + ), + file("app/node_modules/@scope/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "@scope/fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/@scope/fixture/parser.cjs", + }); + }); +}); + +describe("package exports subpath encapsulation", () => { + it("keeps subpaths encapsulated when only a root target is exported", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ exports: "./index.cjs" }), + ), + file("app/node_modules/fixture/index.cjs", "root"), + file("app/node_modules/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "external", + resolved_path: null, + limitations: [expect.stringContaining("a root target only")], + }); + }); + + it("blocks subpaths with an explicit null target", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ + exports: { ".": "./index.cjs", "./parser": null }, + }), + ), + file("app/node_modules/fixture/index.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "external", + resolved_path: null, + limitations: [expect.stringContaining("explicit null exports target")], + }); + }); + + it("reports a missing selected subpath target without index fallback", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ + exports: { "./parser": "./parser.cjs" }, + }), + ), + file("app/node_modules/fixture/parser/index.js", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + limitations: [expect.stringContaining("exports targets are exact files")], + }); + }); +}); + +describe("package subpath directory ownership", () => { + it.each(["root", "other"])( + "selects metadata-less package directories only in the %s container", + (container) => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file("app/src/node_modules/fixture/parser.js", container), + file("app/node_modules/fixture/package.json", "root", "{}"), + file("app/node_modules/fixture/parser.js", "root"), + ]); + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + declared_path: "fixture/parser", + resolution_context: "module-specifier", + resolution_status: "resolved", + resolved_path: + container === "root" + ? "app/src/node_modules/fixture/parser.js" + : "app/node_modules/fixture/parser.js", + }); + }, + ); + + it("does not mistake a package-name file or sibling prefix for a nearer directory", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file("app/src/node_modules/fixture.js", "root"), + file("app/src/node_modules/fixture-extra/parser.js", "root"), + file("app/node_modules/fixture/parser.js", "root"), + ]); + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolved_path: "app/node_modules/fixture/parser.js", + resolution_status: "resolved", + }); + }); + + it.each([ + ["malformed", "{", "not valid package JSON"], + ["unavailable", null, "text is unavailable"], + ["cyclic", '{"main":"."}', "cycle"], + ])("preserves %s nested directory failures", (_name, text, reason) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/node_modules/fixture/package.json", "root", "{}"), + file("app/node_modules/fixture/parser/package.json", "root", text), + ]); + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + declared_path: "fixture/parser", + resolution_context: "module-specifier", + resolved_path: null, + resolution_status: "unavailable", + limitations: [expect.stringContaining(reason)], + }); + }); +}); + +describe("package subpath legacy lookup and metadata failures", () => { + it("resolves legacy subpath files and indexes without an exports field", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + '{"main":"./index.cjs"}', + ), + file("app/node_modules/fixture/direct.cjs", "root"), + file("app/node_modules/fixture/folder/index.js", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/direct", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/direct.cjs", + }); + expect( + resolve({ + declaredPath: "fixture/folder", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/folder/index.js", + }); + }); + + it("rejects subpath metadata that mixes subpath and condition keys", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ + exports: { "./parser": "./parser.cjs", node: "./index.cjs" }, + }), + ), + file("app/node_modules/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "rejected", + resolved_path: null, + limitations: [expect.stringContaining("mixes subpath keys")], + }); + }); + + it("reports malformed subpath package metadata as unavailable", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file("app/node_modules/fixture/package.json", "root", "{"), + file("app/node_modules/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: [expect.stringContaining("not valid package JSON")], + }); + }); + + it("reports unavailable metadata text for subpath resolution", () => { + const files = fileMap([ + file("app/src/consumer.js", "root"), + file("app/node_modules/fixture/package.json", "root", null), + file("app/node_modules/fixture/parser.cjs", "root"), + ]); + + expect( + resolve({ + declaredPath: "fixture/parser", + sourcePath: "app/src/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: [expect.stringContaining("text is unavailable")], }); }); }); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 418b9ac862..6f32b4238a 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -80,61 +80,54 @@ type CandidateResolution = readonly limitations: readonly string[]; }; -/** Resolve one declaration under its exact syntax/metadata context. */ +/** Resolve one declaration while preserving its original input identity. */ export const resolveArtifactPathByContext = ( input: ResolveArtifactPathInput, -): ArtifactPathResolution => resolvePackagePath(input, new Set()); - -const resolvePackagePath = ( - input: ResolveArtifactPathInput, - packageChain: ReadonlySet, - mode: "full" | "files-and-index" = "full", - exportsTargetUrl = false, ): ArtifactPathResolution => { - const rejected = rejectDeclaration(input); - if (rejected !== null) return rejected; - const candidate = contextualCandidate(input, exportsTargetUrl); - if (typeof candidate !== "string") return candidate; - const confined = confineCandidate(input, candidate); - if (typeof confined !== "string") return confined; - if (input.context === "html-reference") - return outcome( - input, - resolveFileCandidates(input, [confined]) ?? { - resolvedPath: null, - status: "not-found", - limitations: [ - `The exact HTML resource ${confined} was not found among the selected application files.`, - ], - }, - ); - // An exports target names one exact file; Node tries no extension or index. - const resolved = exportsTargetUrl - ? (resolveFileCandidates(input, [confined]) ?? - exactExportsTargetNotFound(confined)) - : mode === "files-and-index" - ? (resolveFileCandidates(input, [ - ...fileCandidates(confined), - ...indexCandidates(confined), - ]) ?? notFoundCandidate()) - : resolveCandidate(input, confined, packageChain); - return outcome(input, resolved); + const resolution = resolvePath(input); + return resolution.status === "resolved" + ? { + declared_path: input.declaredPath, + resolution_context: input.context, + resolved_path: resolution.resolvedPath, + resolution_status: resolution.status, + limitations: resolution.limitations, + } + : { + declared_path: input.declaredPath, + resolution_context: input.context, + resolved_path: null, + resolution_status: resolution.status, + limitations: resolution.limitations, + }; }; -const rejectDeclaration = ( - input: ResolveArtifactPathInput, -): ArtifactPathResolution | null => { - const declared = input.declaredPath; +const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { + const rejected = rejectDeclaration(input.declaredPath); + if (rejected !== null) return rejected; + const candidate = contextualCandidate(input); + if (typeof candidate !== "string") return candidate; + const confined = confineCandidate(candidate); + if (typeof confined !== "string") return confined; + if (input.context === "html-reference") + return ( + resolveFileCandidates(input, [confined]) ?? + unresolvedCandidate("not-found", [ + `The exact HTML resource ${confined} was not found among the selected application files.`, + ]) + ); + return resolveCandidate(input, confined, new Set()); +}; + +const rejectDeclaration = (declared: string): CandidateResolution | null => { if (declared.length === 0) - return unresolvedOutcome(input, "rejected", [ - "The declared path is empty.", - ]); + return unresolvedCandidate("rejected", ["The declared path is empty."]); if (declared.includes("\0") || declared.includes("\\")) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "NUL and backslash path syntax are not admitted for canonical artifact paths.", ]); if (!admitsCanonicalPathSyntax(declared)) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "Encoded dot or separator bytes are rejected before artifact path resolution.", ]); return null; @@ -142,13 +135,11 @@ const rejectDeclaration = ( const contextualCandidate = ( input: ResolveArtifactPathInput, - exportsTargetUrl: boolean, -): string | ArtifactPathResolution => { +): string | CandidateResolution => { const { context } = input; let declared = context === "html-reference" || context === "url-reference" || - exportsTargetUrl || (context === "module-specifier" && input.moduleKind !== "require") ? stripQueryAndFragment(input.declaredPath) : input.declaredPath; @@ -157,66 +148,132 @@ const contextualCandidate = ( const fileUrl = fileUrlPath(declared); if (fileUrl !== undefined) return fileUrl; if (hasScheme(declared)) - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "URL and Node builtin schemes are outside static artifact module resolution.", ]); if (!declared.startsWith(".") && !declared.startsWith("/")) return bareModuleCandidate(input, declared); + if (input.moduleKind !== "require") { + const decoded = decodeModuleUrlPath(declared); + if (typeof decoded !== "string") return decoded; + declared = decoded; + } } else if (looksExternal(declared)) - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "URL schemes and protocol-relative URLs are outside this local artifact path context.", ]); - // Exports targets use URL paths for both loaders; legacy main fields remain literal. - if ( - exportsTargetUrl || - (context === "module-specifier" && input.moduleKind !== "require") - ) { - try { - declared = decodeURIComponent(declared); - } catch (cause: unknown) { - void cause; - return unresolvedOutcome(input, "rejected", [ - "The module URL path contains malformed percent encoding.", - ]); - } - if (declared.includes("\0")) - return unresolvedOutcome(input, "rejected", [ - "The decoded module URL path contains NUL.", - ]); - } - const relative = declared.startsWith("/") ? declared.slice(1) : declared; - return declared.startsWith("/") - ? relative - : posix.join(posix.dirname(input.sourcePath), relative); + return relativeCandidate(input.sourcePath, declared); }; +const decodeModuleUrlPath = ( + declared: string, +): string | CandidateResolution => { + let decoded: string; + try { + decoded = decodeURIComponent(declared); + } catch (cause: unknown) { + void cause; + return unresolvedCandidate("rejected", [ + "The module URL path contains malformed percent encoding.", + ]); + } + return decoded.includes("\0") + ? unresolvedCandidate("rejected", [ + "The decoded module URL path contains NUL.", + ]) + : decoded; +}; + +const relativeCandidate = (sourcePath: string, declared: string): string => + declared.startsWith("/") + ? declared.slice(1) + : posix.join(posix.dirname(sourcePath), declared); + const bareModuleCandidate = ( input: ResolveArtifactPathInput, declared: string, -): string | ArtifactPathResolution => { +): CandidateResolution => { const packageName = barePackageName(declared); if (packageName === null || isBuiltin(declared) || declared.startsWith("#")) - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "The bare specifier is a Node builtin, package import map, or invalid package name.", ]); - if (declared !== packageName) - return unresolvedOutcome(input, "external", [ - "Bare package subpaths remain unresolved unless an exact package-exports subpath model is available.", - ]); + const subpath = + declared === packageName ? "." : `.${declared.slice(packageName.length)}`; const source = input.files.get(input.sourcePath); let directory = posix.dirname(input.sourcePath); while (true) { - const candidate = posix.join(directory, "node_modules", declared); - if (hasContainerCandidate(input.files, candidate, source?.container_sha256)) - return candidate; + const candidate = posix.join(directory, "node_modules", packageName); + if (subpath === ".") { + const direct = resolveFileCandidates(input, fileCandidates(candidate)); + if (direct !== null) return direct; + } + if (hasContainerDirectory(input.files, candidate, source?.container_sha256)) + return resolvePackageSpecifier(input, candidate, subpath); if (directory === "." || directory === "") break; directory = posix.dirname(directory); } - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "No matching bare package was inventoried in an enclosing node_modules directory.", ]); }; +/** Package roots and exact subpaths share metadata and exports selection. */ +const resolvePackageSpecifier = ( + input: ResolveArtifactPathInput, + directory: string, + subpath: string, +): CandidateResolution => { + const metadata = readPackageMetadata(input, directory); + if (metadata.kind === "unavailable") + return unresolvedCandidate("unavailable", [metadata.limitation]); + const rawExports = + metadata.kind === "available" + ? Reflect.get(metadata.value, "exports") + : undefined; + if (rawExports === undefined || rawExports === null) { + if (subpath === ".") + return resolveDirectory(input, directory, metadata, new Set()); + const candidate = confineCandidate(posix.join(directory, subpath.slice(2))); + return typeof candidate === "string" + ? resolveCandidate(input, candidate, new Set()) + : candidate; + } + const exported = selectPackageExport(rawExports, subpath, input.moduleKind); + const packagePath = posix.join(directory, "package.json"); + if (exported.status === "rejected") + return unresolvedCandidate("rejected", [ + `Package metadata ${packagePath}: ${exported.limitation}`, + ]); + if (exported.status === "unmatched") + return unresolvedCandidate("external", [ + `Directory package metadata ${packagePath} declares no exports target ${subpath === "." ? "for the active conditions" : `for subpath ${subpath}`}; exports encapsulation permits no file or index lookup. It declares ${exported.declared.join(", ") || "a root target only"}.`, + ]); + if (exported.status === "blocked") + return unresolvedCandidate("external", [ + `Directory package metadata ${packagePath} blocks subpath ${subpath} with an explicit null exports target.`, + ]); + return resolveExportTarget(input, packagePath, exported.value); +}; + +/** An exports target is a URL naming one exact file for both Node loaders. */ +const resolveExportTarget = ( + input: ResolveArtifactPathInput, + packagePath: string, + target: string, +): CandidateResolution => { + const rejected = rejectDeclaration(target); + if (rejected !== null) return rejected; + const decoded = decodeModuleUrlPath(stripQueryAndFragment(target)); + if (typeof decoded !== "string") return decoded; + const candidate = confineCandidate(relativeCandidate(packagePath, decoded)); + if (typeof candidate !== "string") return candidate; + return ( + resolveFileCandidates(input, [candidate]) ?? + exactExportsTargetNotFound(candidate) + ); +}; + const barePackageName = (specifier: string): string | null => { const segments = specifier.split("/"); if (specifier.startsWith("@")) @@ -226,30 +283,43 @@ const barePackageName = (specifier: string): string | null => { return segments[0] === "" ? null : (segments[0] ?? null); }; -const hasContainerCandidate = ( +const hasContainerDirectory = ( files: ReadonlyMap, candidate: string, containerSha256: string | undefined, -): boolean => - [ - ...fileCandidates(candidate), - ...indexCandidates(candidate), - posix.join(candidate, "package.json"), - ].some((path) => { - const file = files.get(path); - return ( - file !== undefined && +): boolean => { + if ( + [...indexCandidates(candidate), posix.join(candidate, "package.json")].some( + (path) => { + const file = files.get(path); + return ( + file !== undefined && + (containerSha256 === undefined || + file.container_sha256 === containerSha256) + ); + }, + ) + ) + return true; + // Inventory has files rather than directory entries. Any same-container + // descendant establishes a package directory even without package.json. + const prefix = `${candidate}/`; + for (const [path, file] of files) + if ( + path.startsWith(prefix) && (containerSha256 === undefined || file.container_sha256 === containerSha256) - ); - }); + ) + return true; + return false; +}; const htmlCandidate = ( input: ResolveArtifactPathInput, -): string | ArtifactPathResolution => { +): string | CandidateResolution => { const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath)); if (looksExternal(declared)) - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "External HTML references are not mapped to local artifact assets.", ]); const rawBase = input.htmlBaseHref; @@ -258,18 +328,18 @@ const htmlCandidate = ( ? rawBase : stripQueryAndFragment(htmlUrlText(rawBase)); if (base !== undefined && base !== null && looksExternal(base)) - return unresolvedOutcome(input, "external", [ + return unresolvedCandidate("external", [ "The document base href is external, so its script reference is not a local artifact path.", ]); // Removing URL tabs and newlines can join an encoded dot or separator that // the raw declaration split, so admit the parsed URL text again. if (!admitsCanonicalPathSyntax(declared)) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "Encoded dot or separator bytes are rejected before artifact path resolution.", ]); const declaredPath = percentDecodeUrlPath(declared); if (declaredPath === null) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); if (declaredPath.startsWith("/")) return declaredPath.slice(1); @@ -281,31 +351,29 @@ const htmlCandidate = ( // admission rules a declared path gets so it cannot smuggle traversal or // separator syntax past canonicalization. if (!admitsCanonicalPathSyntax(base)) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.", ]); const decodedBase = percentDecodeUrlPath(base); if (decodedBase === null) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "The document base href path percent-decodes to NUL or to bytes that are not UTF-8.", ]); const basePath = decodedBase.startsWith("/") ? decodedBase.slice(1) : posix.join(posix.dirname(input.sourcePath), decodedBase); - if (declaredPath === "") - return emptyHtmlReference(input, decodedBase, basePath); + if (declaredPath === "") return emptyHtmlReference(decodedBase, basePath); return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath); }; const emptyHtmlReference = ( - input: ResolveArtifactPathInput, base: string, basePath: string, -): string | ArtifactPathResolution => { +): string | CandidateResolution => { if (!htmlBaseIsDirectory(base)) return basePath; - const confined = confineCandidate(input, basePath); + const confined = confineCandidate(basePath); if (typeof confined !== "string") return confined; - return unresolvedOutcome(input, "not-found", [ + return unresolvedCandidate("not-found", [ `The HTML reference resolves to directory ${confined || "."}, not an exact selected application file.`, ]); }; @@ -326,123 +394,132 @@ const htmlBaseIsDirectory = (base: string): boolean => const htmlBaseDirectory = (base: string, basePath: string): string => htmlBaseIsDirectory(base) ? basePath : posix.dirname(basePath); -const confineCandidate = ( - input: ResolveArtifactPathInput, - candidate: string, -): string | ArtifactPathResolution => { +const confineCandidate = (candidate: string): string | CandidateResolution => { const normalized = normalizeJoinedLogicalPath(candidate); if (logicalPathEscapesRoot(normalized)) - return unresolvedOutcome(input, "rejected", [ + return unresolvedCandidate("rejected", [ "The resolved candidate escapes the canonical artifact root.", ]); return normalized === "." ? "" : normalized; }; +type PackageMetadata = + | { + readonly kind: "available"; + readonly path: string; + readonly value: object; + } + | { readonly kind: "absent" } + | { readonly kind: "unavailable"; readonly limitation: string }; + +const readPackageMetadata = ( + input: ResolveArtifactPathInput, + directory: string, +): PackageMetadata => { + const path = posix.join(directory, "package.json"); + const file = input.files.get(path); + const source = input.files.get(input.sourcePath); + if ( + file === undefined || + (source !== undefined && file.container_sha256 !== source.container_sha256) + ) + return { kind: "absent" }; + if (!file.text.included) + return { + kind: "unavailable", + limitation: `Directory package metadata ${path} was inventoried but its text is unavailable: ${file.text.reason}.`, + }; + let value: unknown; + try { + value = JSON.parse(file.text.value); + } catch (cause: unknown) { + void cause; + return { + kind: "unavailable", + limitation: `Directory package metadata ${path} is not valid package JSON.`, + }; + } + return typeof value === "object" && value !== null + ? { kind: "available", path, value } + : { + kind: "unavailable", + limitation: `Directory package metadata ${path} is not valid package JSON.`, + }; +}; + +/** File and directory lookup never interprets a nested directory's exports. */ const resolveCandidate = ( input: ResolveArtifactPathInput, candidate: string, packageChain: ReadonlySet, -): CandidateResolution => { - const direct = resolveFileCandidates(input, fileCandidates(candidate)); - if (direct !== null) return direct; - const source = input.files.get(input.sourcePath); - const packagePath = posix.join(candidate, "package.json"); - const packageFile = input.files.get(packagePath); - if ( - packageFile === undefined || - (source !== undefined && - packageFile.container_sha256 !== source.container_sha256) - ) - return ( - resolveFileCandidates(input, indexCandidates(candidate)) ?? - notFoundCandidate() - ); - if (!packageFile.text.included) - return { - resolvedPath: null, - status: "unavailable", - limitations: [ - `Directory package metadata ${packagePath} was inventoried but its text is unavailable: ${packageFile.text.reason}.`, - ], - }; - const main = packageEntry( - packageFile.text.value, - input.moduleKind, - input.context === "module-specifier" && - !input.declaredPath.startsWith(".") && - !input.declaredPath.startsWith("/") && - !hasScheme(input.declaredPath), +): CandidateResolution => + resolveFileCandidates(input, fileCandidates(candidate)) ?? + resolveDirectory( + input, + candidate, + readPackageMetadata(input, candidate), + packageChain, ); - if (main.status === "invalid") - return { - resolvedPath: null, - status: "unavailable", - limitations: [ - `Directory package metadata ${packagePath} is not valid package JSON.`, - ], - }; - if (main.status === "rejected") - return { - resolvedPath: null, - status: "rejected", - limitations: [`Package metadata ${packagePath}: ${main.limitation}`], - }; - if (main.status === "unmatched") - return { - resolvedPath: null, - status: "external", - limitations: [ - `Directory package metadata ${packagePath} declares no exports target for the active conditions; it declares ${main.declared.join(", ")}.`, - ], - }; - if (packageChain.has(packagePath)) { - return { - resolvedPath: null, - status: "unavailable", - limitations: [ - `Directory package entrypoint cycle includes ${packagePath}.`, - ], - }; - } - if (main.status === "missing") + +const resolveDirectory = ( + input: ResolveArtifactPathInput, + directory: string, + metadata: PackageMetadata, + packageChain: ReadonlySet, +): CandidateResolution => { + if (metadata.kind === "absent") return ( - resolveFileCandidates(input, indexCandidates(candidate)) ?? + resolveFileCandidates(input, indexCandidates(directory)) ?? notFoundCandidate() ); + if (metadata.kind === "unavailable") + return unresolvedCandidate("unavailable", [metadata.limitation]); + if (packageChain.has(metadata.path)) + return unresolvedCandidate("unavailable", [ + `Directory package entrypoint cycle includes ${metadata.path}.`, + ]); + const preferred = [ + Reflect.get(metadata.value, "main"), + ...(input.moduleKind === undefined + ? [Reflect.get(metadata.value, "module")] + : []), + ]; + const entry = preferred.find((value) => value !== undefined); + if (entry === undefined) + return ( + resolveFileCandidates(input, indexCandidates(directory)) ?? + notFoundCandidate() + ); + if (typeof entry !== "string" || entry.length === 0) + return unresolvedCandidate("unavailable", [ + `Directory package metadata ${metadata.path} is not valid package JSON.`, + ]); const entryInput: ResolveArtifactPathInput = { - declaredPath: main.value, - sourcePath: packagePath, + declaredPath: entry, + sourcePath: metadata.path, context: "package-entrypoint", files: input.files, + ...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }), }; - const chain = new Set([...packageChain, packagePath]); - if (main.source === "legacy") { - const entry = resolvePackagePath(entryInput, chain, "files-and-index"); - if (entry.resolution_status !== "not-found") return candidateOutcome(entry); - const index = resolveFileCandidates(input, indexCandidates(candidate)); - if (index !== null) return index; - } - // Preserve recursive artifact package lookup only after legacy file/index fallbacks. - return candidateOutcome( - resolvePackagePath(entryInput, chain, "full", main.source === "exports"), + const rejected = rejectDeclaration(entryInput.declaredPath); + if (rejected !== null) return rejected; + const candidate = contextualCandidate(entryInput); + if (typeof candidate !== "string") return candidate; + const confined = confineCandidate(candidate); + if (typeof confined !== "string") return confined; + const selected = + resolveFileCandidates(entryInput, [ + ...fileCandidates(confined), + ...indexCandidates(confined), + ]) ?? resolveFileCandidates(input, indexCandidates(directory)); + if (selected !== null) return selected; + return resolveCandidate( + entryInput, + confined, + new Set([...packageChain, metadata.path]), ); }; -const candidateOutcome = ( - nested: ArtifactPathResolution, -): CandidateResolution => - nested.resolution_status === "resolved" - ? { - resolvedPath: nested.resolved_path, - status: nested.resolution_status, - limitations: nested.limitations, - } - : { - resolvedPath: null, - status: nested.resolution_status, - limitations: nested.limitations, - }; - const resolveFileCandidates = ( input: ResolveArtifactPathInput, candidates: readonly string[], @@ -468,84 +545,62 @@ const fileCandidates = (candidate: string): readonly string[] => [ const indexCandidates = (candidate: string): readonly string[] => EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`)); -const packageEntry = ( - text: string, - moduleKind: ResolveArtifactPathInput["moduleKind"], - useExports: boolean, -): - | { - readonly status: "value"; - readonly value: string; - readonly source: "legacy" | "exports"; - } - | { readonly status: "missing" } - | { readonly status: "invalid" } +type PackageExportOutcome = + | { readonly status: "value"; readonly value: string } | { readonly status: "rejected"; readonly limitation: string } - | { readonly status: "unmatched"; readonly declared: readonly string[] } => { - try { - const value: unknown = JSON.parse(text); - if (typeof value !== "object" || value === null) - return { status: "invalid" }; - const rawExports = Reflect.get(value, "exports"); - if (useExports && rawExports !== undefined && rawExports !== null) { - const exported = packageExport(rawExports, moduleKind); - return exported.status === "value" - ? { ...exported, source: "exports" } - : exported; - } - // Node imports and requires ignore the bundler-only module field. Keep - // that fallback for callers without an explicit module-loading kind. - const preferred = [ - Reflect.get(value, "main"), - ...(moduleKind === undefined ? [Reflect.get(value, "module")] : []), - ]; - const entry = preferred.find((candidate) => candidate !== undefined); - if (entry === undefined) return { status: "missing" }; - const legacy = packagePathValue(entry); - return legacy.status === "value" ? { ...legacy, source: "legacy" } : legacy; - } catch (cause: unknown) { - // Reflect-based manifest reads fail closed as invalid. - void cause; - return { status: "invalid" }; - } -}; + | { readonly status: "blocked" } + | { readonly status: "unmatched"; readonly declared: readonly string[] }; -const packageExport = ( +/** Only exact non-directory keys participate; wildcard expansion is unsupported. */ +const selectPackageExport = ( value: unknown, + subpath: string, moduleKind: ResolveArtifactPathInput["moduleKind"], ): PackageExportOutcome => { + let target = value; + let declared: string[] = []; if (typeof value === "object" && value !== null && !Array.isArray(value)) { const keys = Object.keys(value); - if ( - keys.some((key) => key.startsWith(".")) && - keys.some((key) => !key.startsWith(".")) - ) + const hasSubpaths = keys.some((key) => key.startsWith(".")); + if (hasSubpaths && keys.some((key) => !key.startsWith("."))) return { status: "rejected", limitation: "The package exports configuration mixes subpath keys and condition keys.", }; - } - const root = - typeof value === "object" && value !== null && Object.hasOwn(value, ".") - ? Reflect.get(value, ".") - : value; - const flattened = exportTargets(root, packageExportConditions(moduleKind)); - if (flattened.kind === "invalid" || flattened.kind === "invalid-config") - return { status: "rejected", limitation: flattened.limitation }; - const first = - flattened.kind === "unmatched" ? undefined : flattened.values[0]; - return first === undefined - ? { - status: "unmatched", - declared: - typeof root === "object" && root !== null ? Object.keys(root) : [], - } - : { status: "value", value: first }; + declared = keys; + if (hasSubpaths) { + if ( + subpath.endsWith("/") || + subpath.includes("*") || + !Object.hasOwn(value, subpath) + ) + return { status: "unmatched", declared }; + target = Reflect.get(value, subpath); + } else if (subpath !== ".") return { status: "unmatched", declared }; + } else if (subpath !== ".") return { status: "unmatched", declared }; + const selected = selectExportTarget( + target, + packageExportConditions(moduleKind), + ); + if (selected.kind === "invalid" || selected.kind === "invalid-config") + return { status: "rejected", limitation: selected.limitation }; + if (selected.kind === "unmatched") + return { + status: "unmatched", + declared: + typeof target === "object" && target !== null + ? Object.keys(target) + : declared, + }; + return selected.kind === "blocked" + ? { status: "blocked" } + : { status: "value", value: selected.value }; }; -type ExportTargets = - | { readonly kind: "targets"; readonly values: readonly string[] } +type ExportTargetOutcome = + | { readonly kind: "value"; readonly value: string } + | { readonly kind: "blocked" } | { readonly kind: "unmatched" } | { readonly kind: "invalid"; readonly limitation: string } | { readonly kind: "invalid-config"; readonly limitation: string }; @@ -553,7 +608,7 @@ type ExportTargets = const invalidExportTarget = ( value: unknown, reason: string, -): ExportTargets => ({ +): ExportTargetOutcome => ({ kind: "invalid", limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`, }); @@ -581,26 +636,26 @@ const forbiddenExportSegment = (value: string): string | undefined => * An array skips unmatched and invalid entries, stops at its first string, and * preserves the final invalid-versus-null refusal if no string is selected. */ -const exportTargets = ( +const selectExportTarget = ( value: unknown, conditions: ReadonlySet, -): ExportTargets => { +): ExportTargetOutcome => { if (typeof value === "string") { if (!value.startsWith("./")) return invalidExportTarget(value, 'must start with "./"'); const forbidden = forbiddenExportSegment(value); return forbidden === undefined - ? { kind: "targets", values: [value] } + ? { kind: "value", value } : invalidExportTarget( value, `contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`, ); } - if (value === null) return { kind: "targets", values: [] }; + if (value === null) return { kind: "blocked" }; if (Array.isArray(value)) { let invalid: string | null = null; for (const entry of value) { - const nested = exportTargets(entry, conditions); + const nested = selectExportTarget(entry, conditions); // Node's array fallback catches invalid targets, not invalid configuration. if (nested.kind === "invalid-config") return nested; if (nested.kind === "invalid") { @@ -609,10 +664,10 @@ const exportTargets = ( } if (nested.kind === "unmatched") continue; invalid = null; - if (nested.values.length > 0) return nested; + if (nested.kind === "value") return nested; } return invalid === null - ? { kind: "targets", values: [] } + ? { kind: "blocked" } : { kind: "invalid", limitation: invalid }; } if (typeof value !== "object") @@ -630,7 +685,7 @@ const exportTargets = ( } for (const [condition, target] of Object.entries(value)) { if (!conditions.has(condition)) continue; - const nested = exportTargets(target, conditions); + const nested = selectExportTarget(target, conditions); if (nested.kind !== "unmatched") return nested; } return { kind: "unmatched" }; @@ -654,20 +709,6 @@ const packageExportConditions = ( "default", ]); -type PackageExportOutcome = - | { readonly status: "value"; readonly value: string } - | { readonly status: "rejected"; readonly limitation: string } - | { readonly status: "unmatched"; readonly declared: readonly string[] }; - -const packagePathValue = ( - value: unknown, -): - | { readonly status: "value"; readonly value: string } - | { readonly status: "invalid" } => - typeof value === "string" && value.length > 0 - ? { status: "value", value } - : { status: "invalid" }; - const notFoundCandidate = (): CandidateResolution => ({ resolvedPath: null, status: "not-found", @@ -697,29 +738,7 @@ const fileUrlPath = (value: string): string | undefined => { } }; -const outcome = ( - input: ResolveArtifactPathInput, - resolution: CandidateResolution, -): ArtifactPathResolution => - resolution.status === "resolved" - ? { - declared_path: input.declaredPath, - resolution_context: input.context, - resolved_path: resolution.resolvedPath, - resolution_status: resolution.status, - limitations: resolution.limitations, - } - : { - declared_path: input.declaredPath, - resolution_context: input.context, - resolved_path: null, - resolution_status: resolution.status, - limitations: resolution.limitations, - }; - -const unresolvedOutcome = ( - input: ResolveArtifactPathInput, +const unresolvedCandidate = ( status: UnresolvedArtifactPathStatus, limitations: readonly string[], -): ArtifactPathResolution => - outcome(input, { resolvedPath: null, status, limitations }); +): CandidateResolution => ({ resolvedPath: null, status, limitations }); diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts new file mode 100644 index 0000000000..2cbd4ff143 --- /dev/null +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "resolves package subpaths with source provenance through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-package-subpaths-"); + const source = [ + 'const nearest = require("fixture/parser");', + 'const nested = require("nested/parser");', + 'const parser = require("exported/parser");', + 'const directory = require("exported/parser/");', + 'const hidden = require("exported/hidden");', + 'const missing = require("exported/missing");', + 'new BrowserWindow({webPreferences:{preload:"exported/parser"}});', + ].join("\n"); + const files = { + "src/main.cjs": source, + "src/node_modules/fixture/parser.js": "module.exports = 1;", + "node_modules/fixture/package.json": "{}", + "node_modules/fixture/parser.js": "module.exports = 2;", + "node_modules/nested/package.json": "{}", + "node_modules/nested/parser/package.json": JSON.stringify({ + main: "./entry.js", + exports: "./decoy.js", + }), + "node_modules/nested/parser/entry.js": "module.exports = 3;", + "node_modules/nested/parser/index.js": "module.exports = 4;", + "node_modules/nested/parser/decoy.js": "module.exports = 5;", + "node_modules/exported/package.json": JSON.stringify({ + exports: { + "./parser": "./parser.cjs", + "./parser/": "./parser.cjs", + "./missing": "./missing.cjs", + }, + }), + "node_modules/exported/parser.cjs": "module.exports = 6;", + "node_modules/exported/hidden.js": "module.exports = 7;", + "node_modules/exported/missing/index.js": "module.exports = 8;", + }; + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content); + }), + ); + + const nativeRequire = createRequire(join(root, "src/main.cjs")); + const expectedTargets = { + "fixture/parser": "src/node_modules/fixture/parser.js", + "nested/parser": "node_modules/nested/parser/entry.js", + "exported/parser": "node_modules/exported/parser.cjs", + }; + for (const [specifier, target] of Object.entries(expectedTargets)) + expect( + relative(root, nativeRequire.resolve(specifier)).replaceAll("\\", "/"), + ).toBe(target); + for (const specifier of ["exported/parser/", "exported/hidden"]) + expect(() => nativeRequire.resolve(specifier)).toThrow( + expect.objectContaining({ code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }), + ); + expect(() => nativeRequire.resolve("exported/missing")).toThrow( + expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + ); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "directory", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + const imports = graph.edges.filter( + ({ properties }) => properties.module_link_kind === "require", + ); + const sourceDigest = createHash("sha256").update(source).digest("hex"); + for (const [specifier, target] of Object.entries(expectedTargets)) + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier, + resolved_path: target, + resolution_status: "resolved", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ + available: true, + sha256: sourceDigest, + }), + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: "src/main.cjs", + }), + }), + }), + }), + ); + for (const [specifier, status] of [ + ["exported/parser/", "external"], + ["exported/hidden", "external"], + ["exported/missing", "not-found"], + ]) + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier, + resolved_path: null, + resolution_status: status, + }), + }), + ); + const preloads = graph.nodes.filter( + ({ kind }) => kind === "electron-preload", + ); + expect(preloads).toHaveLength(1); + expect(preloads[0]?.identity).toMatchObject({ + key: "electron-preload:module-specifier:node_modules/exported/parser.cjs", + }); + for (const observation of preloads[0]?.observations ?? []) + expect(observation).toMatchObject({ + properties: { + declared_path: "exported/parser", + resolution_context: "module-specifier", + resolved_path: "node_modules/exported/parser.cjs", + }, + evidence: { + location: { + available: true, + value: { source: "src/main.cjs", start: { line: 7 } }, + }, + }, + }); + }, +);