From 5d3ae66e6385c08ec73d5053c5dd96c7bf5e9973 Mon Sep 17 00:00:00 2001 From: rodricksz4h5 Date: Thu, 22 Oct 2026 23:55:43 +0530 Subject: [PATCH] fix(javascript): match unchanged module exports across application versions (#5196) * fix(javascript): match unchanged module exports across application versions An export binding's identity is keyed within its whole application root (container_sha256), so it changed whenever any sibling file changed, and no match tier paired export nodes: every export of an unchanged file was reported removed and added, and its module fell to unknown. The exact module-source-digest tier now also pairs an export binding by its own file's bytes (identity artifact_sha256) and exported name, as it already pairs a source module by source_sha256. Exports of edited files stay unmatched. * style(javascript): format the version-matching export test --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../javascriptApplicationVersionKeys.ts | 12 +++ .../applicationVersionMatchingCli.test.ts | 97 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 11dbf62de5..dec1a9d2ee 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -200,6 +200,18 @@ const contentDigestKey = (node: ApplicationNode): string | null => const moduleSourceDigestKey = (node: ApplicationNode): string | null => { if (node.kind !== "javascript-module") return null; + // An export binding is identified within its whole application root, so its + // id changes with any sibling file. Its own file's bytes and exported name + // identify it across versions, as `source_sha256` does a source module. + if ( + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === "module-export" + ) { + const name = uniqueStringProperty(node, "exported_name"); + return name === null + ? null + : `module-export\0${node.identity.artifact_sha256}\0${name}`; + } const value = uniqueStringProperty(node, "source_sha256"); return value === null ? null : `module-source\0${node.kind}\0${value}`; }; diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 683ec9a338..c9c331a6da 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -454,3 +454,100 @@ cliTest( ).toBe(true); }, ); + +cliTest( + "matches unchanged module exports after a sibling file changes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-export-version-"); + const util = { + v1: "module.exports = { greet: () => 1 };\n", + v2: "module.exports = { greet: () => 2 };\n", + }; + const analyze = async ( + name: string, + version: string, + utilSource: string, + ) => { + const application = join(root, name); + await mkdir(join(application, "src"), { recursive: true }); + await writeFile( + join(application, "package.json"), + JSON.stringify({ + name: "export-fixture", + version, + main: "src/main.js", + }), + ); + await writeFile( + join(application, "src/main.js"), + 'const util = require("./util.js");\nmodule.exports = { start: () => util.greet() };\n', + ); + await writeFile(join(application, "src/util.js"), utilSource); + const response = await cli.run({ + arguments: ["analyze-javascript-application", application, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return parseEvidence(response.json); + }; + const compare = async (left: unknown, right: unknown) => { + const inputPath = join(root, `comparison-${Math.random()}.json`); + await writeFile(inputPath, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", inputPath, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + }; + const exportsOf = (evidence: { normalized_result: unknown }) => + javascriptApplicationAnalysisResultSchema + .parse(evidence.normalized_result) + .graph.nodes.filter(({ observations }) => + observations.some( + ({ properties }) => properties.semantic_role === "export-binding", + ), + ); + const base = await analyze("base", "1.0.0", util.v1); + // Only package.json differs: both JavaScript files are byte-identical. + const bumped = await analyze("bumped", "1.0.1", util.v1); + const edited = await analyze("edited", "1.0.1", util.v2); + const baseExports = exportsOf(base); + expect(baseExports.length).toBeGreaterThanOrEqual(2); + + const unchanged = await compare(base, bumped); + for (const node of baseExports) + expect(unchanged.items).toContainEqual( + expect.objectContaining({ + left_node_id: node.node_id, + status: "unchanged", + match: expect.objectContaining({ + basis: "exact-module-source-digest", + confidence: "exact", + }), + }), + ); + expect( + unchanged.items.filter( + ({ node_kind, status }) => + node_kind === "javascript-module" && status !== "unchanged", + ), + ).toEqual([]); + + // An edited file's export is not paired by name alone. + const changed = await compare(base, edited); + const utilExport = baseExports.find(({ observations }) => + observations.some( + ({ properties }) => properties.module_path === "src/util.js", + ), + ); + expect(changed.items).toContainEqual( + expect.objectContaining({ + left_node_id: utilExport?.node_id, + status: "removed", + }), + ); + }, +);