From af6a9980669129aae882c9216766fc8d9f544079 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:13:17 -0700 Subject: [PATCH] fix(javascript): keep string Worker paths unresolved without a document base (#3797) A plain new Worker("./w.js") or navigator.serviceWorker.register("./sw.js") resolves against the document base URL in a browser, and a Node worker_threads string resolves against the working directory. Static analysis was resolving browser strings against the calling script, which recorded a resolved mapping to the wrong file. Only new Worker(new URL(path, import.meta.url)) now produces a specifier. Other worker and service-worker strings keep their expression and stay unresolved, matching the existing worker_threads string handling. Fixes #3309 Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 5 +- ...scriptStaticAnalysisCalleeBoundary.test.ts | 5 +- .../javascriptStaticAnalysisCalls.ts | 18 ++++-- .../applicationWorkflowCli.test.ts | 54 ++++++++++------- .../javascriptWorkerUrlCli.test.ts | 60 ++++++++++++++++--- ...javascriptApplicationEmptyLiterals.test.ts | 4 +- ...ascriptApplicationTemplateLiterals.test.ts | 2 +- .../javascriptSourceMapLocations.test.ts | 20 +++---- .../javascriptRuntimeReconciliation.ts | 2 +- 9 files changed, 119 insertions(+), 51 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 07548202e9..e66d52d724 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -223,7 +223,10 @@ Selected bounded text is then parsed as inert data to recover: - Webpack and Rspack chunk registrations and module factories represented as AST literals; - static imports, dynamic imports, CommonJS `require` calls, workers, and - service workers; + service workers (only `new Worker(new URL(path, import.meta.url))` resolves + relative to the calling module; string worker and service-worker paths use + the document base URL or Node's working directory, which static analysis + does not establish, so they keep their expression and stay unresolved); - route, network endpoint, storage, and vendor-marker observations; - local source-map declarations and original source names and content digests; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 477bef1ac4..cb602ca0f1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -27,11 +27,12 @@ describe("static callee boundary matching", () => { ["./bare.html", "call:loadFile"], ["https://optional.example.test", "call:win.loadURL"], ]); + // The document base URL is unknown, so the script path stays unresolved. expect( analysis.references .filter(({ kind }) => kind === "service-worker") - .map(({ specifier }) => specifier), - ).toEqual(["./worker.js"]); + .map(({ specifier, expression }) => [specifier, expression]), + ).toEqual([[null, 'navigator.serviceWorker.register("./worker.js")']]); expect( analysis.endpoints .filter(({ mechanism }) => mechanism.endsWith("loadURL")) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 816ba3eaf2..16a123b8b5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -95,19 +95,25 @@ export const inspectCall = ( addReference(context, { node, kind: "worker", - // Node string paths use the process cwd or filesystem root, neither of - // which is established by the analyzed source's artifact location. + // A string path is relative to the document base URL in a browser and + // to the process cwd in Node; the analyzed source's artifact location + // establishes neither. Only `new URL(path, import.meta.url)` names a + // location relative to this module. specifier: - (workerConstructor === "node" ? undefined : first) ?? - (workerConstructor === undefined || worker === undefined + workerConstructor === undefined || worker === undefined ? undefined - : workerUrlSpecifier(worker, bindings)), + : workerUrlSpecifier(worker, bindings), }); + // Service worker script URLs are also relative to the document base URL. if ( name === "serviceWorker.register" || name.endsWith(".serviceWorker.register") ) - addReference(context, { node, kind: "service-worker", specifier: first }); + addReference(context, { + node, + kind: "service-worker", + specifier: undefined, + }); inspectEndpointCall(node, name, first, context); inspectStorageCall(node, name, first, context); if (first === "audio" || first === "video") diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index eefa8a7f95..da514f2921 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -541,7 +541,7 @@ it("resolves source-map URL paths once without changing their declarations", asy await writeFile( join(root, "app.js"), cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") + - 'new Worker("./worker%20file.js");\n' + + 'new Worker(new URL("./worker%20file.js", import.meta.url));\n' + 'navigator.serviceWorker.register("./service%20worker.js");\n', ); await writeFile(join(root, "worker file.js"), "export const worker = 1;\n"); @@ -598,27 +598,41 @@ it("resolves source-map URL paths once without changing their declarations", asy }), ); } - for (const [kind, specifier, path] of [ - ["worker", "./worker%20file.js", "worker file.js"], - ["service-worker", "./service%20worker.js", "service worker.js"], - ]) { - const worker = graph.nodes.find((node) => node.kind === kind); - expect(worker?.observations).toContainEqual( - expect.objectContaining({ - properties: expect.objectContaining({ - declared_specifier: specifier, - resolved_path: path, - }), + const worker = graph.nodes.find((node) => node.kind === "worker"); + expect(worker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: "./worker%20file.js", + resolved_path: "worker file.js", }), - ); - expect(graph.edges).toContainEqual( - expect.objectContaining({ - source_node_id: worker?.node_id, - relation: "maps_to", - properties: { resolved_path: path }, + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: worker?.node_id, + relation: "maps_to", + properties: { resolved_path: "worker file.js" }, + }), + ); + // A service worker script URL is relative to the unestablished document base. + const serviceWorker = graph.nodes.find( + (node) => node.kind === "service-worker", + ); + expect(serviceWorker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression: 'navigator.serviceWorker.register("./service%20worker.js")', + resolved_path: null, }), - ); - } + }), + ); + expect( + graph.edges.some( + ({ source_node_id, relation }) => + source_node_id === serviceWorker?.node_id && relation === "maps_to", + ), + ).toBe(false); expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph); }, 20_000); diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts index 977083c760..1039f5d3a1 100644 --- a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts +++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts @@ -279,12 +279,6 @@ cliTest( specifier: "./worker.js", resolved: "bundled/worker.js", }, - { - source: "nested/literal.mjs", - expression: 'new Worker("./literal-worker.js");', - specifier: "./literal-worker.js", - resolved: "nested/literal-worker.js", - }, ]; await writeFixtureFiles(root, { ...Object.fromEntries( @@ -431,9 +425,6 @@ cliTest( "directory-dot.mjs": "./worker.js/.", "directory-parent.mjs": "./worker.js/child/..", "empty-segment.mjs": "./child//../worker.js", - "empty-worker-url.mjs": "", - "query-only-worker-url.mjs": "?cache=1", - "fragment-only-worker-url.mjs": "#worker", "package.mjs": "./package-worker", "external.mjs": "https://example.test/worker.js", }; @@ -478,6 +469,57 @@ cliTest( }, ); +cliTest( + "keeps string Worker paths unresolved because the document base is unknown", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-document-base-"); + // A browser resolves these strings against the document base URL, not the + // calling script; Node resolves them against the working directory. + const expressions = { + "scripts/main.js": 'new Worker("./worker.js")', + "scripts/service.js": 'navigator.serviceWorker.register("./sw.js")', + }; + await writeFixtureFiles(root, { + "index.html": '', + ...Object.fromEntries( + Object.entries(expressions).map(([path, code]) => [path, `${code};`]), + ), + "worker.js": "self.postMessage('document-relative worker');", + "scripts/worker.js": "self.postMessage('script-relative decoy');", + "scripts/sw.js": "self.postMessage('script-relative decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + for (const [source, expression] of Object.entries(expressions)) { + const node = result.graph.nodes.find( + ({ kind, observations }) => + (kind === "worker" || kind === "service-worker") && + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ), + ); + expect(node?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression, + resolved_path: null, + }), + }), + ); + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === node?.node_id, + ), + ).toBe(false); + } + }, +); + cliTest( "bounds repeated ambient alias branches through the compiled CLI", async ({ cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 8387acdaae..a575e41a8a 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -148,7 +148,9 @@ it("analyzes a plain long-line loader without package metadata", async () => { for (const node of nodes) { expect(node.identity).toMatchObject({ strategy: "artifact-local-key", - key: node.kind === "endpoint" ? literalEndpointKey("") : "", + // A Worker string is relative to the unestablished document base, so + // its identity is the retained expression rather than a specifier. + key: node.kind === "endpoint" ? literalEndpointKey("") : 'new Worker("")', }); expect(node.observations.map(({ label }) => label)).toEqual([null]); } diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts index 55807d6de7..1e1f4d970b 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts @@ -55,7 +55,7 @@ const graphIdentities = async (source: string) => { it.each([ "fetch('https://api.example.com/v1/users');", "localStorage.setItem('token', 1);", - "new Worker('./worker.js');", + "new Worker(new URL('./worker.js', import.meta.url));", "const dep = require('./dep.js'); dep();", "import('./dep.js');", ])( diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index d9d4c8ffb5..21d5719e06 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -168,13 +168,13 @@ it("associates each source-map URL with its edge when aliases share one physical }); }); -it("resolves a bare source-map file name and worker URL next to the script", async () => { +it("resolves a bare source-map file name and module 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");', + 'new Worker(new URL("worker.js", import.meta.url));', 'navigator.serviceWorker.register("sw.js");', "//# sourceMappingURL=app.js.map", ].join("\n"), @@ -228,12 +228,12 @@ it("resolves a bare source-map file name and worker URL next to the script", asy 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", - }); + // A service worker URL is relative to the unestablished document base. + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && + nodes.get(source_node_id)?.kind === "service-worker", + ), + ).toBe(false); }); diff --git a/tests/fixtures/javascriptRuntimeReconciliation.ts b/tests/fixtures/javascriptRuntimeReconciliation.ts index baca10aeb6..2f0310ce46 100644 --- a/tests/fixtures/javascriptRuntimeReconciliation.ts +++ b/tests/fixtures/javascriptRuntimeReconciliation.ts @@ -7,7 +7,7 @@ import { createWebTextArtifact } from "../../src/domain/webContentArtifact.js"; import { analyzeJavaScriptApplication } from "../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "./temporaryDirectory.js"; -export const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; +export const SOURCE = `const worker = new Worker(new URL("./worker.js", import.meta.url));\nexport const observed = worker;\n`; export const applicationFixture = async (): Promise => { const root = await createTestTempDirectory("rea-runtime-reconciliation-");