diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index e3bf4c6e81..9158ff906f 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -268,11 +268,12 @@ const addSourceMapEdge = ( context: JavaScriptArtifactGraphContext, input: FindingInput, ): void => { - const resolvedPath = resolveArtifactPath( - input.value.declared_url, - input.file.path, - context.filesByPath, - ); + const resolvedPath = resolveArtifactPathByContext({ + declaredPath: input.value.declared_url, + sourcePath: input.file.path, + context: "url-reference", + files: context.filesByPath, + }).resolved_path; const resolvedFile = resolvedPath === null ? undefined : context.filesByPath.get(resolvedPath); const resolvedNode = @@ -341,17 +342,26 @@ const resolveReference = ( ? null : { path: `${sourcePath}#module:${specifier}`, file, node: module }; } - const path = resolveArtifactPath( - specifier, - sourcePath, - context.filesByPath, - reference.kind === "require" - ? "require" - : reference.kind === "static-import" || - reference.kind === "dynamic-import" - ? "import" - : undefined, - ); + // Worker scripts are URLs relative to the script, not module specifiers. + const path = + reference.kind === "worker" || reference.kind === "service-worker" + ? resolveArtifactPathByContext({ + declaredPath: specifier, + sourcePath, + context: "url-reference", + files: context.filesByPath, + }).resolved_path + : resolveArtifactPath( + specifier, + sourcePath, + context.filesByPath, + reference.kind === "require" + ? "require" + : reference.kind === "static-import" || + reference.kind === "dynamic-import" + ? "import" + : undefined, + ); if (path === null) return null; const file = context.filesByPath.get(path); const node = context.fileNodes.get(path); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 3f6232def5..5223926e83 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -562,6 +562,61 @@ describe("contextual JavaScript module identity", () => { }); }); +describe("url-reference JavaScript artifact path resolution", () => { + it("resolves sibling paths and rejects external URL forms", () => { + const files = fileMap([ + file("dist/app.js", "root"), + file("dist/app.js.map", "root"), + file("dist/worker.js", "root"), + ]); + + expect( + resolve({ + declaredPath: "app.js.map", + sourcePath: "dist/app.js", + context: "url-reference", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "dist/app.js.map", + }); + expect( + resolve({ + declaredPath: "app.js.map?v=1#section", + sourcePath: "dist/app.js", + context: "url-reference", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "dist/app.js.map", + }); + expect( + resolve({ + declaredPath: "//cdn.example.com/app.js.map", + sourcePath: "dist/app.js", + context: "url-reference", + files, + }), + ).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + expect( + resolve({ + declaredPath: "https://cdn.example.com/app.js.map", + sourcePath: "dist/app.js", + context: "url-reference", + files, + }), + ).toMatchObject({ + resolution_status: "external", + resolved_path: null, + }); + }); +}); + const resolve = (input: ResolveArtifactPathInput) => resolveArtifactPathByContext(input); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 57a5012b02..3450ffd20b 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -13,7 +13,8 @@ type ArtifactPathResolutionContext = | "package-entrypoint" | "filesystem-expression" | "module-specifier" - | "html-reference"; + | "html-reference" + | "url-reference"; type UnresolvedArtifactPathStatus = | "not-found" @@ -127,6 +128,7 @@ const contextualCandidate = ( const { context } = input; const declared = context === "html-reference" || + context === "url-reference" || (context === "module-specifier" && input.moduleKind !== "require") ? stripQueryAndFragment(input.declaredPath) : input.declaredPath; @@ -140,9 +142,9 @@ const contextualCandidate = ( ]); if (!declared.startsWith(".") && !declared.startsWith("/")) return bareModuleCandidate(input, declared); - } else if (hasScheme(declared)) + } else if (looksExternal(declared)) return unresolvedOutcome(input, "external", [ - "URL schemes are outside this local artifact path context.", + "URL schemes and protocol-relative URLs are outside this local artifact path context.", ]); const relative = declared.startsWith("/") ? declared.slice(1) : declared; return declared.startsWith("/") diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index 089e89da57..e8be59a4b0 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -42,3 +42,59 @@ it.each(["\n", "\r\n", "\r", "\u2028", "\u2029"])( }); }, ); + +it("resolves a bare source-map file name and worker URL next to the script", async () => { + const root = await createTestTempDirectory("rea-source-map-relative-"); + await mkdir(join(root, "dist")); + await writeFile( + join(root, "dist", "app.js"), + [ + 'new Worker("worker.js");', + 'navigator.serviceWorker.register("sw.js");', + "//# sourceMappingURL=app.js.map", + ].join("\n"), + ); + await writeFile( + join(root, "dist", "worker.js"), + "self.onmessage = () => {};", + ); + await writeFile(join(root, "dist", "sw.js"), "self.onfetch = () => {};"); + await writeFile( + join(root, "dist", "app.js.map"), + JSON.stringify({ + version: 3, + sources: ["../src/app.js"], + sourcesContent: ['new Worker("worker.js");'], + names: [], + mappings: "AAAA", + }), + ); + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const nodes = new Map(result.graph.nodes.map((node) => [node.node_id, node])); + const mapEdge = result.graph.edges.find( + ({ relation, properties }) => + relation === "maps_to" && properties.declared_url === "app.js.map", + ); + expect(mapEdge?.properties).toMatchObject({ + declared_url: "app.js.map", + resolved_path: "dist/app.js.map", + }); + expect(nodes.get(mapEdge?.target_node_id ?? "")?.identity.strategy).toBe( + "content-digest", + ); + const workerEdge = result.graph.edges.find( + ({ relation, source_node_id }) => + relation === "maps_to" && nodes.get(source_node_id)?.kind === "worker", + ); + expect(workerEdge?.properties).toMatchObject({ + resolved_path: "dist/worker.js", + }); + const serviceWorkerEdge = result.graph.edges.find( + ({ relation, source_node_id }) => + relation === "maps_to" && + nodes.get(source_node_id)?.kind === "service-worker", + ); + expect(serviceWorkerEdge?.properties).toMatchObject({ + resolved_path: "dist/sw.js", + }); +});