diff --git a/src/application/javascript/ElectronBoundaryGraphContext.ts b/src/application/javascript/ElectronBoundaryGraphContext.ts index bd0598331a..cc96f92799 100644 --- a/src/application/javascript/ElectronBoundaryGraphContext.ts +++ b/src/application/javascript/ElectronBoundaryGraphContext.ts @@ -2,9 +2,10 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicat import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; -import type { - JavaScriptArtifactGraphContext, - JavaScriptArtifactGraphCoverage, +import { + sourceNodeFor, + type JavaScriptArtifactGraphContext, + type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; import { astObservationEvidence, @@ -38,10 +39,11 @@ export const electronFindingSourceNode = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, moduleKey: string | null, + runtime?: string, ): ApplicationNode | undefined => moduleKey === null ? (context.assetNodes.get(file.path) ?? context.fileNodes.get(file.path)) - : context.moduleNodes.get(`${file.path}\0${moduleKey}`); + : sourceNodeFor(context, file.path, moduleKey, runtime); /** Observation-scoped identity shared safely across different artifact files. */ export const electronObservationIdentity = ( diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index 43fdaeee59..d38ba48ae7 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -73,6 +73,7 @@ const addIpcRecord = ( context, record.file, record.finding.module_key, + record.finding.module_runtime, ); if (source === undefined) return; state.sources.set(record.key, source); @@ -255,6 +256,7 @@ const addValidationObservations = (state: IpcGraphState): void => { state.context, file, validation.module_key, + validation.module_runtime, ) : state.handlers.get(handlerRecord.key); if (target !== undefined) diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index 9fc0a85559..73645fbc52 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -38,7 +38,12 @@ export const addElectronNativeBoundaries = ( const addNativeBinding = (input: NativeBindingInput): void => { const { context, file, value, coverage } = input; - const source = electronFindingSourceNode(context, file, value.module_key); + const source = electronFindingSourceNode( + context, + file, + value.module_key, + value.module_runtime, + ); if (source === undefined) return; const resolved = resolveArtifactPath( value.specifier, diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index 7643062c53..f8d192db6e 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -39,17 +39,32 @@ export const addElectronWindowBoundaries = ( if (javascript === null) continue; const coverage = javascriptAnalysisCoverage(javascript); for (const value of javascript.electron.browser_windows) { - const source = electronFindingSourceNode(context, file, value.module_key); + const source = electronFindingSourceNode( + context, + file, + value.module_key, + value.module_runtime, + ); if (source !== undefined) addBrowserWindow({ context, file, source, value, coverage }); } for (const value of javascript.electron.context_bridge_apis) { - const source = electronFindingSourceNode(context, file, value.module_key); + const source = electronFindingSourceNode( + context, + file, + value.module_key, + value.module_runtime, + ); if (source !== undefined) addContextBridge({ context, file, source, value, coverage }); } for (const value of javascript.electron.utility_processes) { - const source = electronFindingSourceNode(context, file, value.module_key); + const source = electronFindingSourceNode( + context, + file, + value.module_key, + value.module_runtime, + ); if (source !== undefined) addUtilityProcess({ context, file, source, value, coverage }); } diff --git a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts index cce0d50ef7..960d660f42 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts @@ -161,7 +161,7 @@ const addBundlerModuleNodes = ( ], }); context.moduleNodes.set( - moduleLookupKey(file.path, moduleValue.module_key), + moduleLookupKey(file.path, registration.runtime, moduleValue.module_key), module, ); addAstContainsEdge(context, { @@ -195,7 +195,7 @@ const addBundlerRuntimeEdges = ( const { file, registration, chunk, coverage } = record; for (const moduleKey of registration.entry_module_keys) { const resolved = context.moduleNodes.get( - moduleLookupKey(file.path, moduleKey), + moduleLookupKey(file.path, registration.runtime, moduleKey), ); const target = resolved ?? unresolvedBundlerModuleNode(context, record, moduleKey); diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 0acc834efd..58b72bce77 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -323,8 +323,11 @@ export const selfReferenceOmissions = ( ]; /** Deterministic key for one recovered module inside a bundle asset. */ -export const moduleLookupKey = (path: string, moduleKey: string): string => - `${path}\0${moduleKey}`; +export const moduleLookupKey = ( + path: string, + runtime: string, + moduleKey: string, +): string => compositeKey([path, runtime, moduleKey]); /** Deterministic key for one recovered bundler chunk inside a bundle asset. */ export const chunkLookupKey = ( @@ -353,7 +356,10 @@ export const sourceNodeFor = ( context: JavaScriptArtifactGraphContext, path: string, moduleKey: string | null, + runtime?: string, ): ApplicationNode | undefined => moduleKey === null ? context.sourceModuleNodes.get(path) - : context.moduleNodes.get(moduleLookupKey(path, moduleKey)); + : runtime === undefined + ? undefined + : context.moduleNodes.get(moduleLookupKey(path, runtime, moduleKey)); diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index e7a002a6ae..bbe2adead6 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -78,8 +78,12 @@ const addReference = ( omissions: { selfReferences: number }, ): void => { const source = - sourceNodeFor(context, input.file.path, input.value.module_key) ?? - input.asset; + sourceNodeFor( + context, + input.file.path, + input.value.module_key, + input.value.module_runtime, + ) ?? input.asset; const resolved = input.value.specifier === null ? null @@ -162,8 +166,12 @@ const addEndpoint = ( input: FindingInput, ): void => { const source = - sourceNodeFor(context, input.file.path, input.value.module_key) ?? - input.asset; + sourceNodeFor( + context, + input.file.path, + input.value.module_key, + input.value.module_runtime, + ) ?? input.asset; const key = compactStringIdentityKey(input.value.value); const label = `${input.value.kind} endpoint`; const node = context.accumulator.addNode({ @@ -204,8 +212,12 @@ const addStorage = ( input: FindingInput, ): void => { const source = - sourceNodeFor(context, input.file.path, input.value.module_key) ?? - input.asset; + sourceNodeFor( + context, + input.file.path, + input.value.module_key, + input.value.module_runtime, + ) ?? input.asset; const node = context.accumulator.addNode({ kind: "storage", identity: artifactLocalIdentity( @@ -262,8 +274,12 @@ const addDiscoveredRole = ( coverage: input.coverage, }); const source = - sourceNodeFor(context, input.file.path, input.value.module_key) ?? - input.asset; + sourceNodeFor( + context, + input.file.path, + input.value.module_key, + input.value.module_runtime, + ) ?? input.asset; addStaticInferenceEdge(context, { source, target: role, @@ -359,9 +375,12 @@ const resolveReference = ( if (specifier === null) return null; const chunk = resolveBundlerChunkReference(context, sourcePath, reference); if (chunk !== null) return chunk; - const module = context.moduleNodes.get( - moduleLookupKey(sourcePath, specifier), - ); + const module = + reference.module_runtime === undefined + ? undefined + : context.moduleNodes.get( + moduleLookupKey(sourcePath, reference.module_runtime, specifier), + ); if (module !== undefined) { const file = context.filesByPath.get(sourcePath); return file === undefined @@ -407,10 +426,11 @@ const resolveBundlerChunkReference = ( if (file === undefined) return null; const registrations = context.analysis.files .find(({ file: analyzedFile }) => analyzedFile.path === sourcePath) - ?.javascript?.bundler_registrations.filter(({ modules }) => - reference.module_key === null - ? true - : modules.some( + ?.javascript?.bundler_registrations.filter(({ runtime, modules }) => + reference.module_runtime === undefined + ? reference.module_key === null + : runtime === reference.module_runtime && + modules.some( ({ module_key: moduleKey }) => moduleKey === reference.module_key, ), ); diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index d96ef690ea..6a4bbf0bff 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -39,6 +39,8 @@ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & { readonly web_preferences_status: "object-literal" | "dynamic" | "missing"; readonly web_preferences: readonly ElectronWebPreference[]; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; }; @@ -56,6 +58,8 @@ export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & { readonly members: readonly string[]; readonly unknown_members: number; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; }; @@ -109,6 +113,8 @@ type ElectronIpcHandler = interface ElectronIpcFindingContext { readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } @@ -134,12 +140,16 @@ export interface ElectronSenderValidationFinding { readonly expected: ElectronStaticValue; readonly enforcement: "unknown"; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } interface ElectronUtilityProcessFindingState { readonly service_name: string | null; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } @@ -169,6 +179,8 @@ export interface ElectronNativeAddonBindingFinding { readonly namespace_access?: boolean; readonly dynamic_member_access?: boolean; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index f11901c8c4..4e141b58e3 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -40,7 +40,7 @@ export const inspectBundlerRegistration = ( const table = entry.elements[1]; const runtimeValue = runtimeMetadata(entry.elements[2]); if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return; - const recovered = recoverBundlerModules(source, table, accumulator); + const recovered = recoverBundlerModules(source, table, runtime, accumulator); const chunkKeys = staticArrayValues(chunkIds); accumulator.unknownFindings += chunkKeys.unknown; accumulator.unknownFindings += @@ -92,12 +92,13 @@ export const inspectEsbuildWrapper = ( : null; const table = call.arguments[0]; if (wrapperKind === null || !t.isObjectExpression(table)) return; - const recovered = recoverBundlerModules(source, table, accumulator); + const runtime = `esbuild-${wrapperKind}`; + const recovered = recoverBundlerModules(source, table, runtime, accumulator); if (recovered.modules.length === 0) return; accumulator.unknownFindings += recovered.unknownAsyncChunkKeys; const registration: JavaScriptBundlerRegistration = { bundler: "esbuild", - runtime: `esbuild-${wrapperKind}`, + runtime, chunk_keys: [`${wrapperKind}@${String(call.start ?? 0)}`], unknown_chunk_keys: 0, runtime_require_name: null, @@ -127,13 +128,19 @@ interface RecoveredBundlerModules { const recoverBundlerModules = ( source: string, table: t.ObjectExpression, + runtime: string, accumulator: AnalysisAccumulator, ): RecoveredBundlerModules => { const modules: JavaScriptBundlerModule[] = []; const asyncChunkKeys: string[] = []; let unknownAsyncChunkKeys = 0; for (const property of table.properties) { - const recovered = recoverBundlerModule(source, property, accumulator); + const recovered = recoverBundlerModule( + source, + property, + runtime, + accumulator, + ); if (recovered === null) continue; modules.push(recovered.module); asyncChunkKeys.push(...recovered.asyncChunkKeys); @@ -149,6 +156,7 @@ const recoverBundlerModules = ( const recoverBundlerModule = ( source: string, property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement, + runtime: string, accumulator: AnalysisAccumulator, ): { readonly module: JavaScriptBundlerModule; @@ -169,6 +177,7 @@ const recoverBundlerModule = ( start: factory.start, end: factory.end, key, + runtime, requireName, }; accumulator.moduleRangeIndex.add(moduleRange); diff --git a/src/domain/javascript/javascriptStaticAnalysisFindings.ts b/src/domain/javascript/javascriptStaticAnalysisFindings.ts index 4108e7e726..149b3dc8e9 100644 --- a/src/domain/javascript/javascriptStaticAnalysisFindings.ts +++ b/src/domain/javascript/javascriptStaticAnalysisFindings.ts @@ -130,7 +130,11 @@ export const finalizeLocatedFindings = < values: readonly LocatedJavaScriptFinding[], modules: JavaScriptModuleRangeIndex, ): Value[] => - values.map(({ offset, value }) => ({ - ...value, - module_key: modules.find(offset)?.key ?? null, - })); + values.map(({ offset, value }) => { + const module = modules.find(offset); + return { + ...value, + module_key: module?.key ?? null, + ...(module === undefined ? {} : { module_runtime: module.runtime }), + }; + }); diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index 4c585786c2..ddee2ce32d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -6,9 +6,27 @@ import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; it("preserves first insertion precedence and inclusive interval bounds", () => { const nested = new JavaScriptModuleRangeIndex(100); expect(nested.find(0)).toBeUndefined(); - nested.add({ start: 10, end: 90, key: "outer-first", requireName: null }); - nested.add({ start: 20, end: 30, key: "inner-second", requireName: null }); - nested.add({ start: 30, end: 40, key: "later-overlap", requireName: null }); + nested.add({ + start: 10, + end: 90, + key: "outer-first", + runtime: "webpackChunkApp", + requireName: null, + }); + nested.add({ + start: 20, + end: 30, + key: "inner-second", + runtime: "webpackChunkApp", + requireName: null, + }); + nested.add({ + start: 30, + end: 40, + key: "later-overlap", + runtime: "webpackChunkApp", + requireName: null, + }); expect(nested.find(9)).toBeUndefined(); expect(nested.find(10)?.key).toBe("outer-first"); expect(nested.find(20)?.key).toBe("outer-first"); @@ -20,7 +38,13 @@ it("preserves first insertion precedence and inclusive interval bounds", () => { it("rejects missing and out-of-range lookup offsets", () => { const index = new JavaScriptModuleRangeIndex(20); - index.add({ start: 5, end: 10, key: "bounded", requireName: null }); + index.add({ + start: 5, + end: 10, + key: "bounded", + runtime: "webpackChunkApp", + requireName: null, + }); expect(index.find(undefined)).toBeUndefined(); expect(index.find(null)).toBeUndefined(); expect(index.find(-1)).toBeUndefined(); @@ -58,8 +82,11 @@ it("keeps module references attributed across many real factory ranges", () => { expect( analysis.references.find( ({ specifier }) => specifier === `dependency-${index}`, - )?.module_key, - ).toBe(`module-${index}`); + ), + ).toMatchObject({ + module_key: `module-${index}`, + module_runtime: "webpackChunkApp", + }); } expect( analysis.references.find( diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 297cbccd47..06a60a0bde 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -23,6 +23,7 @@ export interface JavaScriptModuleRange { readonly start: number; readonly end: number; readonly key: string; + readonly runtime: string; readonly requireName: string | null; } diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 4b99316de5..1e72deb1d7 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -25,6 +25,8 @@ interface JavaScriptStaticReferenceState { | "worker" | "service-worker"; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } @@ -41,6 +43,8 @@ export interface JavaScriptStaticEndpoint { readonly value: string; readonly mechanism: string; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } @@ -55,6 +59,8 @@ export interface JavaScriptStaticStorage { readonly name: string | null; readonly mechanism: string; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } @@ -95,6 +101,8 @@ export interface JavaScriptRolePath { readonly resolution_context: JavaScriptStaticPathContext; readonly mechanism: string; readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; readonly location: JavaScriptSourceRange; } diff --git a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts new file mode 100644 index 0000000000..2ad1529870 --- /dev/null +++ b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts @@ -0,0 +1,241 @@ +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const runtimeSource = ( + runtime: string, + name: string, + entry = 1, + dependency = 2, +) => ` + globalThis.${runtime}.push([["main"], { + ${String(entry)}: (module, exports, req) => { + req(${String(dependency)}); req.e("lazy"); fetch("/${name}"); + localStorage.setItem("${name}", "value"); + const { BrowserWindow } = require("electron"); new BrowserWindow({}); + }, + ${String(dependency)}: (module) => { module.exports = "${name}"; } + }, (req) => req(${String(entry)})]); + globalThis.${runtime}.push([["lazy"], {9: () => {}}]); +`; + +const appA = runtimeSource("webpackChunkAppA", "a"); +const appB = runtimeSource("webpackChunkAppB", "b"); + +cliTest( + "keeps source-derived runtime, module and chunk tuples collision-free", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundler-runtime-tuple-"); + const registrations = [ + { + runtime: "webpackChunkA", + module: "x\0y", + chunk: "x\0lazy", + endpoint: "/a", + }, + { + runtime: "webpackChunkA\0x", + module: "y", + chunk: "lazy", + endpoint: "/b", + }, + ]; + const source = registrations + .map( + ({ runtime, module, chunk, endpoint }) => ` + globalThis[${JSON.stringify(runtime)}].push([["main"], { + [${JSON.stringify(module)}]: (m, e, req) => { fetch(${JSON.stringify(endpoint)}); req.e(${JSON.stringify(chunk)}); } + }, (req) => req(${JSON.stringify(module)})]); + globalThis[${JSON.stringify(runtime)}].push([[${JSON.stringify(chunk)}], {}]); + `, + ) + .join("\n"); + await writeFixtureFiles(root, { "bundle.js": source }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + for (const { runtime, module, chunk, endpoint } of registrations) { + const entry = graph.edges.find( + (edge) => + edge.properties.kind === "bundler-entry-module" && + edge.properties.runtime === runtime, + ); + expect(nodes.get(entry?.target_node_id ?? "")?.identity).toMatchObject({ + namespace: `${runtime}:module`, + key: module, + }); + const call = graph.edges.find( + (edge) => + edge.relation === "calls" && + nodes.get(edge.target_node_id)?.observations[0]?.properties.value === + endpoint, + ); + expect(call?.source_node_id).toBe(entry?.target_node_id); + const lazy = graph.edges.find( + (edge) => + edge.source_node_id === entry?.target_node_id && + edge.properties.specifier === `chunk:${chunk}`, + ); + expect( + nodes.get(lazy?.target_node_id ?? "")?.observations[0]?.properties, + ).toMatchObject({ runtime, chunk_keys: [chunk] }); + } + }, +); + +for (const scenario of [ + { + name: "colliding module IDs", + source: appA + appB, + runtimes: ["webpackChunkAppA", "webpackChunkAppB"], + keys: [1, 1], + }, + { + name: "reversed registrations", + source: appB + appA, + runtimes: ["webpackChunkAppA", "webpackChunkAppB"], + keys: [1, 1], + }, + { + name: "single runtime", + source: appA, + runtimes: ["webpackChunkAppA"], + keys: [1], + }, + { + name: "disjoint module IDs", + source: appA + runtimeSource("webpackChunkAppB", "b", 3, 4), + runtimes: ["webpackChunkAppA", "webpackChunkAppB"], + keys: [1, 3], + }, + { + name: "Webpack and Rspack collision", + source: appA + runtimeSource("rspackChunkAppB", "b"), + runtimes: ["webpackChunkAppA", "rspackChunkAppB"], + keys: [1, 1], + }, +]) { + cliTest( + `keeps bundled relationships within their runtime for ${scenario.name}`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundler-runtime-scope-"); + await writeFixtureFiles(root, { "bundle.js": scenario.source }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + expect(graph.coverage.status).toBe("complete"); + for (const [index, runtime] of scenario.runtimes.entries()) { + const name = index === 0 ? "a" : "b"; + const key = String(scenario.keys[index]); + const module = graph.nodes.find( + (node) => + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === `${runtime}:module` && + node.identity.key === key, + ); + expect(module).toBeDefined(); + const entry = graph.edges.find( + (edge) => + edge.properties.kind === "bundler-entry-module" && + edge.properties.runtime === runtime, + ); + expect(entry?.target_node_id).toBe(module?.node_id); + expect(entry?.properties.resolution_status).toBe("resolved"); + const ownedEdges = graph.edges.filter( + (edge) => edge.source_node_id === module?.node_id, + ); + expect( + ownedEdges.find( + (edge) => + edge.relation === "calls" && + nodes.get(edge.target_node_id)?.observations[0]?.properties + .value === `/${name}`, + ), + ).toBeDefined(); + expect( + ownedEdges.find( + (edge) => + edge.relation === "persists_to" && + nodes.get(edge.target_node_id)?.observations[0]?.properties + .name === name, + ), + ).toBeDefined(); + expect( + ownedEdges.find( + (edge) => + edge.relation === "contains" && + nodes.get(edge.target_node_id)?.kind === "browser-window", + ), + ).toBeDefined(); + const dependency = ownedEdges.find( + (edge) => + edge.properties.kind === "require" && + edge.properties.specifier === String(Number(key) + 1), + ); + expect( + nodes.get(dependency?.target_node_id ?? "")?.identity, + ).toMatchObject({ + namespace: `${runtime}:module`, + key: String(Number(key) + 1), + }); + const lazy = ownedEdges.find( + (edge) => edge.properties.specifier === "chunk:lazy", + ); + expect( + nodes.get(lazy?.target_node_id ?? "")?.observations[0]?.properties, + ).toMatchObject({ runtime, chunk_keys: ["lazy"] }); + } + }, + ); +} + +cliTest( + "shares module IDs across chunks only within the same runtime", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundler-shared-runtime-"); + await writeFixtureFiles(root, { + "bundle.js": ` + globalThis.webpackChunkApp.push([["main"], {1: (module, exports, req) => { req(2); }} , (req) => req(1)]); + globalThis.webpackChunkApp.push([["other"], {2: (module) => { module.exports = "dependency"; }}]); + `, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const module = (key: string) => + graph.nodes.find( + (node) => + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === "webpackChunkApp:module" && + node.identity.key === key, + ); + expect( + graph.edges.find( + (edge) => + edge.properties.kind === "require" && + edge.properties.specifier === "2", + ), + ).toMatchObject({ + source_node_id: module("1")?.node_id, + target_node_id: module("2")?.node_id, + }); + }, +);