diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index cbf9e3c813..ce171beb9c 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -115,9 +115,18 @@ export const addJavaScriptArtifactContainers = ( export const addJavaScriptArtifactFiles = ( context: JavaScriptArtifactGraphContext, ): void => { + const packagesByPath = indexFirstByPath(context.analysis.packages); + const jsonModulesByPath = indexFirstByPath(context.analysis.json_modules); + const sourceMapsByPath = indexFirstByPath(context.analysis.source_maps); for (const analyzed of context.analysis.files) { const { file } = analyzed; - const target = createFileTarget(context, file, analyzed.javascript); + const jsonValue = jsonModulesByPath.get(file.path); + const target = createFileTarget( + context, + file, + analyzed.javascript, + jsonValue, + ); context.fileNodes.set(file.path, target); if (file.kind === "javascript") context.assetNodes.set(file.path, target); const entry = createAsarEntry(context, file); @@ -163,9 +172,7 @@ export const addJavaScriptArtifactFiles = ( ? "JavaScript syntax could not be parsed." : `JavaScript text was unavailable: ${file.text.reason}.`, }); - const packageValue = context.analysis.packages.find( - ({ path }) => path === file.path, - ); + const packageValue = packagesByPath.get(file.path); if (packageValue !== undefined && packageValue.status !== "included") addUnavailableStaticParseScope(context, { file, @@ -173,9 +180,6 @@ export const addJavaScriptArtifactFiles = ( operation: "parse-package-json", limitation: packageValue.limitation, }); - const jsonValue = context.analysis.json_modules.find( - ({ path }) => path === file.path, - ); if (jsonValue !== undefined && jsonValue.status !== "included") addUnavailableStaticParseScope(context, { file, @@ -183,9 +187,7 @@ export const addJavaScriptArtifactFiles = ( operation: "parse-json-module", limitation: jsonValue.limitation, }); - const sourceMap = context.analysis.source_maps.find( - ({ path }) => path === file.path, - ); + const sourceMap = sourceMapsByPath.get(file.path); if (sourceMap !== undefined && sourceMap.status === "invalid") addUnavailableStaticParseScope(context, { file, @@ -270,11 +272,9 @@ const createFileTarget = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, javascript: JavaScriptModuleArtifactAnalysis["files"][number]["javascript"], + json: JavaScriptModuleArtifactAnalysis["json_modules"][number] | undefined, ): ApplicationNode => { const kind = artifactFileNodeKind(file.kind); - const json = context.analysis.json_modules.find( - ({ path }) => path === file.path, - ); return context.accumulator.addNode({ kind, identity: { @@ -309,6 +309,16 @@ const createFileTarget = ( }); }; +const indexFirstByPath = ( + values: readonly Value[], +): ReadonlyMap => { + const index = new Map(); + for (const value of values) { + if (!index.has(value.path)) index.set(value.path, value); + } + return index; +}; + const createAsarEntry = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, diff --git a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts new file mode 100644 index 0000000000..afccab83de --- /dev/null +++ b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts @@ -0,0 +1,124 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { readJavaScriptArtifactFiles } from "../../support/javascriptApplicationScope.js"; +import { analyzeJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; +import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import type { + JavaScriptJsonModuleObservation, + JavaScriptPackageObservation, + JavaScriptSourceMapObservation, +} from "../../../src/application/javascript/JavaScriptArtifactAnalysisTypes.js"; + +it("uses the first structured observation when paths repeat", async () => { + const root = await createTestTempDirectory("rea-artifact-graph-index-"); + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ name: "fixture" })), + writeFile(join(root, "data.json"), JSON.stringify({ actual: true })), + writeFile(join(root, "app.js"), "//# sourceMappingURL=app.js.map"), + writeFile( + join(root, "app.js.map"), + JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "", + }), + ), + ]); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const packageObservation = analysis.packages.find( + ({ path }) => path === "package.json", + ); + const jsonObservation = analysis.json_modules.find( + ({ path }) => path === "data.json", + ); + const sourceMapObservation = analysis.source_maps.find( + ({ path }) => path === "app.js.map", + ); + expect(packageObservation).toBeDefined(); + expect(jsonObservation).toBeDefined(); + expect(sourceMapObservation).toBeDefined(); + if ( + packageObservation === undefined || + jsonObservation === undefined || + sourceMapObservation === undefined + ) + throw new TypeError("Expected structured artifact observations"); + + const graph = buildJavaScriptArtifactGraph(snapshot, files, { + ...analysis, + packages: [ + packageObservation, + { + ...packageObservation, + status: "invalid", + name: null, + version: null, + main: null, + renderer: null, + limitation: "later duplicate", + } satisfies JavaScriptPackageObservation, + ], + json_modules: [ + jsonObservation, + { + ...jsonObservation, + status: "invalid", + top_level_keys: [], + omitted_top_level_keys: 0, + limitation: "later duplicate", + } satisfies JavaScriptJsonModuleObservation, + ], + source_maps: [ + sourceMapObservation, + { + ...sourceMapObservation, + status: "invalid", + sources: [], + limitation: "later duplicate", + } satisfies JavaScriptSourceMapObservation, + ], + }); + + const dataFile = graph.nodes.find(({ observations }) => + observations.some(({ properties }) => properties.path === "data.json"), + ); + expect(dataFile?.observations[0]?.properties).toMatchObject({ + json_parse_status: "included", + json_top_level_keys: ["actual"], + omitted_json_top_level_keys: 0, + }); + const unavailablePaths = graph.nodes + .filter(({ kind }) => kind === "unknown") + .flatMap(({ observations }) => + observations.map(({ properties }) => ({ + path: properties.path, + operation: properties.operation, + })), + ); + expect(unavailablePaths).not.toContainEqual({ + path: "package.json", + operation: "parse-package-json", + }); + expect(unavailablePaths).not.toContainEqual({ + path: "data.json", + operation: "parse-json-module", + }); + expect(unavailablePaths).not.toContainEqual({ + path: "app.js.map", + operation: "parse-local-source-map", + }); + } finally { + await reader.close(); + } +});