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>
This commit is contained in:
rodricksz4h5andgensokyo-dev authored and GitHub committed 2026-10-23 02:25:43 +08:00
1 parent 26573c3aa4
commit 5d3ae66e63
2 files changed
+109

No files matched your search

@@ -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}`;
};
@@ -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",
}),
);
},
);