diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 148f7fb7e5..b098b3c26d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -429,7 +429,12 @@ export const addSourceMapDirectives = ( end === null ) continue; - const commentSource = source.slice(start, end); + // The parser owns comment boundaries. Exclude only the closing block + // delimiter; an asterisk elsewhere remains part of the declared URL. + const commentSource = source.slice( + start, + comment.type === "CommentBlock" ? end - 2 : end, + ); if ( !(commentSource.startsWith("//") || commentSource.startsWith("/*")) || !(commentSource[2] === "#" || commentSource[2] === "@") @@ -449,9 +454,7 @@ 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(commentSource.startsWith("//") ? /\s/u : /[\s*]/u); + const terminator = commentSource.slice(declaredStart).search(/\s/u); const declaredEnd = terminator === -1 ? commentSource.length : declaredStart + terminator; if (declaredEnd === declaredStart) continue; diff --git a/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts b/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts index f5664f73c4..6779d35bdd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisSourceMaps.test.ts @@ -72,10 +72,10 @@ describe("source map directives", () => { it("preserves Unicode whitespace and URL terminator boundaries", () => { const source = "ğ/*@\u00a0sourceMappingURL\u2003=\ufeffapp.js.map* ignored */"; - const declaredEnd = source.indexOf("* ignored"); + const declaredEnd = source.indexOf(" ignored"); expect(analyzeJavaScriptStaticSource(source).source_map_urls).toEqual([ { - declared_url: "app.js.map", + declared_url: "app.js.map*", location: { start: { line: 1, column: 1 }, end: { line: 1, column: declaredEnd }, @@ -84,6 +84,29 @@ describe("source map directives", () => { ]); }); + it.each([ + ["/*#", "app*part.js.map", " */"], + ["/*@", "*part.js.map", "*/"], + ["/*#", "app.js.map*", "*/"], + ["/*#", "**part.js.map", " */"], + ["/*#", "app.js.map", "*/"], + ["//#", "app*part.js.map", ""], + ])("preserves the URL and range in %s %s%s", (marker, url, suffix) => { + const directive = marker + " sourceMappingURL=" + url; + const result = analyzeJavaScriptStaticSource( + "export const value = 1;\n" + directive + suffix, + ); + expect(result.source_map_urls).toEqual([ + { + declared_url: url, + location: { + start: { line: 2, column: 0 }, + end: { line: 2, column: directive.length }, + }, + }, + ]); + }); + it("reports nothing when no directive is present", () => { expect(sourceMapUrls("const value = 1;")).toEqual([]); }); diff --git a/tests/acceptance/applications/blockSourceMapCli.test.ts b/tests/acceptance/applications/blockSourceMapCli.test.ts new file mode 100644 index 0000000000..957cd49c26 --- /dev/null +++ b/tests/acceptance/applications/blockSourceMapCli.test.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; + +import { createPackageWithOptions, listPackage } from "@electron/asar"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "links a block source-map URL containing an asterisk through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-block-map-"); + const directory = join(root, "input"); + await mkdir(directory); + const url = "app*part.js.map"; + const directive = "/*# sourceMappingURL=" + url; + await writeFile(join(directory, "app.js"), directive + "*/"); + await writeFile( + join(directory, "app_part.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }), + ); + const archive = join(root, "app.asar"); + await createPackageWithOptions(directory, archive, {}); + // ASAR logical names can contain '*' even on Windows. Rename this header + // key without changing entry bytes, sizes or payload offsets. + const bytes = await readFile(archive); + const key = Buffer.from('"app_part.js.map"'); + const offset = bytes.indexOf(key); + expect(offset).toBeGreaterThan(0); + expect(bytes.indexOf(key, offset + key.length)).toBe(-1); + Buffer.from('"' + url + '"').copy(bytes, offset); + await writeFile(archive, bytes); + expect(listPackage(archive, { isPack: false })).toContain(sep + url); + let document: unknown; + if (surface === "CLI") { + const result = await cli.run({ + arguments: ["analyze-javascript-application", archive, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + document = result.json; + } else { + const { call } = await connectLocalToolsMcp(); + const result = await call("analyze_javascript_application", { + input_path: archive, + format: "asar", + }); + expect(result.isError).not.toBe(true); + document = result.structuredContent; + } + const { + normalized_result: { graph }, + } = z + .object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }) + .parse(document); + const digest = createHash("sha256").update(url).digest("hex"); + const map = graph.nodes.find(({ observations }) => + observations.some(({ properties }) => properties.declared_url === url), + ); + expect( + map, + "the complete declared URL must identify the map", + ).toBeDefined(); + expect(map?.observations).toContainEqual( + expect.objectContaining({ + properties: { + declared_url: url, + declared_url_sha256: digest, + resolved_path: url, + available: true, + }, + evidence: expect.objectContaining({ + location: { + available: true, + value: { + kind: "source-range", + source: "app.js", + start: { line: 1, column: 0 }, + end: { line: 1, 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: url }, + }), + ); + }, +);