diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index f40d3113a..82645590a 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -9,7 +9,10 @@ provenance; inputs and recovered code are never executed. Recovery requires a caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md). Use `analyze_javascript_application` directly on the operator-supplied ASAR or -extracted tree. When size is unknown or large and the connected schema advertises +extracted tree. Exact `#alias` specifiers resolve through the importer's +enclosing package `imports` map to package-local files; nested `node_modules` +packages use their own map. Wildcard, recursive, and external-package import +targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises `detail`, request `detail: "summary"`. This returns an `inspect_analysis_view` Evidence record with a distinct ID and a `normalized_result.parent_evidence_id` for the complete retained application analysis. Source-map contents are part of diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 7442fc746..fbafe042e 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -452,9 +452,29 @@ such as `./web3:app.js` still names a local artifact. Package `exports` fallback arrays are supported both at the top level and under the root `"."` entry. The resolver selects targets in declared order using the same conditional and invalid-entry handling as nested exports arrays. +Nonempty arrays containing only inactive conditions remain unmatched, allowing +the enclosing condition object to continue to a later active condition. +Empty arrays and explicit null targets keep their blocking outcome. +Refusal evidence distinguishes those source values and retains condition names +from unmatched nested targets rather than reporting array indices. For imports and requires, the active Node conditions include `module-sync`; earlier active conditions retain precedence over later ones. +Exact package-private `#alias` specifiers resolve against the importer's +enclosing `package.json` `imports` map — the nearest same-container package +scope, stopping at a `node_modules` directory so a nested package cannot inherit +an ancestor's aliases. Selected targets are package-local `./` files with no +extension or index fallback, using the same condition, array, and null selection +as exports subpaths. A missing key, explicit null, or out-of-scope alias keeps +an unresolved edge with that reason. Wildcard keys (`#internal/*`), recursive +`#` targets, and external-package import targets remain unresolved. + +Exact keys such as `#/x` are literal lookup identities. `#` and names ending +in `/` are invalid. Imports arrays skip invalid parent-relative, absolute, and +URL targets before selecting a later package-local file; an unsupported bare +package or recursive alias keeps its unresolved outcome rather than selecting +a later fallback. + ESM relative module paths and selected package exports targets use URL suffix handling and one percent-decoding pass. CommonJS relative paths and legacy package main fields retain literal filename punctuation. Exports targets must start with diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 9a79f84d1..491d5236d 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -868,6 +868,365 @@ describe("package exports exact targets", () => { }); }); +describe("package import alias resolution", () => { + it.each(["import", "require"] as const)( + "resolves an exact #%s alias from the enclosing package imports map", + (moduleKind) => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./config.cjs" }, + }), + "main.js": "", + "config.cjs": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind, + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + declared_path: "#config", + }); + }, + ); + + it("resolves an alias from a nested file through the enclosing package root", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./config.cjs" }, + }), + "src/main.js": "", + "config.cjs": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "src/main.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + }); + }); + + it("uses a nested package imports map rather than the application root", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./root.cjs" }, + }), + "root.cjs": "", + "node_modules/fixture/package.json": JSON.stringify({ + imports: { "#config": "./local.cjs" }, + }), + "node_modules/fixture/index.js": "", + "node_modules/fixture/local.cjs": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "node_modules/fixture/index.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "node_modules/fixture/local.cjs", + }); + }); + + it("does not inherit an ancestor imports map across a nested package.json", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./root.cjs" }, + }), + "root.cjs": "", + "node_modules/fixture/package.json": "{}", + "node_modules/fixture/index.js": "", + }); + const outcome = resolve({ + declaredPath: "#config", + sourcePath: "node_modules/fixture/index.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain( + "node_modules/fixture/package.json", + ); + expect(outcome.limitations.join(" ")).not.toContain("root.cjs"); + }); + + it("does not apply a nested package map to the application root importer", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./root.cjs" }, + }), + "main.js": "", + "root.cjs": "", + "node_modules/fixture/package.json": JSON.stringify({ + imports: { "#config": "./local.cjs" }, + }), + "node_modules/fixture/local.cjs": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "root.cjs", + }); + }); +}); + +describe("package import alias target selection", () => { + it("selects import and require conditions from an imports target", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { + "#config": { + import: "./config.mjs", + require: "./config.cjs", + default: "./fallback.js", + }, + }, + }), + "main.js": "", + "config.mjs": "", + "config.cjs": "", + "fallback.js": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }).resolved_path, + ).toBe("config.mjs"); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }).resolved_path, + ).toBe("config.cjs"); + }); + + it("resolves array import targets at their first string entry", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": [{ import: "./missing.mjs" }, "./config.cjs"] }, + }), + "main.js": "", + "config.cjs": "", + }); + expect( + resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + }); + }); + + it("does not add an extension or index to a selected import target", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./config" }, + }), + "main.js": "", + "config.cjs": "", + "config/index.js": "", + }); + const outcome = resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain( + "import targets are exact files", + ); + }); +}); + +describe("package import alias refusals", () => { + it("reports a missing exact alias without looking in another package", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./config.cjs" }, + }), + "main.js": "", + "config.cjs": "", + }); + const outcome = resolve({ + declaredPath: "#other", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain("#other"); + expect(outcome.limitations.join(" ")).toContain("#config"); + }); + + it("reports an explicit null import target as blocked", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": null }, + }), + "main.js": "", + "config.cjs": "", + }); + const outcome = resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain("explicit null"); + }); + + it("leaves wildcard import aliases unresolved", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#internal/*": "./src/*.js" }, + }), + "main.js": "", + "src/util.js": "", + }); + const outcome = resolve({ + declaredPath: "#internal/util", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toMatch(/wildcard/i); + }); + + it("leaves recursive and external-package import targets unresolved", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#loop": "#config", "#dep": "fixture" }, + }), + "main.js": "", + "node_modules/fixture/index.js": "", + }); + const recursive = resolve({ + declaredPath: "#loop", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + const external = resolve({ + declaredPath: "#dep", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(recursive).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(recursive.limitations.join(" ")).toMatch(/recursive/i); + expect(external).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(external.limitations.join(" ")).toMatch(/external-package/i); + }); + + it("rejects reserved hash specifiers", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#config": "./config.cjs" }, + }), + "main.js": "", + "config.cjs": "", + }); + expect( + resolve({ + declaredPath: "#", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }); + + it("does not use a foreign-container enclosing package.json", () => { + const files = fileMap([ + file( + "package.json", + "other", + JSON.stringify({ imports: { "#config": "./config.cjs" } }), + ), + file("main.js", "root"), + file("config.cjs", "root"), + ]); + const outcome = resolve({ + declaredPath: "#config", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files, + }); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain("package scopes"); + }); +}); + describe("directory package entrypoint precedence", () => { it.each([ ["require", '{"main":"actual.cjs"}', "actual.cjs"], @@ -1211,6 +1570,15 @@ const file = ( : { included: true, value: text }, }); +const aliasFiles = ( + entries: Readonly>, +): ReturnType => + fileMap( + Object.entries(entries).map(([path, text]) => + file(path, "root", text ?? ""), + ), + ); + describe("exact HTML resource resolution", () => { it.each([ ["extension", ["app.js"]], diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 73d82e91b..b6bbde2b6 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -14,6 +14,11 @@ import { percentDecodeUrlPath, stripQueryAndFragment, } from "../../domain/artifactPathSyntax.js"; +import { + classifyPackageImportSpecifier, + classifyPackageImportTargetString, + lookupExactPackageImport, +} from "../../domain/javascript/javascriptPackageImportAliases.js"; type ArtifactPathResolutionContext = | "package-entrypoint" @@ -103,6 +108,13 @@ export const resolveArtifactPathByContext = ( }; const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { + // Package import keys are literal lookup identities, not artifact paths. + // The selected target is admitted separately by resolveExportTarget. + if ( + input.context === "module-specifier" && + input.declaredPath.startsWith("#") + ) + return resolvePackageImportAlias(input, input.declaredPath); const rejected = rejectDeclaration(input.declaredPath); if (rejected !== null) return rejected; const candidate = contextualCandidate(input); @@ -202,9 +214,9 @@ const bareModuleCandidate = ( declared: string, ): CandidateResolution => { const packageName = barePackageName(declared); - if (packageName === null || isBuiltin(declared) || declared.startsWith("#")) + if (packageName === null || isBuiltin(declared)) return unresolvedCandidate("external", [ - "The bare specifier is a Node builtin, package import map, or invalid package name.", + "The bare specifier is a Node builtin or invalid package name.", ]); if (input.moduleKind === "import" && packageName.includes("%")) return unresolvedCandidate("rejected", [ @@ -235,6 +247,115 @@ const bareModuleCandidate = ( ]); }; +/** + * Nearest same-container package.json, stopping at a `node_modules` directory + * so a nested package cannot inherit an ancestor's map. + */ +const enclosingPackageMetadata = ( + input: ResolveArtifactPathInput, +): + | Extract + | { readonly kind: "none" } => { + let directory = posix.dirname(input.sourcePath); + while (posix.basename(directory) !== "node_modules") { + const metadata = readPackageMetadata(input, directory); + if (metadata.kind !== "absent") return metadata; + if (directory === "." || directory === "") break; + directory = posix.dirname(directory); + } + return { kind: "none" }; +}; + +/** + * Resolve an exact `#alias` against the importer's enclosing `imports` map. + * Selected relative targets are exact files; patterns, recursive aliases, and + * external-package targets stay unresolved without falling through. + */ +const resolvePackageImportAlias = ( + input: ResolveArtifactPathInput, + specifier: string, +): CandidateResolution => { + const classified = classifyPackageImportSpecifier(specifier); + if (classified.kind === "invalid") + return unresolvedCandidate("rejected", [ + `The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`, + ]); + if (classified.kind === "pattern") + return unresolvedCandidate("external", [ + `Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`, + ]); + const metadata = enclosingPackageMetadata(input); + if (metadata.kind === "none") + return unresolvedCandidate("external", [ + `No enclosing package.json defines package import alias ${specifier}; package import maps do not cross package scopes.`, + ]); + if (metadata.kind === "unavailable") + return unresolvedCandidate("unavailable", [metadata.limitation]); + const lookup = lookupExactPackageImport(metadata.imports, specifier); + if (lookup.kind === "undefined-map") + return unresolvedCandidate("external", [ + `Package metadata ${metadata.path} does not define package import alias ${specifier}.`, + ]); + if (lookup.kind === "missing-key") + return unresolvedCandidate("external", [ + `Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`, + ]); + if (lookup.kind === "pattern-unresolved") + return unresolvedCandidate("external", [ + `Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`, + ]); + const selected = selectPackageImportTarget(lookup.target, input.moduleKind); + if (selected.status === "rejected") + return unresolvedCandidate("rejected", [ + `Package metadata ${metadata.path}: ${selected.limitation}`, + ]); + if (selected.status === "unmatched") + return unresolvedCandidate("external", [ + `Package metadata ${metadata.path} declares no imports target for alias ${specifier} under the active conditions; it declares ${selected.declared.join(", ") || "no conditions"}.`, + ]); + if (selected.status === "blocked") + return unresolvedCandidate("external", [ + `Package metadata ${metadata.path} blocks import alias ${specifier} with ${selected.cause === "null" ? "an explicit null imports target" : "an empty imports target array"}.`, + ]); + if (selected.status === "non-relative") { + const target = classifyPackageImportTargetString(selected.value); + return unresolvedCandidate("external", [ + target.kind === "recursive" + ? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.` + : `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`, + ]); + } + return resolveExportTarget(input, metadata.path, selected.value, "imports"); +}; + +const selectPackageImportTarget = ( + value: unknown, + moduleKind: ResolveArtifactPathInput["moduleKind"], +): + | { readonly status: "value"; readonly value: string } + | { readonly status: "non-relative"; readonly value: string } + | { readonly status: "blocked"; readonly cause: PackageTargetBlockCause } + | { readonly status: "unmatched"; readonly declared: readonly string[] } + | { readonly status: "rejected"; readonly limitation: string } => { + const selected = selectExportTarget( + value, + packageExportConditions(moduleKind), + "imports", + ); + if (selected.kind === "invalid" || selected.kind === "invalid-config") + return { status: "rejected", limitation: selected.limitation }; + if (selected.kind === "unmatched") + return { + status: "unmatched", + declared: selected.declared, + }; + if (selected.kind === "blocked") + return { status: "blocked", cause: selected.cause }; + if (selected.kind === "non-relative") + return { status: "non-relative", value: selected.value }; + return { status: "value", value: selected.value }; +}; + /** Self references use the nearest package scope before dependency lookup. */ const commonJsSelfCandidate = ( input: ResolveArtifactPathInput, @@ -242,35 +363,22 @@ const commonJsSelfCandidate = ( ): CandidateResolution | null => { if (input.moduleKind !== "require" || !input.files.has(input.sourcePath)) return null; - let directory = posix.dirname(input.sourcePath); - while (posix.basename(directory) !== "node_modules") { - const metadata = readPackageMetadata(input, directory); - if (metadata.kind === "unavailable") - return unresolvedCandidate("unavailable", [metadata.limitation]); - if (metadata.kind === "available") { - if ( - metadata.name === undefined || - metadata.exports === undefined || - (declared !== metadata.name && - !declared.startsWith(`${metadata.name}/`)) - ) - return null; - const subpath = - declared === metadata.name - ? "." - : `.${declared.slice(metadata.name.length)}`; - // Self resolution does not apply the CommonJS dependency-name matcher. - return resolvePackageExports( - input, - metadata.path, - subpath, - metadata.exports, - ); - } - if (directory === "." || directory === "") break; - directory = posix.dirname(directory); - } - return null; + const metadata = enclosingPackageMetadata(input); + if (metadata.kind === "none") return null; + if (metadata.kind === "unavailable") + return unresolvedCandidate("unavailable", [metadata.limitation]); + if ( + metadata.name === undefined || + metadata.exports === undefined || + (declared !== metadata.name && !declared.startsWith(`${metadata.name}/`)) + ) + return null; + const subpath = + declared === metadata.name + ? "." + : `.${declared.slice(metadata.name.length)}`; + // Self resolution does not apply the CommonJS dependency-name matcher. + return resolvePackageExports(input, metadata.path, subpath, metadata.exports); }; /** Package roots and exact subpaths share metadata and exports selection. */ @@ -324,16 +432,17 @@ const resolvePackageExports = ( ]); if (exported.status === "blocked") return unresolvedCandidate("external", [ - `Directory package metadata ${packagePath} blocks subpath ${subpath} with an explicit null exports target.`, + `Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`, ]); return resolveExportTarget(input, packagePath, exported.value); }; -/** An exports target is a URL naming one exact file for both Node loaders. */ +/** An exports or import target is a URL naming one exact file for both Node loaders. */ const resolveExportTarget = ( input: ResolveArtifactPathInput, packagePath: string, target: string, + mapKind: "exports" | "imports" = "exports", ): CandidateResolution => { const rejected = rejectDeclaration(target); if (rejected !== null) return rejected; @@ -343,7 +452,7 @@ const resolveExportTarget = ( if (typeof candidate !== "string") return candidate; return ( resolveFileCandidates(input, [candidate]) ?? - exactExportsTargetNotFound(candidate) + exactPackageTargetNotFound(candidate, mapKind) ); }; @@ -492,6 +601,7 @@ type PackageMetadata = readonly path: string; readonly name: string | undefined; readonly exports: string | object | undefined; + readonly imports: object | undefined; readonly main: unknown; readonly module: unknown; } @@ -532,6 +642,7 @@ const readPackageMetadata = ( }; const name = Reflect.get(value, "name"); const exports = Reflect.get(value, "exports"); + const imports = Reflect.get(value, "imports"); return { kind: "available", path, @@ -543,6 +654,11 @@ const readPackageMetadata = ( (typeof exports === "object" && exports !== null) ? exports : undefined, + // Import maps must be non-array objects; other shapes are not a map. + imports: + typeof imports === "object" && imports !== null && !Array.isArray(imports) + ? imports + : undefined, main: Reflect.get(value, "main"), module: Reflect.get(value, "module"), }; @@ -644,10 +760,12 @@ const fileCandidates = (candidate: string): readonly string[] => [ const indexCandidates = (candidate: string): readonly string[] => EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`)); +type PackageTargetBlockCause = "null" | "empty-array"; + type PackageExportOutcome = | { readonly status: "value"; readonly value: string } | { readonly status: "rejected"; readonly limitation: string } - | { readonly status: "blocked" } + | { readonly status: "blocked"; readonly cause: PackageTargetBlockCause } | { readonly status: "unmatched"; readonly declared: readonly string[] }; /** Only exact non-directory keys participate; wildcard expansion is unsupported. */ @@ -687,29 +805,33 @@ const selectPackageExport = ( if (selected.kind === "unmatched") return { status: "unmatched", - declared: - typeof target === "object" && target !== null - ? Object.keys(target) - : declared, + declared: selected.declared, }; - return selected.kind === "blocked" - ? { status: "blocked" } - : { status: "value", value: selected.value }; + if (selected.kind === "blocked") + return { status: "blocked", cause: selected.cause }; + if (selected.kind === "non-relative") + return { + status: "rejected", + limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`, + }; + return { status: "value", value: selected.value }; }; type ExportTargetOutcome = | { readonly kind: "value"; readonly value: string } - | { readonly kind: "blocked" } - | { readonly kind: "unmatched" } + | { readonly kind: "non-relative"; readonly value: string } + | { readonly kind: "blocked"; readonly cause: PackageTargetBlockCause } + | { readonly kind: "unmatched"; readonly declared: readonly string[] } | { readonly kind: "invalid"; readonly limitation: string } | { readonly kind: "invalid-config"; readonly limitation: string }; const invalidExportTarget = ( value: unknown, reason: string, + mapKind: "exports" | "imports" = "exports", ): ExportTargetOutcome => ({ kind: "invalid", - limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`, + limitation: `The package ${mapKind === "imports" ? "import" : "exports"} target ${JSON.stringify(value) ?? String(value)} ${reason}.`, }); // Node checks raw target segments before URL normalization. Compare encoded @@ -729,50 +851,80 @@ const forbiddenExportSegment = (value: string): string | undefined => ); }); +const selectStringPackageTarget = ( + value: string, + mapKind: "exports" | "imports", +): ExportTargetOutcome => { + if (mapKind === "imports") { + const target = classifyPackageImportTargetString(value); + if (target.kind === "invalid") + return invalidExportTarget( + value, + "cannot be parent-relative, absolute, or a URL", + mapKind, + ); + if (target.kind !== "relative") return { kind: "non-relative", value }; + } else if (!value.startsWith("./")) + return invalidExportTarget(value, 'must start with "./"', mapKind); + const forbidden = forbiddenExportSegment(value); + return forbidden === undefined + ? { kind: "value", value } + : invalidExportTarget( + value, + `contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`, + mapKind, + ); +}; + +const selectArrayPackageTarget = ( + value: readonly unknown[], + conditions: ReadonlySet, + mapKind: "exports" | "imports", +): ExportTargetOutcome => { + if (value.length === 0) return { kind: "blocked", cause: "empty-array" }; + let lastRefusal: + | Extract + | undefined; + const declared = new Set(); + for (const entry of value) { + const nested = selectExportTarget(entry, conditions, mapKind); + // Node's array fallback catches invalid targets, not invalid configuration. + if (nested.kind === "invalid-config") return nested; + if (nested.kind === "unmatched") { + for (const condition of nested.declared) declared.add(condition); + continue; + } + if (nested.kind === "invalid" || nested.kind === "blocked") { + lastRefusal = nested; + continue; + } + return nested; + } + return lastRefusal ?? { kind: "unmatched", declared: [...declared] }; +}; + /** * Resolve a target in Node's declared order. Explicit null blocks an active * condition, whereas an unmatched nested object permits the next condition. * 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. + * preserves the last invalid/null outcome if no string is selected. A nonempty + * array of unmatched conditions stays unmatched; an empty array blocks. */ const selectExportTarget = ( value: unknown, conditions: ReadonlySet, + mapKind: "exports" | "imports" = "exports", ): ExportTargetOutcome => { - if (typeof value === "string") { - if (!value.startsWith("./")) - return invalidExportTarget(value, 'must start with "./"'); - const forbidden = forbiddenExportSegment(value); - return forbidden === undefined - ? { kind: "value", value } - : invalidExportTarget( - value, - `contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`, - ); - } - if (value === null) return { kind: "blocked" }; - if (Array.isArray(value)) { - let invalid: string | null = null; - for (const entry of value) { - 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") { - invalid = nested.limitation; - continue; - } - if (nested.kind === "unmatched") continue; - invalid = null; - if (nested.kind === "value") return nested; - } - return invalid === null - ? { kind: "blocked" } - : { kind: "invalid", limitation: invalid }; - } + if (typeof value === "string") + return selectStringPackageTarget(value, mapKind); + if (value === null) return { kind: "blocked", cause: "null" }; + if (Array.isArray(value)) + return selectArrayPackageTarget(value, conditions, mapKind); if (typeof value !== "object") return invalidExportTarget( value, "must be a relative string, object, array, or null", + mapKind, ); for (const key of Object.keys(value)) { const numeric = Number(key); @@ -782,12 +934,15 @@ const selectExportTarget = ( limitation: `The package exports configuration contains numeric condition key ${JSON.stringify(key)}.`, }; } + const declared = new Set(); for (const [condition, target] of Object.entries(value)) { + declared.add(condition); if (!conditions.has(condition)) continue; - const nested = selectExportTarget(target, conditions); + const nested = selectExportTarget(target, conditions, mapKind); if (nested.kind !== "unmatched") return nested; + for (const name of nested.declared) declared.add(name); } - return { kind: "unmatched" }; + return { kind: "unmatched", declared: [...declared] }; }; /** @@ -816,11 +971,14 @@ const notFoundCandidate = (): CandidateResolution => ({ ], }); -const exactExportsTargetNotFound = (path: string): CandidateResolution => ({ +const exactPackageTargetNotFound = ( + path: string, + mapKind: "exports" | "imports", +): CandidateResolution => ({ resolvedPath: null, status: "not-found", limitations: [ - `The selected package exports target ${path} does not exist in the inventoried artifact container; exports targets are exact files, so no extension or directory index was tried.`, + `The selected package ${mapKind === "imports" ? "import" : "exports"} target ${path} does not exist in the inventoried artifact container; ${mapKind === "imports" ? "import" : "exports"} targets are exact files, so no extension or directory index was tried.`, ], }); diff --git a/src/domain/javascript/javascriptPackageImportAliases.test.ts b/src/domain/javascript/javascriptPackageImportAliases.test.ts new file mode 100644 index 000000000..6f64c1d65 --- /dev/null +++ b/src/domain/javascript/javascriptPackageImportAliases.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyPackageImportSpecifier, + classifyPackageImportTargetString, + lookupExactPackageImport, +} from "./javascriptPackageImportAliases.js"; + +describe("package import specifier classification", () => { + it("treats an exact hash alias as an exact key", () => { + expect(classifyPackageImportSpecifier("#config")).toEqual({ + kind: "exact", + key: "#config", + }); + }); + + it.each(["#internal/env", "#/hidden"])( + "treats %j as an exact key", + (specifier) => { + expect(classifyPackageImportSpecifier(specifier)).toEqual({ + kind: "exact", + key: specifier, + }); + }, + ); + + it.each(["#", "#/", "#utils/"])( + "rejects reserved specifier %j", + (specifier) => { + expect(classifyPackageImportSpecifier(specifier)).toEqual({ + kind: "invalid", + }); + }, + ); + + it.each(["#internal/*", "#*"])( + "leaves pattern specifier %j unresolved", + (specifier) => { + expect(classifyPackageImportSpecifier(specifier)).toEqual({ + kind: "pattern", + }); + }, + ); +}); + +describe("exact package import lookup", () => { + it("returns the exact target without falling through to another key", () => { + expect( + lookupExactPackageImport( + { "#config": "./config.cjs", "#other": "./other.cjs" }, + "#config", + ), + ).toEqual({ + kind: "found", + target: "./config.cjs", + declared: ["#config", "#other"], + }); + }); + + it("names declared exact keys when the requested alias is absent", () => { + expect( + lookupExactPackageImport({ "#config": "./config.cjs" }, "#missing"), + ).toEqual({ + kind: "missing-key", + declared: ["#config"], + }); + }); + + it("does not claim a miss when the map only has pattern keys", () => { + expect( + lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/a"), + ).toEqual({ + kind: "pattern-unresolved", + declared: ["#internal/*"], + }); + }); + + it("still selects an exact key when the same map also has patterns", () => { + expect( + lookupExactPackageImport( + { "#config": "./config.cjs", "#internal/*": "./src/*.js" }, + "#config", + ), + ).toEqual({ + kind: "found", + target: "./config.cjs", + declared: ["#config", "#internal/*"], + }); + }); + + it.each([undefined, null, "./config.cjs", ["#config"], 7] as const)( + "treats non-object imports value %j as an undefined map", + (imports) => { + expect(lookupExactPackageImport(imports, "#config")).toEqual({ + kind: "undefined-map", + }); + }, + ); + + it("does not treat a pattern key as an exact match for itself", () => { + expect( + lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/*"), + ).toEqual({ + kind: "pattern-unresolved", + declared: ["#internal/*"], + }); + }); +}); + +describe("package import target string classification", () => { + it("keeps package-local relative targets", () => { + expect(classifyPackageImportTargetString("./config.cjs")).toEqual({ + kind: "relative", + target: "./config.cjs", + }); + }); + + it("marks recursive hash aliases as recursive", () => { + expect(classifyPackageImportTargetString("#other")).toEqual({ + kind: "recursive", + target: "#other", + }); + }); + + it.each(["lodash", "pkg/subpath"])( + "marks %j as an external-package target", + (target) => { + expect(classifyPackageImportTargetString(target)).toEqual({ + kind: "external-package", + target, + }); + }, + ); + + it.each([ + "../outside.js", + "/outside.js", + "https://example.invalid/mod.js", + "file:///outside.js", + "node:fs", + ])("marks %j as an invalid import target", (target) => { + expect(classifyPackageImportTargetString(target)).toEqual({ + kind: "invalid", + target, + }); + }); +}); diff --git a/src/domain/javascript/javascriptPackageImportAliases.ts b/src/domain/javascript/javascriptPackageImportAliases.ts new file mode 100644 index 000000000..9eab77e10 --- /dev/null +++ b/src/domain/javascript/javascriptPackageImportAliases.ts @@ -0,0 +1,98 @@ +/** + * Exact package-private import-alias lookup. Node resolves `#` specifiers + * against the importer's enclosing package `imports` map; this module owns the + * specifier and exact-key rules. Condition, array, and null target selection + * stay with the shared package-target machinery. Wildcard keys, recursive + * `#` targets, and external-package targets remain explicit unknowns. + */ + +/** How one `#` specifier participates in exact import-alias lookup. */ +export type PackageImportSpecifierClassification = + | { readonly kind: "invalid" } + | { readonly kind: "pattern" } + | { readonly kind: "exact"; readonly key: string }; + +/** Outcome of looking up one exact `#` key in a package `imports` object. */ +export type PackageImportExactLookup = + | { readonly kind: "undefined-map" } + | { + readonly kind: "missing-key"; + readonly declared: readonly string[]; + } + | { + readonly kind: "pattern-unresolved"; + readonly declared: readonly string[]; + } + | { + readonly kind: "found"; + readonly target: unknown; + readonly declared: readonly string[]; + }; + +/** How a selected string target is interpreted for package-private imports. */ +export type PackageImportTargetClassification = + | { readonly kind: "relative"; readonly target: string } + | { readonly kind: "invalid"; readonly target: string } + | { readonly kind: "recursive"; readonly target: string } + | { readonly kind: "external-package"; readonly target: string }; + +/** + * Classify a `#` module specifier before any package-scope walk. + * `#` and names ending in `/` are invalid. Other literal keys, including + * `#/…`, participate in exact lookup; wildcard expansion remains unsupported. + */ +export const classifyPackageImportSpecifier = ( + specifier: string, +): PackageImportSpecifierClassification => { + if (specifier === "#" || specifier.endsWith("/")) return { kind: "invalid" }; + if (specifier.includes("*")) return { kind: "pattern" }; + return { kind: "exact", key: specifier }; +}; + +/** True when an `imports` key is a pattern rather than an exact alias. */ +export const isPackageImportPatternKey = (key: string): boolean => + key.includes("*") || key.endsWith("/"); + +/** + * Look up one exact `#` key in a package `imports` value. + * A missing exact key is reported as missing when every declared key is also + * exact; if the map contains pattern keys, the miss stays pattern-unresolved + * rather than claiming the specifier is undefined. + */ +export const lookupExactPackageImport = ( + imports: unknown, + key: string, +): PackageImportExactLookup => { + if (typeof imports !== "object" || imports === null || Array.isArray(imports)) + return { kind: "undefined-map" }; + const declared = Object.keys(imports); + if (Object.hasOwn(imports, key) && !isPackageImportPatternKey(key)) + return { + kind: "found", + target: Reflect.get(imports, key), + declared, + }; + return declared.some(isPackageImportPatternKey) + ? { kind: "pattern-unresolved", declared } + : { kind: "missing-key", declared }; +}; + +/** + * Classify a selected string import target. Only package-local `./` paths are + * resolved as files; recursive `#` aliases and bare packages stay unknown. + * Parent-relative, absolute, and URL targets are invalid, allowing array + * selection to skip them before choosing a package-local fallback. + */ +export const classifyPackageImportTargetString = ( + target: string, +): PackageImportTargetClassification => { + if (target.startsWith("./")) return { kind: "relative", target }; + if ( + target.startsWith("../") || + target.startsWith("/") || + URL.canParse(target) + ) + return { kind: "invalid", target }; + if (target.startsWith("#")) return { kind: "recursive", target }; + return { kind: "external-package", target }; +}; diff --git a/tests/acceptance/applications/packageImportAliasesCli.test.ts b/tests/acceptance/applications/packageImportAliasesCli.test.ts new file mode 100644 index 000000000..90927dfa1 --- /dev/null +++ b/tests/acceptance/applications/packageImportAliasesCli.test.ts @@ -0,0 +1,251 @@ +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"; + +const literalAliases = ["#/x", "#x%2Fy", "#x%2Ey", "#x\\y", "#x\0y"]; +const fallbackAliases = { + "#inactive-array": { + node: [{ browser: "./browser.cjs" }], + default: "./config.cjs", + }, + "#nested-inactive-array": { + node: [[{ browser: "./browser.cjs" }]], + default: "./config.cjs", + }, + "#parent": ["../outside.cjs", "./config.cjs"], + "#absolute": { node: [["/outside.cjs"], "./config.cjs"] }, + "#url": { + import: ["https://example.invalid/outside.cjs", "./config.cjs"], + require: ["file:///outside.cjs", "./config.cjs"], + }, +}; +const resolvedAliases = [...literalAliases, ...Object.keys(fallbackAliases)]; +const refusedAliases = { + "#empty": { target: [], limitation: "an empty imports target array" }, + "#empty-active": { + target: { node: [] }, + limitation: "an empty imports target array", + }, + "#null-last": { + target: [[], null], + limitation: "an explicit null imports target", + }, + "#empty-last": { + target: [null, []], + limitation: "an empty imports target array", + }, + "#inactive": { + target: [{ browser: "./browser.cjs" }], + limitation: "it declares browser.", + }, + "#nested-inactive": { + target: [ + [{ browser: "./browser.cjs" }, { "rea-inactive": "./inactive.cjs" }], + ], + limitation: "it declares browser, rea-inactive.", + }, +}; +const allAliases = [...resolvedAliases, ...Object.keys(refusedAliases)]; + +const createPackageImportAliasFixture = async () => { + const root = await createTestTempDirectory("rea-package-import-aliases-"); + const mainSource = [ + 'const config = require("#config");', + 'const nested = require("./node_modules/fixture/index.cjs");', + 'const missing = require("#missing");', + ...allAliases.map((specifier) => `require(${JSON.stringify(specifier)});`), + ].join("\n"); + const moduleSource = allAliases + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join("\n"); + const nestedSource = [ + 'const config = require("#config");', + "module.exports = config;", + ].join("\n"); + const files = { + "package.json": JSON.stringify({ + name: "app", + imports: { + "#config": "./config.cjs", + ...Object.fromEntries( + literalAliases.map((specifier) => [specifier, "./config.cjs"]), + ), + ...fallbackAliases, + ...Object.fromEntries( + Object.entries(refusedAliases).map(([specifier, { target }]) => [ + specifier, + target, + ]), + ), + }, + }), + "main.cjs": mainSource, + "main.mjs": moduleSource, + "config.cjs": "module.exports = 'app-config';", + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + imports: { "#config": "./local.cjs" }, + }), + "node_modules/fixture/index.cjs": nestedSource, + "node_modules/fixture/local.cjs": "module.exports = 'fixture-config';", + }; + 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); + }), + ); + return { root, mainSource, moduleSource, nestedSource }; +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "resolves exact package import aliases with package scope through %s", + async (surface, { cli }) => { + const { root, mainSource, moduleSource, nestedSource } = + await createPackageImportAliasFixture(); + const nativeRequire = createRequire(join(root, "main.cjs")); + expect( + relative(root, nativeRequire.resolve("#config")).replaceAll("\\", "/"), + ).toBe("config.cjs"); + expect( + relative( + root, + createRequire(join(root, "node_modules/fixture/index.cjs")).resolve( + "#config", + ), + ).replaceAll("\\", "/"), + ).toBe("node_modules/fixture/local.cjs"); + expect(() => nativeRequire.resolve("#missing")).toThrow( + expect.objectContaining({ code: "ERR_PACKAGE_IMPORT_NOT_DEFINED" }), + ); + + 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; + for (const specifier of resolvedAliases) + for (const [moduleKind, sourcePath, source] of [ + ["require", "main.cjs", mainSource], + ["import", "main.mjs", moduleSource], + ] as const) + expect(graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + module_link_kind: moduleKind, + specifier, + resolved_path: "config.cjs", + resolution_status: "resolved", + }), + evidence: sourceEvidence(sourcePath, source), + }), + ); + const imports = graph.edges.filter( + ({ properties }) => properties.module_link_kind === "require", + ); + for (const [specifier, { limitation }] of Object.entries(refusedAliases)) + for (const [moduleKind, sourcePath, source] of [ + ["require", "main.cjs", mainSource], + ["import", "main.mjs", moduleSource], + ] as const) + expect(graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + module_link_kind: moduleKind, + specifier, + resolved_path: null, + resolution_status: "external", + }), + evidence: sourceEvidence(sourcePath, source, limitation), + }), + ); + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: "#config", + resolved_path: "config.cjs", + resolution_status: "resolved", + }), + evidence: sourceEvidence("main.cjs", mainSource), + }), + ); + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: "#config", + resolved_path: "node_modules/fixture/local.cjs", + resolution_status: "resolved", + }), + evidence: sourceEvidence( + "node_modules/fixture/index.cjs", + nestedSource, + ), + }), + ); + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: "#missing", + resolved_path: null, + resolution_status: "external", + }), + evidence: expect.objectContaining({ + limitations: expect.arrayContaining([ + expect.stringContaining("does not define package import alias"), + ]), + }), + }), + ); + }, +); + +const sourceEvidence = ( + sourcePath: string, + source: string, + limitation?: string, +): unknown => + expect.objectContaining({ + artifact: expect.objectContaining({ + available: true, + sha256: createHash("sha256").update(source).digest("hex"), + }), + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: sourcePath, + }), + }), + ...(limitation === undefined + ? {} + : { + limitations: expect.arrayContaining([ + expect.stringContaining(limitation), + ]), + }), + }); diff --git a/tests/boundary/browser/cdpSvgXlinkHref.test.ts b/tests/boundary/browser/cdpSvgXlinkHref.test.ts index be4fe268e..56c3fc3cf 100644 --- a/tests/boundary/browser/cdpSvgXlinkHref.test.ts +++ b/tests/boundary/browser/cdpSvgXlinkHref.test.ts @@ -29,6 +29,11 @@ const chromeExecutable = [ existsSync(candidate), ); +// Match the real-browser verifier: a cold Chrome launch on a loaded coverage +// runner can exceed 20 seconds before publishing DevToolsActivePort. +const CHROME_STARTUP_TIMEOUT_MS = 60_000; +const CHROME_TEST_TIMEOUT_MS = CHROME_STARTUP_TIMEOUT_MS + 30_000; + // Issue 1586's page, plus the HTML, image, use, and foreign-origin cases the // real snapshot must distinguish. The XHTML page is the prefixed-svg gap // reported on PR 1602. The tests are skipped when no Chrome-family executable @@ -90,7 +95,7 @@ it.skipIf(chromeExecutable === undefined)( executable, activePortPath: join(profile, "DevToolsActivePort"), stderr: () => stderr, - timeoutMs: 20_000, + timeoutMs: CHROME_STARTUP_TIMEOUT_MS, }); const endpoint = `http://127.0.0.1:${String(port)}`; const provider = new CdpBrowserProvider(); @@ -158,6 +163,7 @@ it.skipIf(chromeExecutable === undefined)( }); } }, + CHROME_TEST_TIMEOUT_MS, ); const xhtmlPage = ` @@ -261,7 +267,7 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [ executable, activePortPath: join(profile, "DevToolsActivePort"), stderr: () => stderr, - timeoutMs: 20_000, + timeoutMs: CHROME_STARTUP_TIMEOUT_MS, }); const endpoint = `http://127.0.0.1:${String(port)}`; const provider = new CdpBrowserProvider(); @@ -312,6 +318,7 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [ }); } }, + CHROME_TEST_TIMEOUT_MS, ); } diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index 1eb992760..b53fe8516 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -742,6 +742,30 @@ describe("nested and array package export targets", () => { ); }); describe("unmatched package export conditions", () => { + it.each([ + [[{ browser: "./default.cjs" }], "browser"], + [ + [[{ browser: "./default.cjs" }, { "rea-inactive": "./node.cjs" }]], + "browser, rea-inactive", + ], + [{ node: [{ browser: "./default.cjs" }] }, "node, browser"], + ] as const)( + "preserves condition names in unmatched target %j", + async (target, declared) => { + for (const moduleKind of ["import", "require"] as const) { + const { node_error, outcome } = await compareWithNode( + target, + moduleKind, + ); + expect(node_error).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED"); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations[0]).toContain(`It declares ${declared}.`); + } + }, + ); it.each(["import", "require"] as const)( "names the declared conditions for %s", async (moduleKind) => { @@ -769,6 +793,31 @@ describe("unmatched package export conditions", () => { }); }); +describe("blocked package export provenance", () => { + it.each([ + [[], "an empty exports target array"], + [{ node: [] }, "an empty exports target array"], + [[null, []], "an empty exports target array"], + [[[], null], "an explicit null exports target"], + ] as const)( + "preserves the blocking source value in %j", + async (target, limitation) => { + for (const moduleKind of ["import", "require"] as const) { + const { node_error, outcome } = await compareWithNode( + target, + moduleKind, + ); + expect(node_error).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED"); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations[0]).toContain(limitation); + } + }, + ); +}); + describe("package exports fallback refusal reasons", () => { it.each([ [{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "rejected"], diff --git a/tests/boundary/javascript/packageImportAliases.test.ts b/tests/boundary/javascript/packageImportAliases.test.ts new file mode 100644 index 000000000..b009c3ad4 --- /dev/null +++ b/tests/boundary/javascript/packageImportAliases.test.ts @@ -0,0 +1,654 @@ +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + resolveArtifactPathByContext, + type ArtifactPathResolution, +} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const nodeOutcomeSchema = z.strictObject({ + target: z.string().nullable(), + error_code: z.string().nullable(), +}); + +const posixRelative = (root: string, absolute: string): string => + relative(root, absolute).replaceAll("\\", "/"); + +const inventory = ( + contents: ReadonlyMap, +): Map => + new Map( + [...contents].map(([path, value]) => [ + path, + { + path, + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: Buffer.byteLength(value), + inventory_artifact_id: path, + kind: path.endsWith("package.json") ? "package-json" : "javascript", + unpacked: false, + text: { included: true, value }, + }, + ]), + ); + +const writeTree = async ( + root: string, + files: Readonly>, +): Promise> => { + const contents = new Map(); + await Promise.all( + Object.entries(files).map(async ([path, value]) => { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, value); + contents.set(path, value); + }), + ); + return contents; +}; + +const nodeRequireTarget = async ( + importer: string, + specifier: string, +): Promise> => { + try { + return { + target: createRequire(importer).resolve(specifier), + error_code: null, + }; + } catch (error) { + const code = + error instanceof Error && "code" in error + ? z.string().parse(Reflect.get(error, "code")) + : null; + return { target: null, error_code: code }; + } +}; + +const nodeImportTarget = async ( + cwd: string, + specifier: string, +): Promise> => { + const { stdout } = await execute( + process.execPath, + [ + "--input-type=module", + "-e", + ` + try { + const url = await import.meta.resolve(${JSON.stringify(specifier)}); + console.log(JSON.stringify({ target: url, error_code: null })); + } catch (error) { + console.log(JSON.stringify({ + target: null, + error_code: error.code ?? null, + })); + } + `, + ], + { cwd }, + ); + return nodeOutcomeSchema.parse(JSON.parse(stdout)); +}; + +const resolveAlias = ( + files: Map, + declaredPath: string, + sourcePath: string, + moduleKind: "import" | "require", +): ArtifactPathResolution => + resolveArtifactPathByContext({ + declaredPath, + sourcePath, + context: "module-specifier", + moduleKind, + files, + }); + +describe("literal package import keys compared with Node", () => { + it("matches literal imports keys containing path-like bytes in both Node loaders", async () => { + const root = await createTestTempDirectory("rea-import-alias-literal-"); + const specifiers = ["#/x", "#x%2Fy", "#x%2Ey", "#x\\y", "#x\0y"]; + const contents = await writeTree(root, { + "package.json": JSON.stringify({ + imports: Object.fromEntries( + specifiers.map((specifier) => [specifier, "./target.cjs"]), + ), + }), + "main.cjs": "module.exports = 1;", + "target.cjs": "module.exports = 1;", + }); + const inventoried = inventory(contents); + for (const specifier of specifiers) { + const required = await nodeRequireTarget( + join(root, "main.cjs"), + specifier, + ); + expect(required.error_code).toBeNull(); + expect(posixRelative(root, required.target ?? "")).toBe("target.cjs"); + const imported = await nodeImportTarget(root, specifier); + expect(imported.error_code).toBeNull(); + expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe( + "target.cjs", + ); + for (const moduleKind of ["require", "import"] as const) + expect( + resolveAlias(inventoried, specifier, "main.cjs", moduleKind), + JSON.stringify({ specifier, moduleKind }), + ).toMatchObject({ + declared_path: specifier, + resolution_status: "resolved", + resolved_path: "target.cjs", + }); + } + }); + + it("still rejects unsafe selected targets for literal imports keys", async () => { + const root = await createTestTempDirectory("rea-import-alias-target-"); + const contents = await writeTree(root, { + "package.json": JSON.stringify({ + imports: { "#x%2Fy": "./nested%2Ftarget.cjs" }, + }), + "main.cjs": "module.exports = 1;", + "nested/target.cjs": "module.exports = 1;", + }); + const inventoried = inventory(contents); + const required = await nodeRequireTarget(join(root, "main.cjs"), "#x%2Fy"); + expect(required.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER"); + const imported = await nodeImportTarget(root, "#x%2Fy"); + expect(imported.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER"); + for (const moduleKind of ["require", "import"] as const) + expect( + resolveAlias(inventoried, "#x%2Fy", "main.cjs", moduleKind), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }); +}); + +describe("inactive package target arrays compared with Node", () => { + it("continues past inactive arrays in enclosing imports and exports conditions", async () => { + const root = await createTestTempDirectory("rea-inactive-package-array-"); + const conditional = { + node: [{ browser: "./browser.cjs" }], + default: "./fallback.cjs", + }; + const nested = { + node: [ + [{ browser: "./browser.cjs" }], + { "rea-inactive": "./inactive.cjs" }, + ], + default: "./fallback.cjs", + }; + const contents = await writeTree(root, { + "package.json": JSON.stringify({ + imports: { "#conditional": conditional, "#nested": nested }, + }), + "main.cjs": "module.exports = 1;", + "fallback.cjs": "module.exports = 1;", + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + exports: { ".": conditional, "./nested": nested }, + }), + "node_modules/fixture/fallback.cjs": "module.exports = 2;", + }); + const inventoried = inventory(contents); + for (const [specifier, expected] of [ + ["#conditional", "fallback.cjs"], + ["#nested", "fallback.cjs"], + ["fixture", "node_modules/fixture/fallback.cjs"], + ["fixture/nested", "node_modules/fixture/fallback.cjs"], + ] as const) { + const required = await nodeRequireTarget( + join(root, "main.cjs"), + specifier, + ); + expect(required.error_code, specifier).toBeNull(); + expect(posixRelative(root, required.target ?? "")).toBe(expected); + const imported = await nodeImportTarget(root, specifier); + expect(imported.error_code).toBeNull(); + expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe( + expected, + ); + for (const moduleKind of ["require", "import"] as const) + expect( + resolveAlias(inventoried, specifier, "main.cjs", moduleKind), + ).toMatchObject({ + declared_path: specifier, + resolution_status: "resolved", + resolved_path: expected, + }); + } + }); +}); + +describe("package target refusal evidence compared with Node", () => { + it("keeps empty, null, invalid, and unmatched array outcomes distinct", async () => { + const root = await createTestTempDirectory("rea-inactive-package-refusal-"); + const inactive = { browser: "./browser.cjs" }; + const contents = await writeTree(root, { + "package.json": JSON.stringify({ + imports: { + "#empty-direct": [], + "#empty": { node: [], default: "./fallback.cjs" }, + "#null": { node: [null, inactive], default: "./fallback.cjs" }, + "#null-last": [[], null], + "#empty-last": [null, []], + "#invalid": { + node: ["../outside.cjs", inactive], + default: "./fallback.cjs", + }, + "#unmatched": [inactive], + "#unmatched-nested": [ + [inactive, { "rea-inactive": "./inactive.cjs" }], + ], + "#unmatched-active": { node: [inactive] }, + }, + }), + "main.cjs": "module.exports = 1;", + "fallback.cjs": "module.exports = 1;", + }); + const inventoried = inventory(contents); + for (const [specifier, errorCode, status, limitation] of [ + [ + "#empty-direct", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "an empty imports target array", + ], + [ + "#empty", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "an empty imports target array", + ], + [ + "#null", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "an explicit null imports target", + ], + [ + "#null-last", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "an explicit null imports target", + ], + [ + "#empty-last", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "an empty imports target array", + ], + ["#invalid", "ERR_INVALID_PACKAGE_TARGET", "rejected", "../outside.cjs"], + [ + "#unmatched", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "it declares browser.", + ], + [ + "#unmatched-nested", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "it declares browser, rea-inactive.", + ], + [ + "#unmatched-active", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "it declares node, browser.", + ], + ] as const) { + expect( + (await nodeRequireTarget(join(root, "main.cjs"), specifier)).error_code, + ).toBe(errorCode); + expect((await nodeImportTarget(root, specifier)).error_code).toBe( + errorCode, + ); + for (const moduleKind of ["require", "import"] as const) { + const result = resolveAlias( + inventoried, + specifier, + "main.cjs", + moduleKind, + ); + expect(result).toMatchObject({ + resolution_status: status, + resolved_path: null, + }); + expect(result.limitations.join(" "), specifier).toContain(limitation); + } + } + }); +}); + +describe("package import target arrays compared with Node", () => { + it("skips invalid parent, absolute, and URL targets before an exact fallback", async () => { + const root = await createTestTempDirectory("rea-import-alias-fallback-"); + const imports = { + "#parent": ["../outside.cjs", "./fallback.cjs"], + "#absolute": { node: [["/outside.cjs"], "./fallback.cjs"] }, + "#url": { + import: ["https://example.invalid/outside.cjs", "./fallback.cjs"], + require: ["file:///outside.cjs", "./fallback.cjs"], + }, + }; + const contents = await writeTree(root, { + "package.json": JSON.stringify({ imports }), + "main.cjs": "module.exports = 1;", + "fallback.cjs": "module.exports = 1;", + }); + const inventoried = inventory(contents); + for (const specifier of Object.keys(imports)) { + const required = await nodeRequireTarget( + join(root, "main.cjs"), + specifier, + ); + expect(required.error_code).toBeNull(); + expect(posixRelative(root, required.target ?? "")).toBe("fallback.cjs"); + const imported = await nodeImportTarget(root, specifier); + expect(imported.error_code).toBeNull(); + expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe( + "fallback.cjs", + ); + for (const moduleKind of ["require", "import"] as const) + expect( + resolveAlias(inventoried, specifier, "main.cjs", moduleKind), + ).toMatchObject({ + declared_path: specifier, + resolution_status: "resolved", + resolved_path: "fallback.cjs", + }); + } + }); + + it("preserves invalid, null, and unsupported-package outcomes in arrays", async () => { + const root = await createTestTempDirectory( + "rea-import-alias-fallback-refuse-", + ); + const contents = await writeTree(root, { + "package.json": JSON.stringify({ + imports: { + "#invalid": [null, "../outside.cjs"], + "#blocked": ["../outside.cjs", null], + "#package": ["fixture", "./fallback.cjs"], + }, + }), + "main.cjs": "module.exports = 1;", + "fallback.cjs": "module.exports = 1;", + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + main: "index.cjs", + }), + "node_modules/fixture/index.cjs": "module.exports = 2;", + }); + const inventoried = inventory(contents); + for (const [specifier, errorCode, resolutionStatus, reason] of [ + ["#invalid", "ERR_INVALID_PACKAGE_TARGET", "rejected", "../outside.cjs"], + [ + "#blocked", + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + "external", + "explicit null", + ], + ] as const) { + const required = await nodeRequireTarget( + join(root, "main.cjs"), + specifier, + ); + expect(required.error_code).toBe(errorCode); + expect((await nodeImportTarget(root, specifier)).error_code).toBe( + errorCode, + ); + for (const moduleKind of ["require", "import"] as const) { + const result = resolveAlias( + inventoried, + specifier, + "main.cjs", + moduleKind, + ); + expect(result).toMatchObject({ + resolution_status: resolutionStatus, + resolved_path: null, + }); + expect(result.limitations.join(" ")).toContain(reason); + } + } + const required = await nodeRequireTarget( + join(root, "main.cjs"), + "#package", + ); + expect(posixRelative(root, required.target ?? "")).toBe( + "node_modules/fixture/index.cjs", + ); + const imported = await nodeImportTarget(root, "#package"); + expect(imported.error_code).toBeNull(); + expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe( + "node_modules/fixture/index.cjs", + ); + for (const moduleKind of ["require", "import"] as const) { + const result = resolveAlias( + inventoried, + "#package", + "main.cjs", + moduleKind, + ); + expect(result).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(result.limitations.join(" ")).toContain( + "External-package import targets remain unresolved", + ); + } + }); +}); + +describe("package import aliases compared with Node", () => { + it("matches Node require and import for an exact root alias", async () => { + const root = await createTestTempDirectory("rea-import-alias-root-"); + const files = { + "package.json": JSON.stringify({ + name: "app", + imports: { "#config": "./config.cjs" }, + }), + "main.cjs": 'module.exports = require("#config");', + "config.cjs": "module.exports = 'config.cjs';", + }; + const contents = await writeTree(root, files); + const inventoried = inventory(contents); + const required = await nodeRequireTarget(join(root, "main.cjs"), "#config"); + expect(posixRelative(root, required.target ?? "")).toBe("config.cjs"); + expect( + resolveAlias(inventoried, "#config", "main.cjs", "require"), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + }); + const imported = await nodeImportTarget(root, "#config"); + expect(imported.error_code).toBeNull(); + expect( + resolveAlias(inventoried, "#config", "main.cjs", "import"), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + }); + }); + + it("matches Node when a nested package has its own imports map", async () => { + const root = await createTestTempDirectory("rea-import-alias-nested-"); + const files = { + "package.json": JSON.stringify({ + name: "app", + imports: { "#config": "./root.cjs" }, + }), + "root.cjs": "module.exports = 'root';", + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + imports: { "#config": "./local.cjs" }, + }), + "node_modules/fixture/index.cjs": 'module.exports = require("#config");', + "node_modules/fixture/local.cjs": "module.exports = 'local';", + }; + const contents = await writeTree(root, files); + const inventoried = inventory(contents); + const nested = await nodeRequireTarget( + join(root, "node_modules/fixture/index.cjs"), + "#config", + ); + expect(posixRelative(root, nested.target ?? "")).toBe( + "node_modules/fixture/local.cjs", + ); + expect( + resolveAlias( + inventoried, + "#config", + "node_modules/fixture/index.cjs", + "require", + ), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "node_modules/fixture/local.cjs", + }); + const fromRoot = await nodeRequireTarget(join(root, "root.cjs"), "#config"); + expect(posixRelative(root, fromRoot.target ?? "")).toBe("root.cjs"); + expect( + resolveAlias(inventoried, "#config", "root.cjs", "require"), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "root.cjs", + }); + }); + + it("matches Node when a nested package does not define the alias", async () => { + const root = await createTestTempDirectory("rea-import-alias-scope-"); + const files = { + "package.json": JSON.stringify({ + name: "app", + imports: { "#config": "./root.cjs" }, + }), + "root.cjs": "module.exports = 'root';", + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + }), + "node_modules/fixture/index.cjs": 'module.exports = require("#config");', + }; + await writeTree(root, files); + const nested = await nodeRequireTarget( + join(root, "node_modules/fixture/index.cjs"), + "#config", + ); + expect(nested.target).toBeNull(); + // CJS reports MODULE_NOT_FOUND when the nested package.json has no + // `imports` field at all; it still does not inherit the ancestor map. + expect(nested.error_code).toBe("MODULE_NOT_FOUND"); + const outcome = resolveAlias( + inventory(new Map(Object.entries(files))), + "#config", + "node_modules/fixture/index.cjs", + "require", + ); + expect(outcome).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect(outcome.limitations.join(" ")).toContain( + "node_modules/fixture/package.json", + ); + }); +}); + +describe("package import alias refusals compared with Node", () => { + it("matches Node refusal for a missing key, explicit null, and reserved specifier", async () => { + const root = await createTestTempDirectory("rea-import-alias-refuse-"); + const files = { + "package.json": JSON.stringify({ + name: "app", + imports: { + "#config": "./config.cjs", + "#blocked": null, + "#": "./config.cjs", + "#/": "./config.cjs", + "#config/": "./config.cjs", + }, + }), + "main.cjs": "module.exports = 1;", + "config.cjs": "module.exports = 1;", + }; + await writeTree(root, files); + const inventoried = inventory(new Map(Object.entries(files))); + const missing = await nodeRequireTarget(join(root, "main.cjs"), "#missing"); + expect(missing.error_code).toBe("ERR_PACKAGE_IMPORT_NOT_DEFINED"); + expect( + resolveAlias(inventoried, "#missing", "main.cjs", "require"), + ).toMatchObject({ resolution_status: "external", resolved_path: null }); + const blocked = await nodeRequireTarget(join(root, "main.cjs"), "#blocked"); + expect(blocked.error_code).toBe("ERR_PACKAGE_IMPORT_NOT_DEFINED"); + expect( + resolveAlias(inventoried, "#blocked", "main.cjs", "require"), + ).toMatchObject({ resolution_status: "external", resolved_path: null }); + for (const specifier of ["#", "#/", "#config/"]) { + const reserved = await nodeRequireTarget( + join(root, "main.cjs"), + specifier, + ); + expect(reserved.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER"); + expect((await nodeImportTarget(root, specifier)).error_code).toBe( + "ERR_INVALID_MODULE_SPECIFIER", + ); + for (const moduleKind of ["require", "import"] as const) + expect( + resolveAlias(inventoried, specifier, "main.cjs", moduleKind), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + } + }); + + it("matches Node when an import condition selects a different exact file", async () => { + const root = await createTestTempDirectory("rea-import-alias-cond-"); + const files = { + "package.json": JSON.stringify({ + name: "app", + type: "module", + imports: { + "#config": { + import: "./config.mjs", + require: "./config.cjs", + }, + }, + }), + "main.cjs": 'module.exports = require("#config");', + "config.cjs": "export default 'cjs'; module.exports = 'cjs';", + "config.mjs": "export default 'mjs';", + }; + await writeTree(root, files); + const inventoried = inventory(new Map(Object.entries(files))); + const required = await nodeRequireTarget(join(root, "main.cjs"), "#config"); + expect(posixRelative(root, required.target ?? "")).toBe("config.cjs"); + expect( + resolveAlias(inventoried, "#config", "main.cjs", "require"), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.cjs", + }); + const imported = await nodeImportTarget(root, "#config"); + expect(imported.error_code).toBeNull(); + expect(imported.target ?? "").toContain("config.mjs"); + expect( + resolveAlias(inventoried, "#config", "main.cjs", "import"), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "config.mjs", + }); + }); +});