From 1d7115b7b470719a32e477ca3cbb681f0ad408b8 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:10:38 +0200 Subject: [PATCH] fix(javascript): omit self-referential import edges with disclosure (#1130) * fix(javascript): omit self-referential import edges with disclosure A specifier that resolves back to the importing module itself produced an imports edge from a module to itself, which the application graph schema forbids; one such specifier failed result validation and lost the whole analysis. Skip those edges at composition time and disclose the omission count in the graph limitations. Fixes #1129 * test(javascript): verify self-import behavior through the compiled CLI --------- Co-authored-by: BaaRai Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../JavaScriptArtifactGraphBuilder.ts | 15 +- .../JavaScriptModuleRelationships.ts | 33 ++++- .../javascriptSelfImportRelationships.test.ts | 139 ++++++++++++++++++ 3 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 tests/boundary/cli/javascriptSelfImportRelationships.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index c043cfb001..70712207dc 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -15,6 +15,7 @@ import { import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js"; import { addJavaScriptModuleRelationships, + type JavaScriptModuleRelationshipOmissions, addJavaScriptSourceModules, } from "./JavaScriptModuleRelationships.js"; import { @@ -88,7 +89,7 @@ const buildJavaScriptArtifactGraphInput = ( const packageRoots = addJavaScriptPackageNodes(context); addJavaScriptSourceModules(context); addJavaScriptBundlerNodes(context); - addJavaScriptModuleRelationships(context); + const relationshipOmissions = addJavaScriptModuleRelationships(context); addJavaScriptStaticFindings(context); addElectronBoundaries(context); addJavaScriptHtmlRoles(context); @@ -103,7 +104,11 @@ const buildJavaScriptArtifactGraphInput = ( nodes: accumulator.nodes(), edges: accumulator.edges(), coverage, - limitations: graphLimitations(context, coverage.status), + limitations: graphLimitations( + context, + coverage.status, + relationshipOmissions, + ), }; }; @@ -171,6 +176,7 @@ const applicationGraphResourceLimit = ( const graphLimitations = ( context: JavaScriptArtifactGraphContext, coverage: "complete" | "partial" | "unknown" | "unavailable", + relationshipOmissions: JavaScriptModuleRelationshipOmissions, ): string[] => { const ipc = collectElectronIpcRecords(context.analysis); const pairings = classifyElectronIpcPairings(ipc); @@ -189,6 +195,11 @@ 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.`, + ] + : []), "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.", diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index d3a8c825b9..ab033e744b 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -129,27 +129,40 @@ export const addJavaScriptSourceModules = ( } }; +/** Relationship compositions the application graph cannot represent, for disclosure. */ +export interface JavaScriptModuleRelationshipOmissions { + /** Import specifiers that resolved back to the importing module itself. */ + readonly selfImports: number; +} + +type RelationshipOmissionCounter = { selfImports: number }; + /** Compose bounded CommonJS and ESM binding relationships across artifact files. */ export const addJavaScriptModuleRelationships = ( context: JavaScriptArtifactGraphContext, -): void => { +): JavaScriptModuleRelationshipOmissions => { + const omissions = { selfImports: 0 }; for (const analyzed of context.analysis.files) { const { file, semantic } = analyzed; const source = context.sourceModuleNodes.get(file.path); if (semantic === null || source === undefined) continue; for (const link of semantic.ir.moduleLinks) { const input = { context, file, semantic, source, link }; - if (isExportLink(link)) addExportRelationship(input); + if (isExportLink(link)) addExportRelationship(input, omissions); else if (link.specifier !== null) - addImportRelationship(input, source, { + addImportRelationship(input, source, omissions, { specifier: link.specifier, importedPath: link.importedName === null ? [] : [link.importedName], }); } } + return omissions; }; -const addExportRelationship = (input: RelationshipInput): void => { +const addExportRelationship = ( + input: RelationshipInput, + omissions: RelationshipOmissionCounter, +): void => { const { context, file, semantic, source, link } = input; if (link.exportedName === null) return; const baseCoverage = semanticCoverage(semantic); @@ -213,15 +226,25 @@ const addExportRelationship = (input: RelationshipInput): void => { evidence: relationshipEvidence(input, "expose-module-export", []), }); const origin = moduleOriginForExport(semantic.ir, link); - if (origin !== null) addImportRelationship(input, exported, origin); + if (origin !== null) + addImportRelationship(input, exported, omissions, origin); }; const addImportRelationship = ( input: RelationshipInput, source: ApplicationNode, + omissions: RelationshipOmissionCounter, origin: JavaScriptModuleOrigin, ): void => { const target = resolveModuleTarget(input, origin.specifier); + // A specifier that resolves back to the importing module itself (a literal + // self-import, self-require, or artifact-confined path alias) + // cannot become an edge: the application graph forbids self-referential + // edges, and emitting one fails result validation for the whole analysis. + if (target.node.node_id === source.node_id) { + omissions.selfImports += 1; + return; + } input.context.accumulator.addEdge({ source_node_id: source.node_id, target_node_id: target.node.node_id, diff --git a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts new file mode 100644 index 0000000000..c3e1238bef --- /dev/null +++ b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts @@ -0,0 +1,139 @@ +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 { + findExportNode, + findGraphEdge, + findSourceModule, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; + +cliTest( + "returns literal self-import evidence while preserving sibling imports and self-re-exports", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-self-import-"); + await writeFixtureFiles(root, { + "a.js": + 'import "./a.js";\nexport const value = 1;\nexport { value as forwarded } from "./a.js";\n', + "b.js": 'import { value } from "./a.js";\nconsole.log(value);\n', + }); + 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(/^1 import specifier .*was omitted;/u), + ); + expect(result.limitations).toEqual( + expect.arrayContaining(graph.limitations), + ); + const source = findSourceModule(graph, "a.js"); + expect(source).toBeDefined(); + expect( + findGraphEdge(graph, findSourceModule(graph, "b.js"), source, "imports") + ?.properties, + ).toMatchObject({ + specifier: "./a.js", + resolution_status: "resolved", + }); + expect( + findGraphEdge( + graph, + findExportNode(graph, "a.js", "forwarded"), + source, + "imports", + )?.properties, + ).toMatchObject({ + specifier: "./a.js", + resolution_status: "resolved", + }); + expect( + graph.edges.find( + (edge) => + edge.source_node_id === source?.node_id && + edge.properties.kind === "static-import" && + edge.properties.specifier === "./a.js" && + edge.evidence.location.available && + edge.evidence.location.value.kind === "source-range" && + edge.evidence.location.value.start.line === 1, + )?.evidence.location, + ).toMatchObject({ + available: true, + value: { kind: "source-range", source: "a.js", start: { line: 1 } }, + }); + }, +); + +cliTest( + "discloses CommonJS and TypeScript self-imports while retaining unresolved extension rewrites", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-self-require-"); + await writeFixtureFiles(root, { + "c.cjs": 'const self = require("./c.cjs");\nmodule.exports = self;\n', + "util.ts": + 'import "./util.ts";\nimport "./util.js";\nexport const value = 1;\n', + }); + 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(/^2 import specifiers .*were omitted;/u), + ); + expect(result.limitations).toEqual( + expect.arrayContaining(graph.limitations), + ); + for (const [path, specifier] of [ + ["c.cjs", "./c.cjs"], + ["util.ts", "./util.ts"], + ] as const) { + const source = findSourceModule(graph, path); + expect(source).toBeDefined(); + expect( + graph.edges.find( + (edge) => + edge.source_node_id === source?.node_id && + edge.properties.specifier === specifier && + edge.properties.resolved_path === path, + )?.evidence.location, + ).toMatchObject({ + available: true, + value: { kind: "source-range", source: path, start: { line: 1 } }, + }); + expect( + graph.nodes.some((node) => + node.observations.some( + ({ properties }) => + properties.semantic_role === "module-reference" && + properties.declared_specifier === specifier, + ), + ), + ).toBe(false); + } + expect( + graph.edges.find( + (edge) => + edge.source_node_id === findSourceModule(graph, "util.ts")?.node_id && + edge.properties.specifier === "./util.js" && + edge.properties.module_link_kind === "import", + )?.properties, + ).toMatchObject({ resolved_path: null, resolution_status: "not-found" }); + }, +);