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,
addJavaScriptSourceMapOriginals,
} from "./JavaScriptArtifactGraphDocuments.js";
import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js";
import {
addJavaScriptStaticFindings,
type JavaScriptStaticFindingOmissions,
} from "./JavaScriptArtifactGraphFindings.js";
import type { JavaScriptBundlerOmissions } from "./JavaScriptArtifactGraphBundlers.js";
import {
addJavaScriptModuleRelationships,
type JavaScriptModuleRelationshipOmissions,
@@ -88,9 +92,9 @@ const buildJavaScriptArtifactGraphInput = (
addJavaScriptArtifactFiles(context);
const packageRoots = addJavaScriptPackageNodes(context);
addJavaScriptSourceModules(context);
addJavaScriptBundlerNodes(context);
const bundlerOmissions = addJavaScriptBundlerNodes(context);
const relationshipOmissions = addJavaScriptModuleRelationships(context);
addJavaScriptStaticFindings(context);
const findingOmissions = addJavaScriptStaticFindings(context);
addElectronBoundaries(context);
addJavaScriptHtmlRoles(context);
addJavaScriptSourceMapOriginals(context);
@@ -104,11 +108,11 @@ const buildJavaScriptArtifactGraphInput = (
nodes: accumulator.nodes(),
edges: accumulator.edges(),
coverage,
limitations: graphLimitations(
context,
coverage.status,
relationshipOmissions,
),
limitations: graphLimitations(context, coverage.status, {
...relationshipOmissions,
...bundlerOmissions,
...findingOmissions,
}),
};
};
@@ -173,10 +177,47 @@ const applicationGraphResourceLimit = (
: ("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 = (
context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable",
relationshipOmissions: JavaScriptModuleRelationshipOmissions,
omissions: SelfReferenceOmissions,
): string[] => {
const ipc = collectElectronIpcRecords(context.analysis);
const pairings = classifyElectronIpcPairings(ipc);
@@ -195,11 +236,7 @@ const graphLimitations = (
);
return [
...context.analysis.limitations,
...(relationshipOmissions.selfImports > 0
? [
`${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.`,
]
: []),
...selfReferenceLimitations(omissions),
"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.",
"Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.",
@@ -15,10 +15,17 @@ import {
staticInferenceEvidence,
} 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. */
export const addJavaScriptBundlerNodes = (
context: JavaScriptArtifactGraphContext,
): void => {
): JavaScriptBundlerOmissions => {
const omissions = { selfAsyncChunks: 0 };
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
@@ -49,8 +56,10 @@ 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)
addBundlerRuntimeEdges(context, record, omissions);
}
return omissions;
};
interface BundlerProjectionInput {
@@ -177,6 +186,7 @@ interface BundlerGraphRecord {
const addBundlerRuntimeEdges = (
context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord,
omissions: { selfAsyncChunks: number },
): void => {
const { file, registration, chunk, coverage } = record;
for (const moduleKey of registration.entry_module_keys) {
@@ -209,6 +219,13 @@ const addBundlerRuntimeEdges = (
for (const chunkKey of registration.async_chunk_keys) {
const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey);
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 =
resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey);
context.accumulator.addEdge({
@@ -39,17 +39,24 @@ interface ResolvedReference {
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. */
export const addJavaScriptStaticFindings = (
context: JavaScriptArtifactGraphContext,
): void => {
): JavaScriptStaticFindingOmissions => {
const omissions = { selfReferences: 0 };
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
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)
addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage)
@@ -59,11 +66,13 @@ export const addJavaScriptStaticFindings = (
for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage });
}
return omissions;
};
const addReference = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>,
omissions: { selfReferences: number },
): void => {
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
@@ -119,6 +128,13 @@ const addReference = (
});
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 =
resolved?.node ??
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);
},
);