From 72201668ed88c2cc822a07c08cf35a1e5ba88f35 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:37:37 +0800 Subject: [PATCH] fix(javascript): preserve graphs with async chunk self-references (#1152) --- .../JavaScriptArtifactGraphBuilder.ts | 11 +-- .../JavaScriptArtifactGraphBundlers.ts | 21 ++++- .../javascriptBundlerSelfReferences.test.ts | 91 +++++++++++++++++++ 3 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 tests/boundary/cli/javascriptBundlerSelfReferences.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 70712207dc..78cb0f4b63 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -88,7 +88,7 @@ const buildJavaScriptArtifactGraphInput = ( addJavaScriptArtifactFiles(context); const packageRoots = addJavaScriptPackageNodes(context); addJavaScriptSourceModules(context); - addJavaScriptBundlerNodes(context); + const bundlerLimitations = addJavaScriptBundlerNodes(context); const relationshipOmissions = addJavaScriptModuleRelationships(context); addJavaScriptStaticFindings(context); addElectronBoundaries(context); @@ -104,11 +104,10 @@ const buildJavaScriptArtifactGraphInput = ( nodes: accumulator.nodes(), edges: accumulator.edges(), coverage, - limitations: graphLimitations( - context, - coverage.status, - relationshipOmissions, - ), + limitations: [ + ...bundlerLimitations, + ...graphLimitations(context, coverage.status, relationshipOmissions), + ], }; }; diff --git a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts index c23182d9bb..21ea83e157 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts @@ -15,10 +15,11 @@ import { staticInferenceEvidence, } from "./JavaScriptArtifactGraphEvidence.js"; -/** Project Webpack/Rspack chunk and factory literals recovered from AST. */ +/** Project Webpack/Rspack AST literals and disclose unrepresentable relationships. */ export const addJavaScriptBundlerNodes = ( context: JavaScriptArtifactGraphContext, -): void => { +): readonly string[] => { + let selfAsyncChunks = 0; for (const analyzed of context.analysis.files) { const { file, javascript } = analyzed; const asset = context.assetNodes.get(file.path); @@ -49,8 +50,14 @@ export const addJavaScriptBundlerNodes = ( records.push({ file, registration, chunk, coverage }); addBundlerModuleNodes(context, { ...input, chunk }); } - for (const record of records) addBundlerRuntimeEdges(context, record); + for (const record of records) + selfAsyncChunks += addBundlerRuntimeEdges(context, record); } + return selfAsyncChunks === 0 + ? [] + : [ + `${String(selfAsyncChunks)} bundler async-chunk ${selfAsyncChunks === 1 ? "reference" : "references"} resolved back to the requesting chunk itself and ${selfAsyncChunks === 1 ? "was" : "were"} omitted; application graph edges cannot be self-referential.`, + ]; }; interface BundlerProjectionInput { @@ -177,7 +184,8 @@ interface BundlerGraphRecord { const addBundlerRuntimeEdges = ( context: JavaScriptArtifactGraphContext, record: BundlerGraphRecord, -): void => { +): number => { + let selfAsyncChunks = 0; const { file, registration, chunk, coverage } = record; for (const moduleKey of registration.entry_module_keys) { const resolved = context.moduleNodes.get( @@ -209,6 +217,10 @@ const addBundlerRuntimeEdges = ( for (const chunkKey of registration.async_chunk_keys) { const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey); const resolved = context.chunkNodes.get(lookup); + if (resolved?.node_id === chunk.node_id) { + selfAsyncChunks++; + continue; + } const target = resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey); context.accumulator.addEdge({ @@ -232,6 +244,7 @@ const addBundlerRuntimeEdges = ( }), }); } + return selfAsyncChunks; }; const unresolvedBundlerModuleNode = ( diff --git a/tests/boundary/cli/javascriptBundlerSelfReferences.test.ts b/tests/boundary/cli/javascriptBundlerSelfReferences.test.ts new file mode 100644 index 0000000000..26555c417a --- /dev/null +++ b/tests/boundary/cli/javascriptBundlerSelfReferences.test.ts @@ -0,0 +1,91 @@ +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 { + findChunkNode, + findGraphEdge, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; + +for (const scenario of [ + { runtime: "webpackChunkdemo", keys: [1], omitted: 1 }, + { runtime: "rspackChunkdemo", keys: [1, 2, 3], omitted: 3 }, +] as const) { + cliTest( + `preserves ${scenario.runtime} evidence when a chunk requests its own IDs`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundler-self-"); + await writeFixtureFiles(root, { + "runtime.js": ` + (self.${scenario.runtime} = self.${scenario.runtime} || []).push([ + ${JSON.stringify(scenario.keys)}, + {10(e,t,r){ + ${scenario.keys.map((key) => `r.e(${String(key)});`).join("\n")} + r.e(4); r.e(99); + }} + ]); + (self.${scenario.runtime} = self.${scenario.runtime} || []).push([ + [4], {40(e,t,r){r.r(t)}} + ]); + `, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const { graph } = result; + expect( + graph.edges.filter( + (edge) => edge.source_node_id === edge.target_node_id, + ), + ).toEqual([]); + expect(graph.limitations).toContainEqual( + expect.stringMatching( + new RegExp( + `^${String(scenario.omitted)} bundler async-chunk references? .*omitted;`, + "u", + ), + ), + ); + expect(result.limitations).toEqual( + expect.arrayContaining(graph.limitations), + ); + const source = findChunkNode(graph, scenario.runtime, "1"); + expect(source?.observations[0]?.properties.async_chunk_keys).toEqual( + expect.arrayContaining([...scenario.keys.map(String), "4", "99"]), + ); + expect(source?.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "runtime.js" }, + }); + expect( + findGraphEdge( + graph, + source, + findChunkNode(graph, scenario.runtime, "4"), + "imports", + )?.properties, + ).toMatchObject({ + kind: "bundler-async-chunk", + chunk_key: "4", + resolution_status: "resolved", + }); + expect( + graph.edges.find( + (edge) => + edge.source_node_id === source?.node_id && + edge.properties.chunk_key === "99", + )?.properties, + ).toMatchObject({ + kind: "bundler-async-chunk", + resolution_status: "not-found", + }); + }, + ); +}