diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 4cb46cfd23..7f2ab5e5db 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -125,6 +125,10 @@ SHA-256 digests, inventory IDs, ASAR container identity, and `.asar.unpacked` status. Direct ASAR inputs and filesystem-backed ASAR files nested beneath a directory are supported. +JavaScript sources (`.js`, `.jsx`, `.mjs`, and `.cjs`) and TypeScript sources +(`.ts`, `.tsx`, `.mts`, and `.cts`) are parsed as inert text; analysis does not +execute them. + JavaScript and HTML source ranges retain an initial UTF-8 BOM as one UTF-16 code unit, matching the original bytes identified by the artifact digest. diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index 84107c366f..1e5a4e5c92 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -330,7 +330,7 @@ const relevantKind = (path: string): JavaScriptArtifactFileKind | undefined => { if (lower === "package.json" || lower.endsWith("/package.json")) return "package-json"; if (lower.endsWith(".json")) return "json"; - if (/\.(?:cjs|mjs|js|jsx|ts|tsx)$/u.test(lower)) return "javascript"; + if (/\.(?:cjs|cts|mjs|mts|js|jsx|ts|tsx)$/u.test(lower)) return "javascript"; if (/\.html?$/u.test(lower)) return "html"; if (lower.endsWith(".map")) return "source-map"; if (lower.endsWith(".node")) return "native-addon"; diff --git a/tests/boundary/cli/javascriptApplicationProgress.test.ts b/tests/boundary/cli/javascriptApplicationProgress.test.ts index 06fc1c8cb0..26bf3e5af9 100644 --- a/tests/boundary/cli/javascriptApplicationProgress.test.ts +++ b/tests/boundary/cli/javascriptApplicationProgress.test.ts @@ -72,6 +72,40 @@ cliTest( }, ); +for (const extension of ["mts", "cts"]) { + for (const command of ["analyze-javascript-application", "analyze"]) { + cliTest( + `${command} returns facts for a selected ${extension} source`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-nodenext-cli-"); + const path = `selected.${extension}`; + await writeFile( + join(root, path), + "export function selected_feature(value: string) { return { value }; }", + ); + const output = await cli.run({ arguments: [command, root, "--json"] }); + expect(output.exitCode).toBe(0); + const evidence = parseEvidence(output.json); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect(result.statistics).toMatchObject({ + relevant_files: 1, + parsed_javascript_files: 1, + parse_failures: 0, + }); + expect( + result.graph.nodes.some( + ({ kind, observations }) => + kind === "javascript-asset" && + observations.some(({ properties }) => properties.path === path), + ), + ).toBe(true); + }, + ); + } +} + for (const command of ["analyze-javascript-application", "analyze"]) { cliTest( `${command} retains HTML source ranges after a bare carriage return`, diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index 3f14ac5b40..ec9d47a077 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -453,3 +454,66 @@ const fixtureDirectory = async (): Promise => { await writeJavaScriptArtifactFixture(root); return root; }; + +for (const extension of ["ts", "tsx", "mts", "cts", "js", "mjs", "cjs"]) { + it.each(["directory", "asar"] as const)( + `retains ${extension} source facts from a real %s artifact`, + async (format) => { + const root = await createTestTempDirectory("rea-nodenext-artifacts-"); + const directory = join(root, "source"); + await mkdir(directory); + const path = `selected.${extension}`; + const parameter = ["ts", "tsx", "mts", "cts"].includes(extension) + ? "value: string" + : "value"; + const source = ` + globalThis.__rea_nodenext_executed = true; + throw new Error("fixture must remain inert"); + export function selected_feature(${parameter}) { return { value }; } + `; + await writeFile(join(directory, path), source); + let inputPath = directory; + if (format === "asar") { + inputPath = join(root, "application.asar"); + await createPackageWithOptions(directory, inputPath, {}); + } + Reflect.deleteProperty(globalThis, "__rea_nodenext_executed"); + const result = await reconstructJavaScriptArtifact({ + input_path: inputPath, + format, + }); + expect( + Reflect.get(globalThis, "__rea_nodenext_executed"), + ).toBeUndefined(); + expect(result.statistics).toMatchObject({ + relevant_files: 1, + parsed_javascript_files: 1, + text_bytes_read: Buffer.byteLength(source), + parse_failures: 0, + }); + const asset = result.graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-asset" && + observations.some(({ properties }) => properties.path === path), + ); + expect(asset?.identity).toMatchObject({ + sha256: createHash("sha256").update(source).digest("hex"), + }); + expect(asset?.observations[0]?.properties).toMatchObject({ + path, + bytes: Buffer.byteLength(source), + file_kind: "javascript", + parse_status: "complete", + }); + expect( + result.semantic_graph.nodes.some( + ({ identity }) => identity.module_path === path, + ), + ).toBe(true); + expect(result.graph.coverage).toMatchObject({ + status: "complete", + truncated: false, + }); + }, + ); +}