diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 929701e9bd..c17cd7198a 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -154,6 +154,41 @@ describe("Windows CommonJS request classification", () => { }); }); +describe("relative ESM URL separators", () => { + it.each([ + ["./dir\\dep.mjs", "resolved", "app/dir/dep.mjs"], + ["../shared\\dep.mjs", "resolved", "shared/dep.mjs"], + ["./unused\\..\\dir\\dep.mjs", "resolved", "app/dir/dep.mjs"], + ["./..\\..\\outside.mjs", "rejected", null], + ["./dir%5cdep.mjs", "rejected", null], + ["./dir%2fdep.mjs", "rejected", null], + ["./dir\\dep%00.mjs", "rejected", null], + [".\\dir\\dep.mjs", "rejected", null], + ] as const)( + "resolves %s with module URL rules", + (declaredPath, status, target) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: fileMap([ + file("app/main.mjs", "root"), + file("app/dir/dep.mjs", "root"), + file("shared/dep.mjs", "root"), + file("outside.mjs", "root"), + ]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: status, + resolved_path: target, + }); + }, + ); +}); + describe("literal POSIX inventory base segments", () => { it("validates the final URL after a parent reference removes the encoded base segment", () => { expect( diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 1c160b1f50..ccc4a51477 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -636,7 +636,11 @@ const contextualCandidate = ( } const hasUrlPathSemantics = context === "module-specifier" && input.moduleKind === "import"; - if (hasUrlPathSemantics) declared = htmlUrlText(declared); + if (hasUrlPathSemantics) { + declared = htmlUrlText(declared); + if (isRelativeJavaScriptModuleSpecifier(input.declaredPath)) + declared = declared.replaceAll("\\", "/"); + } const rejected = rejectDeclaration( declared, declaresLiteralPercent(input) || hasUrlPathSemantics, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts index 8b0a811191..2750021c44 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts @@ -8,6 +8,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import type { JavaScriptArtifactFile } from "../../../../src/domain/javascript/javascriptArtifactFiles.js"; import { resolveStandaloneArtifactPath } from "../../../support/javascriptArtifactPathResolution.js"; import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { importReferenceSource } from "../../../support/referenceSourceResourceScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; const filesFor = (paths: readonly string[]) => @@ -159,22 +160,26 @@ describe("module specifier punctuation", () => { }); describe("ESM module URL decoding", () => { - it("resolves URL-encoded imports to the files loaded by Node", async () => { + it("resolves ESM URL paths to the files loaded by Node", async () => { const cases = [ - ["./plain.mjs", "plain.mjs"], - ["./space%20name.mjs", "space name.mjs"], - ["./pr%C3%A9load.mjs", "préload.mjs"], - ["./name%3F%23.mjs", "name?#.mjs"], - ["./percent%2520.mjs?cache=1#v2", "percent%20.mjs"], + ["./plain.mjs", "plain.mjs", "static"], + ["./space%20name.mjs", "space name.mjs", "static"], + ["./pr%C3%A9load.mjs", "préload.mjs", "static"], + ["./name%3F%23.mjs", "name?#.mjs", "static"], + ["./percent%2520.mjs?cache=1#v2", "percent%20.mjs", "static"], + ["./nested\\direct.mjs", "nested/direct.mjs", "static"], + ["./unused\\..\\nested\\parent.mjs", "nested/parent.mjs", "dynamic"], ] as const; const root = await createTestTempDirectory("rea-module-url-"); const main = join(root, "main.mjs"); + await mkdir(join(root, "nested")); await writeFile( main, cases - .map( - ([specifier], index) => - `import value${index} from ${JSON.stringify(specifier)}; console.log(value${index});`, + .map(([specifier, , mode], index) => + mode === "dynamic" + ? `const { default: value${index} } = await import(${JSON.stringify(specifier)}); console.log(value${index});` + : `import value${index} from ${JSON.stringify(specifier)}; console.log(value${index});`, ) .join("\n"), ); @@ -184,7 +189,11 @@ describe("ESM module URL decoding", () => { `export default ${JSON.stringify(target)};`, ); const encoded = specifier.slice(2).split("?", 1)[0]?.split("#", 1)[0]; - if (encoded !== undefined && encoded !== target) + if ( + encoded !== undefined && + encoded !== target && + (!encoded.includes("\\") || process.platform !== "win32") + ) await writeFile(join(root, encoded), 'export default "encoded decoy";'); } const native = await promisify(execFile)(process.execPath, [main], { @@ -212,6 +221,22 @@ describe("ESM module URL decoding", () => { for (const edge of imports) expect(edge.properties.resolved_path).toBe(target); } + + const historical = await importReferenceSource({ + root, + caller: "relative-module-separators-test", + policy: { secretPatterns: [] }, + }); + if (!historical.ok) throw historical.error; + for (const [, target] of cases) + expect(historical.value.relationships).toContainEqual( + expect.objectContaining({ + from_path: "main.mjs", + to: target, + kind: "imports", + resolution: "internal", + }), + ); }); it("preserves CommonJS literal percent names", () => {