From e8491e023a255f790ae9850225084d7495e9ade0 Mon Sep 17 00:00:00 2001 From: Itoje Dollar <163161978+dollarsmoney@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:15:35 -0700 Subject: [PATCH 1/3] fix(artifacts): mark Windows launchable files as host executables (#1824) Host-file occurrences derived `executable` from POSIX execute bits, which Windows never reports, so every Windows host file (including PE .exe images classified as executable) reported `executable: false`. Derive the fact once through hostExecutable: POSIX hosts keep the execute bits; Windows marks regular files with the extensions it launches directly (.exe, .com, .bat, .cmd), without reading the ambient PATHEXT. Fixes #1819 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/artifacts/hostExecutableMarking.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/artifacts/hostExecutableMarking.test.ts b/src/artifacts/hostExecutableMarking.test.ts index 7aff5bbda..9f97186d2 100644 --- a/src/artifacts/hostExecutableMarking.test.ts +++ b/src/artifacts/hostExecutableMarking.test.ts @@ -13,6 +13,16 @@ describe("hostExecutableMarking", () => { expect( hostExecutableMarking("C:\\scratch\\SETUP.CMD", file(0o444), "win32"), ).toBe(true); + for (const path of [ + "tools/SETUP.EXE", + "run.Cmd", + "build.bat", + "legacy.com", + ]) + expect(hostExecutableMarking(path, file(0o666), "win32")).toBe(true); + expect(hostExecutableMarking("readonly.exe", file(0o444), "win32")).toBe( + true, + ); expect( hostExecutableMarking("C:\\scratch\\library.dll", file(0o666), "win32"), ).toBe(false); @@ -25,6 +35,11 @@ describe("hostExecutableMarking", () => { ).toBe(false); }); + it("does not treat other Windows filenames as executable even with execute bits", () => { + for (const path of ["notes.txt", "README", "archive.exe.bak"]) + expect(hostExecutableMarking(path, file(0o777), "win32")).toBe(false); + }); + it("keeps POSIX permission bits authoritative regardless of extension", () => { expect(hostExecutableMarking("/scratch/tool", file(0o755), "linux")).toBe( true, From d28b41ea4e93bb4c4d360a3bf2291ca83bb2b378 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sun, 11 Oct 2026 10:17:58 -0700 Subject: [PATCH 2/3] feat(javascript): resolve exact package import aliases (#1862) * feat(javascript): resolve exact package import aliases Resolve exact #alias specifiers against the importer's enclosing package.json imports map using the shared condition, array, and null target selection. Nested packages keep their own maps; missing keys, explicit nulls, and out-of-scope aliases report a precise reason. Wildcard, recursive, and external-package import targets remain unresolved. * fix(javascript): keep nested import aliases in CLI graphs Bound nested #alias requires produce application-graph require edges from the enclosing package. Node reports MODULE_NOT_FOUND when a nested package.json has no imports field, without inheriting the ancestor map. * fix(javascript): resolve literal package import keys before path admission * fix(javascript): preserve import keys and invalid-target fallback * fix(javascript): preserve unmatched package target arrays * fix(javascript): retain package target refusal evidence * test(browser): match real verifier startup budget --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/javascript-applications.md | 5 +- docs/javascript-artifact-reconstruction.md | 20 + .../JavaScriptArtifactPathResolution.test.ts | 368 ++++++++++ .../JavaScriptArtifactPathResolution.ts | 318 ++++++--- .../javascriptPackageImportAliases.test.ts | 147 ++++ .../javascriptPackageImportAliases.ts | 98 +++ .../packageImportAliasesCli.test.ts | 251 +++++++ .../boundary/browser/cdpSvgXlinkHref.test.ts | 11 +- .../packageConditionalExports.test.ts | 49 ++ .../javascript/packageImportAliases.test.ts | 654 ++++++++++++++++++ 10 files changed, 1838 insertions(+), 83 deletions(-) create mode 100644 src/domain/javascript/javascriptPackageImportAliases.test.ts create mode 100644 src/domain/javascript/javascriptPackageImportAliases.ts create mode 100644 tests/acceptance/applications/packageImportAliasesCli.test.ts create mode 100644 tests/boundary/javascript/packageImportAliases.test.ts 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", + }); + }); +}); From 9d8e42bdd6f1bf8a984da1e06e1d76a1dbb9a7ed Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:22:26 +0800 Subject: [PATCH 3/3] fix(javascript): follow actual receivers in nested intrinsic calls (#1912) Consume arguments per generic function invocation rather than property-chain depth. Preserve the callable receiver through call/apply, Reflect.apply and bound invocation. Cover nested mutations and fresh targets through source analysis and inert production CLI/MCP fixtures. Fixes #1909. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/javascript-artifact-reconstruction.md | 12 +++-- .../javascript/electronModuleInterop.test.ts | 54 +++++++++++++++++-- .../javascriptIntrinsicInvocation.ts | 40 ++++++-------- .../electronModuleInteropCli.test.ts | 38 +++++++++---- 4 files changed, 99 insertions(+), 45 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index fbafe042e..1ac08f88c 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -346,12 +346,14 @@ Known Object/Reflect mutators remain recognizable through literal destructuring of intrinsic objects and stable bound-function aliases. Calls retain bound argument prefixes and the original bound receiver through `call`, `apply`, and `Reflect.apply`, including generic `Function.prototype` invocation and binding -creation. Visibly replaced invocation intrinsics produce unknown effects instead +creation. Arguments follow each actual callable invocation rather than property +chain depth; rebinding retains the original bound receiver. +Visibly replaced invocation intrinsics produce unknown effects instead of using their former call semantics; captured function aliases retain their -earlier identity. Unexamined -targets remain unresolved. `Reflect.set` examines its explicit receiver as well -as its target, retaining unknown receiver effects. Creating an unused binding does not itself mutate -an intrinsic. Variable redeclarations retain a single unconditional initializer +earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines +its explicit receiver as well as its target, retaining unknown receiver effects. +Creating an unused binding does not itself mutate an intrinsic. Variable +redeclarations retain a single unconditional initializer when they introduce no assignment or second initializer. Stable identifier chains to fresh object, array, function, undecorated class or RegExp syntax keep those targets separate from intrinsic diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index bdaf113bf..cca3a20c8 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -534,6 +534,50 @@ describe("Electron module interop capture and scope", () => { }); }); +describe("Electron module interop generic invocations", () => { + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const call = Function.prototype.call; const change = call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + 'Function.prototype.call.apply(Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.call,Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const first = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); const change = first.bind(fake); change({value:customCreate});', + "const change = Function.prototype.call.bind(Object.defineProperty); change(null,...args);", + "const change = Function.prototype.apply.bind(Object.defineProperty); change(null,args);", + 'const change = Function.prototype.call.bind(Object.defineProperty,null,...args); change(metadata,"x",{value:1});', + 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Object.defineProperty.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + ])("follows the callable receiver of a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toEqual([]); + }); + + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'Function.prototype.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[metadata,"x",{value:1}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'Function.prototype.call.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + ])("preserves fresh targets through a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toHaveLength(1); + }); +}); + describe("Electron module interop bound mutators", () => { it.each([ 'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});', @@ -560,6 +604,9 @@ describe("Electron module interop bound mutators", () => { 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const apply = Reflect.apply; Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'const change = Reflect.apply.call; change.call(Reflect.apply,null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', 'Reflect.set({},"create",customCreate,Object);', 'const metadata = {}; Reflect.set(metadata,"create",customCreate,Object);', 'const change = Reflect.set.bind(null,{},"create",customCreate); change(Object);', @@ -568,9 +615,6 @@ describe("Electron module interop bound mutators", () => { 'Reflect.set({},"create",...valuesAndReceivers);', "Reflect.set({},...keysValuesAndReceivers);", 'Reflect.set({},"create",customCreate,receiver);', - 'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});', - 'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);', - 'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', ])("rejects intrinsic writes through a bound mutator: %s", (write) => { expect( analyzeJavaScriptStaticSource( @@ -593,8 +637,8 @@ describe("Electron module interop bound mutators", () => { 'const change = Object.defineProperty.bind(null,Object,"create");', 'Object.defineProperty.call(null,metadata,"x",{value:1});', 'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);', - 'const change = Object.defineProperty.call; change(null,metadata,"x",{value:1});', - 'const {apply:change} = Object.defineProperty; change(null,[metadata,"x",{value:1}]);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);', 'Reflect.set(metadata,"x",1,{});', 'const receiver = {}; Reflect.set({},"x",1,receiver);', ])("keeps a fresh bound target or unused binding separate: %s", (write) => { diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index e34efe04a..20885069f 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -38,6 +38,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( let callee: t.Node = node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; let receiver: t.Node | undefined; + let explicitReceiver = false; for (;;) { callee = unwrapJavaScriptExpression(callee).node; if (visited.has(callee)) return undefined; @@ -62,6 +63,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( ) return { kind: "opaque", path: bound.path, arguments: undefined }; receiver = arguments_?.[0]; + explicitReceiver = true; callee = callee.object; arguments_ = invocationArguments(method, arguments_); continue; @@ -72,26 +74,26 @@ export const resolveJavaScriptIntrinsicInvocation = ( if (bound.kind === "opaque") return bound; path = bound.path; receiver = bound.receiver; + explicitReceiver = true; arguments_ = bound.arguments === undefined ? undefined : [...bound.arguments, ...(arguments_ ?? [])]; } - // A projected .call/.apply invokes the preceding callable. The actual - // Function.prototype methods instead invoke their receiver, retained by bind. - while (path.length > 3 && !genericInvocationMethod(path)) { - const method = path.at(-1); - if (method !== "call" && method !== "apply") break; + const method = path.length > 3 ? path.at(-1) : undefined; + if (method === "call" || method === "apply") { + // These properties hold generic function values. Their receiver selects + // the callable; property-chain depth does not consume more arguments. + const callable: t.Node | undefined = explicitReceiver + ? receiver + : t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) + ? callee.object + : undefined; + if (!t.isExpression(callable)) return undefined; + callee = callable; receiver = arguments_?.[0]; + explicitReceiver = true; arguments_ = invocationArguments(method, arguments_); - path = path.slice(0, -1); - } - const genericMethod = genericInvocationMethod(path); - if (genericMethod !== undefined) { - if (!t.isExpression(receiver)) return undefined; - callee = receiver; - receiver = arguments_?.[0]; - arguments_ = invocationArguments(genericMethod, arguments_); continue; } if (path.join(".") === "intrinsic.Reflect.apply") { @@ -99,6 +101,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( if (!t.isExpression(callable)) return undefined; callee = callable; receiver = arguments_?.[1]; + explicitReceiver = true; arguments_ = arrayArguments(arguments_?.[2]); continue; } @@ -110,14 +113,3 @@ export const resolveJavaScriptIntrinsicInvocation = ( }; } }; - -const genericInvocationMethod = ( - path: readonly string[], -): "call" | "apply" | undefined => - path.length === 4 && - path[0] === "intrinsic" && - path[1] === "Function" && - path[2] === "prototype" && - (path[3] === "call" || path[3] === "apply") - ? path[3] - : undefined; diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index badad8696..d4191a213 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -37,8 +37,29 @@ const INTRINSIC_MUTATIONS = [ 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', ], ["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'], + [ + "deep-call.cjs", + 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + ], + [ + "deep-apply.cjs", + 'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + ], ] as const; +const writePoisonedModules = async (root: string): Promise => { + for (const [name, mutation] of INTRINSIC_MUTATIONS) + await writeFile( + join(root, name), + `${mutation} + ${ROLLDOWN_MODULE_INTEROP} + let poisoned = require("electron"); + poisoned = __toESM(poisoned, 1); + new poisoned.BrowserWindow({}); + throw new Error("must not execute poisoned source");`, + ); +}; + cliTest.for(["CLI", "stdio MCP"] as const)( "retains converted Electron windows and preload evidence through %s", async (surface, { cli }) => { @@ -51,20 +72,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( join(root, "preload.js"), 'throw new Error("must stay inert");', ); - for (const [name, mutation] of INTRINSIC_MUTATIONS) - await writeFile( - join(root, name), - `${mutation} - ${ROLLDOWN_MODULE_INTEROP} - let poisoned = require("electron"); - poisoned = __toESM(poisoned, 1); - new poisoned.BrowserWindow({}); - throw new Error("must not execute poisoned source");`, - ); + await writePoisonedModules(root); const source = `const metadata = {}; Object.defineProperty(metadata,"x",{value:1}); const decorateMetadata = Object.defineProperty.bind(null,metadata,"bound"); decorateMetadata({value:1}); + const decorateViaCall = Function.prototype.call.bind(Object.defineProperty); + decorateViaCall(null,metadata,"uncurried",{value:1}); const localFunction = () => {}; Object.defineProperty(localFunction,"x",{value:1}); const localClass = class {}; @@ -105,6 +119,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./generic-bind.cjs"); require("./replaced-apply.cjs"); require("./receiver.cjs"); + require("./deep-call.cjs"); + require("./deep-apply.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -131,7 +147,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( preload_entrypoints: 1, }); expect(result.statistics.parse_failures).toBe(0); - expect(result.statistics.parsed_javascript_files).toBe(10); + expect(result.statistics.parsed_javascript_files).toBe(12); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );