From ce845ba428e9b23d6b74eae1e25e17c37532adac Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:19:39 +0800 Subject: [PATCH] fix(javascript): preserve and resolve complete source-map URLs (#1788) Keep line-comment URL declarations intact and percent-decode local URL paths once, preserving raw Evidence identity and artifact-root confinement. Fixes #1781. Fixes #1782. Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning) --- .../JavaScriptArtifactPathResolution.test.ts | 27 +++ .../JavaScriptArtifactPathResolution.ts | 8 + .../javascriptStaticAnalysisCalls.ts | 4 +- .../applicationWorkflowCli.test.ts | 169 ++++++++++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index c83534ed92..9a79f84d14 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -1106,6 +1106,33 @@ describe("contextual JavaScript module identity", () => { }); describe("url-reference JavaScript artifact path resolution", () => { + it.each([ + "app%00.js.map", + "app%FF.js.map", + "app%C0%AF.js.map", + "app%2ejs.map", + "nested%2fworker.js", + "nested%5cworker.js", + "../../outside.js.map", + ])("rejects an inadmissible URL path: %s", (declaredPath) => { + const resolution = resolve({ + declaredPath, + sourcePath: "dist/app.js", + context: "url-reference", + files: fileMap([ + file("dist/app.js.map", "root"), + file("dist/nested/worker.js", "root"), + ]), + }); + expect(resolution).toMatchObject({ + declared_path: declaredPath, + resolution_context: "url-reference", + resolution_status: "rejected", + resolved_path: null, + }); + expect(resolution.limitations.length).toBeGreaterThan(0); + }); + it("resolves sibling paths and rejects external URL forms", () => { const files = fileMap([ file("dist/app.js", "root"), diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index bee94c841c..690192a79f 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -162,6 +162,14 @@ const contextualCandidate = ( return unresolvedCandidate("external", [ "URL schemes and protocol-relative URLs are outside this local artifact path context.", ]); + if (context === "url-reference") { + const decoded = percentDecodeUrlPath(declared); + if (decoded === null) + return unresolvedCandidate("rejected", [ + "The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.", + ]); + declared = decoded; + } return relativeCandidate(input.sourcePath, declared); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 5d339781b0..cf951f2f4b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -353,7 +353,9 @@ export const addSourceMapDirectives = ( const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u); if (urlOffset === -1) continue; const declaredStart = equalsStart + 1 + urlOffset; - const terminator = commentSource.slice(declaredStart).search(/[\s*]/u); + const terminator = commentSource + .slice(declaredStart) + .search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u); const declaredEnd = terminator === -1 ? commentSource.length : declaredStart + terminator; if (declaredEnd === declaredStart) continue; diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 065101ed83..2cc7919f18 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { copyFile, mkdir, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { join, relative, resolve } from "node:path"; @@ -445,6 +446,174 @@ describe("Electron unavailable identity through stdio MCP", () => { ); }); +it("preserves complete line-comment source-map URLs through CLI and MCP", async () => { + const root = await createTestTempDirectory("rea-source-map-url-cli-"); + const directives = [ + "//# sourceMappingURL=app.js.map?variant=a*modern", + "//@ sourceMappingURL=app.js.map?variant=a*legacy", + "//# sourceMappingURL=app.js.map#first*section", + ]; + await writeFile( + join(root, "app.js"), + ["export const value = 1;", ...directives, ""].join("\n"), + ); + await writeFile( + join(root, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }), + ); + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse( + await runCli(["analyze-javascript-application", root, "--json"]), + ).normalized_result; + const map = graph.nodes.find( + ({ kind, observations }) => + kind === "source-map" && + observations.some(({ properties }) => properties.path === "app.js.map"), + ); + const references = map?.observations.filter( + ({ label }) => label === "source map reference", + ); + expect(references).toHaveLength(directives.length); + for (const [index, directive] of directives.entries()) { + const declaredUrl = directive.slice(directive.indexOf("=") + 1); + const digest = createHash("sha256").update(declaredUrl).digest("hex"); + expect(references).toContainEqual( + expect.objectContaining({ + properties: { + declared_url: declaredUrl, + declared_url_sha256: digest, + resolved_path: "app.js.map", + available: true, + }, + evidence: expect.objectContaining({ + location: { + available: true, + value: { + kind: "source-range", + source: "app.js", + start: { line: index + 2, column: 0 }, + end: { line: index + 2, column: directive.length }, + }, + }, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "maps_to", + target_node_id: map?.node_id, + properties: { + declared_url_sha256: digest, + resolved_path: "app.js.map", + }, + }), + ); + } + expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph); +}, 20_000); + +it("resolves source-map URL paths once without changing their declarations", async () => { + const root = await createTestTempDirectory("rea-source-map-encoding-cli-"); + const cases = [ + ["space%20part.js.map", "space part.js.map"], + ["literal%25.js.map", "literal%.js.map"], + ["encoded%2520.js.map", "encoded%20.js.map"], + ["space%20part.js.map?v=1#section", "space part.js.map"], + ["caf%C3%A9.js.map", "café.js.map"], + ["literal%ZZ.js.map", "literal%ZZ.js.map"], + ] as const; + for (const path of new Set(cases.map(([, path]) => path))) { + await writeFile( + join(root, path), + JSON.stringify({ + version: 3, + file: path, + sources: [], + names: [], + mappings: "", + }), + ); + } + await writeFile( + join(root, "app.js"), + cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") + + 'new Worker("./worker%20file.js");\n' + + 'navigator.serviceWorker.register("./service%20worker.js");\n', + ); + await writeFile(join(root, "worker file.js"), "export const worker = 1;\n"); + await writeFile( + join(root, "service worker.js"), + "export const service = 2;\n", + ); + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse( + await runCli(["analyze-javascript-application", root, "--json"]), + ).normalized_result; + for (const [index, [declaredUrl, path]] of cases.entries()) { + const map = graph.nodes.find( + ({ kind, observations }) => + kind === "source-map" && + observations.some(({ properties }) => properties.path === path), + ); + const digest = createHash("sha256").update(declaredUrl).digest("hex"); + expect(map?.observations).toContainEqual( + expect.objectContaining({ + properties: { + declared_url: declaredUrl, + declared_url_sha256: digest, + resolved_path: path, + available: true, + }, + evidence: expect.objectContaining({ + location: { + available: true, + value: { + kind: "source-range", + source: "app.js", + start: { line: index + 1, column: 0 }, + end: { + line: index + 1, + column: "//# sourceMappingURL=".length + declaredUrl.length, + }, + }, + }, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "maps_to", + target_node_id: map?.node_id, + properties: { declared_url_sha256: digest, resolved_path: path }, + }), + ); + } + 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, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: worker?.node_id, + relation: "maps_to", + properties: { resolved_path: path }, + }), + ); + } + expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph); +}, 20_000); + describe("JavaScript application path CLI", () => { it("accepts a relative local application path and preserves canonical Evidence identity", async () => { const root = await createTestTempDirectory("rea-relative-application-cli-");