Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c06444ad67 fix(javascript): omit self-referential bundler edges with disclosure
A webpack/Rspack chunk file that lazily requests one of its own chunk
ids (`__webpack_require__.e(<own id>)`, including merged multi-id
chunks) produced a bundler-async-chunk edge from the chunk to itself. A
bundled module that requires its own key (`r(50)` inside module 50) did
the same through the static-reference composer. The application graph
forbids self-referential edges, so one such edge failed result
validation and lost the entire analysis ("Result schema rejected ...
(custom)").

Apply the treatment #1130 gave self-imports to both composers: skip the
edge, count it, and disclose the omission in the graph limitations. The
shared helper keeps the existing self-import wording.

Fixes #1146

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:27:09 +00:00
4 changed files with 169 additions and 18 deletions
@@ -12,7 +12,11 @@ import {
addJavaScriptHtmlRoles, addJavaScriptHtmlRoles,
addJavaScriptSourceMapOriginals, addJavaScriptSourceMapOriginals,
} from "./JavaScriptArtifactGraphDocuments.js"; } from "./JavaScriptArtifactGraphDocuments.js";
import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js"; import {
addJavaScriptStaticFindings,
type JavaScriptStaticFindingOmissions,
} from "./JavaScriptArtifactGraphFindings.js";
import type { JavaScriptBundlerOmissions } from "./JavaScriptArtifactGraphBundlers.js";
import { import {
addJavaScriptModuleRelationships, addJavaScriptModuleRelationships,
type JavaScriptModuleRelationshipOmissions, type JavaScriptModuleRelationshipOmissions,
@@ -88,9 +92,9 @@ const buildJavaScriptArtifactGraphInput = (
addJavaScriptArtifactFiles(context); addJavaScriptArtifactFiles(context);
const packageRoots = addJavaScriptPackageNodes(context); const packageRoots = addJavaScriptPackageNodes(context);
addJavaScriptSourceModules(context); addJavaScriptSourceModules(context);
addJavaScriptBundlerNodes(context); const bundlerOmissions = addJavaScriptBundlerNodes(context);
const relationshipOmissions = addJavaScriptModuleRelationships(context); const relationshipOmissions = addJavaScriptModuleRelationships(context);
addJavaScriptStaticFindings(context); const findingOmissions = addJavaScriptStaticFindings(context);
addElectronBoundaries(context); addElectronBoundaries(context);
addJavaScriptHtmlRoles(context); addJavaScriptHtmlRoles(context);
addJavaScriptSourceMapOriginals(context); addJavaScriptSourceMapOriginals(context);
@@ -104,11 +108,11 @@ const buildJavaScriptArtifactGraphInput = (
nodes: accumulator.nodes(), nodes: accumulator.nodes(),
edges: accumulator.edges(), edges: accumulator.edges(),
coverage, coverage,
limitations: graphLimitations( limitations: graphLimitations(context, coverage.status, {
context, ...relationshipOmissions,
coverage.status, ...bundlerOmissions,
relationshipOmissions, ...findingOmissions,
), }),
}; };
}; };
@@ -173,10 +177,47 @@ const applicationGraphResourceLimit = (
: ("items" as const), : ("items" as const),
}); });
type SelfReferenceOmissions = JavaScriptModuleRelationshipOmissions &
JavaScriptBundlerOmissions &
JavaScriptStaticFindingOmissions;
/** Disclose each relationship omitted because it would reference itself. */
const selfReferenceLimitations = (
omissions: SelfReferenceOmissions,
): string[] =>
(
[
[
omissions.selfImports,
"import specifier",
"import specifiers",
"the importing module",
],
[
omissions.selfAsyncChunks,
"bundler async-chunk request",
"bundler async-chunk requests",
"the requesting chunk",
],
[
omissions.selfReferences,
"static reference",
"static references",
"the referencing module",
],
] as const
).flatMap(([count, singular, plural, owner]) =>
count === 0
? []
: [
`${String(count)} ${count === 1 ? singular : plural} resolved back to ${owner} itself and ${count === 1 ? "was" : "were"} omitted; application graph edges cannot be self-referential.`,
],
);
const graphLimitations = ( const graphLimitations = (
context: JavaScriptArtifactGraphContext, context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable", coverage: "complete" | "partial" | "unknown" | "unavailable",
relationshipOmissions: JavaScriptModuleRelationshipOmissions, omissions: SelfReferenceOmissions,
): string[] => { ): string[] => {
const ipc = collectElectronIpcRecords(context.analysis); const ipc = collectElectronIpcRecords(context.analysis);
const pairings = classifyElectronIpcPairings(ipc); const pairings = classifyElectronIpcPairings(ipc);
@@ -195,11 +236,7 @@ const graphLimitations = (
); );
return [ return [
...context.analysis.limitations, ...context.analysis.limitations,
...(relationshipOmissions.selfImports > 0 ...selfReferenceLimitations(omissions),
? [
`${String(relationshipOmissions.selfImports)} import ${relationshipOmissions.selfImports === 1 ? "specifier" : "specifiers"} resolved back to the importing module itself and ${relationshipOmissions.selfImports === 1 ? "was" : "were"} omitted; application graph edges cannot be self-referential.`,
]
: []),
"CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.", "CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.",
"Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.", "Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.",
"Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.", "Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.",
@@ -15,10 +15,17 @@ import {
staticInferenceEvidence, staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js"; } from "./JavaScriptArtifactGraphEvidence.js";
/** Bundler compositions the application graph cannot represent, for disclosure. */
export interface JavaScriptBundlerOmissions {
/** Async chunk requests that resolved back to the requesting chunk itself. */
readonly selfAsyncChunks: number;
}
/** Project Webpack/Rspack chunk and factory literals recovered from AST. */ /** Project Webpack/Rspack chunk and factory literals recovered from AST. */
export const addJavaScriptBundlerNodes = ( export const addJavaScriptBundlerNodes = (
context: JavaScriptArtifactGraphContext, context: JavaScriptArtifactGraphContext,
): void => { ): JavaScriptBundlerOmissions => {
const omissions = { selfAsyncChunks: 0 };
for (const analyzed of context.analysis.files) { for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed; const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path); const asset = context.assetNodes.get(file.path);
@@ -49,8 +56,10 @@ export const addJavaScriptBundlerNodes = (
records.push({ file, registration, chunk, coverage }); records.push({ file, registration, chunk, coverage });
addBundlerModuleNodes(context, { ...input, chunk }); addBundlerModuleNodes(context, { ...input, chunk });
} }
for (const record of records) addBundlerRuntimeEdges(context, record); for (const record of records)
addBundlerRuntimeEdges(context, record, omissions);
} }
return omissions;
}; };
interface BundlerProjectionInput { interface BundlerProjectionInput {
@@ -177,6 +186,7 @@ interface BundlerGraphRecord {
const addBundlerRuntimeEdges = ( const addBundlerRuntimeEdges = (
context: JavaScriptArtifactGraphContext, context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord, record: BundlerGraphRecord,
omissions: { selfAsyncChunks: number },
): void => { ): void => {
const { file, registration, chunk, coverage } = record; const { file, registration, chunk, coverage } = record;
for (const moduleKey of registration.entry_module_keys) { for (const moduleKey of registration.entry_module_keys) {
@@ -209,6 +219,13 @@ const addBundlerRuntimeEdges = (
for (const chunkKey of registration.async_chunk_keys) { for (const chunkKey of registration.async_chunk_keys) {
const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey); const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey);
const resolved = context.chunkNodes.get(lookup); const resolved = context.chunkNodes.get(lookup);
// A chunk file that requests one of its own registered chunk ids carries
// no chunk-graph relationship, and the application graph forbids
// self-referential edges, so one such request would fail the analysis.
if (resolved?.node_id === chunk.node_id) {
omissions.selfAsyncChunks += 1;
continue;
}
const target = const target =
resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey); resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey);
context.accumulator.addEdge({ context.accumulator.addEdge({
@@ -39,17 +39,24 @@ interface ResolvedReference {
readonly node: ApplicationNode; readonly node: ApplicationNode;
} }
/** Static finding compositions the application graph cannot represent. */
export interface JavaScriptStaticFindingOmissions {
/** References, such as a bundled module requiring its own key, that resolve to their source. */
readonly selfReferences: number;
}
/** Project imports, workers, roles, endpoints, storage, and source-map links. */ /** Project imports, workers, roles, endpoints, storage, and source-map links. */
export const addJavaScriptStaticFindings = ( export const addJavaScriptStaticFindings = (
context: JavaScriptArtifactGraphContext, context: JavaScriptArtifactGraphContext,
): void => { ): JavaScriptStaticFindingOmissions => {
const omissions = { selfReferences: 0 };
for (const analyzed of context.analysis.files) { for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed; const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path); const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue; if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript); const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.references) for (const value of javascript.references)
addReference(context, { file, asset, value, coverage }); addReference(context, { file, asset, value, coverage }, omissions);
for (const value of javascript.endpoints) for (const value of javascript.endpoints)
addEndpoint(context, { file, asset, value, coverage }); addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage) for (const value of javascript.storage)
@@ -59,11 +66,13 @@ export const addJavaScriptStaticFindings = (
for (const value of javascript.source_map_urls) for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage }); addSourceMapEdge(context, { file, asset, value, coverage });
} }
return omissions;
}; };
const addReference = ( const addReference = (
context: JavaScriptArtifactGraphContext, context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>, input: FindingInput<StaticReference>,
omissions: { selfReferences: number },
): void => { ): void => {
const source = const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ?? sourceNodeFor(context, input.file.path, input.value.module_key) ??
@@ -119,6 +128,13 @@ const addReference = (
}); });
return; return;
} }
// A reference that resolves to its own source, such as a bundled module
// requiring its own key, cannot become an edge: the application graph
// forbids self-referential edges and one would fail the whole analysis.
if (resolved?.node.node_id === source.node_id) {
omissions.selfReferences += 1;
return;
}
const target = const target =
resolved?.node ?? resolved?.node ??
unresolvedReferenceNode(context, input.file, input.value, input.coverage); unresolvedReferenceNode(context, input.file, input.value, input.coverage);
@@ -0,0 +1,81 @@
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, type TestCli } from "../../support/cli/cliFixture.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
const chunk = (ids: readonly number[], modules: string): string =>
`"use strict";(self.webpackChunkdemo=self.webpackChunkdemo||[]).push([[${ids.join(",")}],{${modules}}]);\n`;
const analyze = async (
cli: TestCli,
files: Readonly<Record<string, string>>,
) => {
const root = await createTestTempDirectory("rea-self-bundler-");
await writeFixtureFiles(root, files);
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode, JSON.stringify(output.json)).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(
result.graph.edges.filter(
(edge) => edge.source_node_id === edge.target_node_id,
),
).toEqual([]);
expect(result.limitations).toEqual(
expect.arrayContaining(result.graph.limitations),
);
return result.graph;
};
cliTest(
"omits a chunk's request for its own id while keeping sibling chunk requests",
async ({ cli }) => {
const graph = await analyze(cli, {
"1.js": chunk(
[1],
"10(e,t,r){r.e(1).then(r.bind(r,11));r.e(2)},11(e,t,r){r.r(t)}",
),
"2.js": chunk([2], "20(e,t,r){r.r(t)}"),
"3.js": chunk(
[3, 4],
"30(e,t,r){r.e(4).then(r.bind(r,31))},31(e,t,r){r.r(t)}",
),
});
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/^2 bundler async-chunk requests resolved back to the requesting chunk itself and were omitted;/u,
),
);
expect(
graph.edges
.filter(({ properties }) => properties.kind === "bundler-async-chunk")
.map(({ properties }) => properties.chunk_key),
).toEqual(["2"]);
},
);
cliTest(
"omits a bundled module's require of its own key while keeping sibling requires",
async ({ cli }) => {
const graph = await analyze(cli, {
"5.js": chunk([5], "50(e,t,r){r(50);r(51)},51(e,t,r){r.r(t)}"),
});
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/^1 static reference resolved back to the referencing module itself and was omitted;/u,
),
);
expect(
graph.edges.some(
({ relation, properties }) =>
relation === "imports" && properties.specifier === "51",
),
).toBe(true);
},
);