diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 1ee56978b8..774b0e4aee 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -105,6 +105,7 @@ export const addJavaScriptSourceModules = ( label: file.path, properties: { semantic_role: "source-module", + source_sha256: file.sha256, path: file.path, container_sha256: file.container_sha256, logical_module_key: file.path, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index f28a450602..c3e20b4c75 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -421,6 +421,33 @@ it("keeps duplicate function fingerprints ambiguous", () => { expect(query.summary.total_seed_matches).toBe(2); }); +it("selects analyzer-produced boundaries by their method field", () => { + const graph = graphFor("const port = Number(process.env.PORT);"); + const boundary = graph.nodes.find(({ kind }) => kind === "boundary"); + if (boundary === undefined) throw new Error("Expected a boundary node"); + + expect(boundary.properties).toMatchObject({ + boundary_kind: "coerce", + field: "Number", + method: "Number", + }); + expect( + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "boundary-field", field: "Number" }, + direction: "forward-influence", + }), + ).toMatchObject({ + seed_node_ids: [boundary.node_id], + summary: { total_seed_matches: 1 }, + }); + expect( + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "boundary-field", field: "missing-field" }, + direction: "forward-influence", + }).summary.total_seed_matches, + ).toBe(0); +}); + const semanticNodeFor = (roleKey: string) => { const file: JavaScriptArtifactFile = { path: "budget.js", diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index b1c44e8f8c..20506946df 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -263,6 +263,7 @@ const addBoundaryNode = ( null), properties: { boundary_kind: operation.kind, + field: operation.method, method: operation.method, }, }); diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts index 0853ce3789..a16ea4c0c2 100644 --- a/src/domain/javascript/sourceToBundleComparison.test.ts +++ b/src/domain/javascript/sourceToBundleComparison.test.ts @@ -30,6 +30,58 @@ const HASH = { const EVIDENCE_ID = `ev_${"a".repeat(64)}`; describe("historical source to bundle comparison", () => { + it("matches an unchanged JSON source artifact by its exact content digest", () => { + const path = "src/data.json"; + const artifact = createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: HASH.unchanged, + }, + observations: [ + { + label: path, + properties: { path, file_kind: "json" }, + evidence: artifactEvidence(HASH.unchanged, path), + }, + ], + }); + const result = compareSourceToBundle({ + reference: historicalGraph("complete", [path]), + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph: createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [artifact.node_id], + nodes: [artifact], + edges: [], + coverage: { + status: "complete", + truncated: false, + omitted_count: 0, + limits: [], + }, + limitations: [], + }), + }, + }); + + expect(item(result, path)).toMatchObject({ + status: "unchanged", + confidence: "exact", + current_node_ids: [artifact.node_id], + candidates: [ + expect.objectContaining({ + signals: expect.arrayContaining([ + expect.objectContaining({ kind: "exact-source-digest" }), + ]), + }), + ], + }); + }); + it("classifies exact, changed, absent, split, and merged mappings stably", () => { const reference = historicalGraph("complete"); const graph = applicationGraph("complete"); @@ -120,7 +172,9 @@ describe("historical source to bundle comparison", () => { }); expect(result.coverage.status).toBe("partial"); }); +}); +describe("source to bundle candidate evaluation", () => { it("evaluates all indexed candidates for each source file", () => { const result = compareSourceToBundle({ reference: historicalGraph("complete", ["src/split.ts"]), diff --git a/src/domain/javascript/sourceToBundleSignals.ts b/src/domain/javascript/sourceToBundleSignals.ts index b9706f7144..62490bec86 100644 --- a/src/domain/javascript/sourceToBundleSignals.ts +++ b/src/domain/javascript/sourceToBundleSignals.ts @@ -45,6 +45,9 @@ export interface SourceToBundleCandidateIndex { } const RELEVANT_NODE_KINDS = new Set([ + // Non-JavaScript source files such as JSON are projected as artifacts. + // Their content digests and paths still identify historical source files. + "artifact", "javascript-asset", "javascript-module", "source-module", diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 64e438ed36..17cc61eaab 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -96,3 +96,85 @@ cliTest( } }, ); + +cliTest( + "matches an unchanged source module after a sibling file changes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-source-module-version-"); + const versions = await Promise.all( + ["left", "right"].map(async (name) => { + const application = join(root, name); + await mkdir(join(application, "src"), { recursive: true }); + await writeFile( + join(application, "package.json"), + JSON.stringify({ + name: "source-module-fixture", + version: "1.0.0", + main: "src/main.js", + }), + ); + await writeFile( + join(application, "src/main.js"), + 'require("./util.js");\n', + ); + await writeFile( + join(application, "src/util.js"), + `module.exports = ${JSON.stringify(name)};\n`, + ); + 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 [left, right] = versions; + if (left === undefined || right === undefined) + throw new Error("Missing source module Evidence"); + const analyses = versions.map(({ normalized_result }) => + javascriptApplicationAnalysisResultSchema.parse(normalized_result), + ); + const sourceModules = analyses.map(({ graph }) => { + const node = graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-module" && + observations.some( + ({ properties }) => + properties.semantic_role === "source-module" && + properties.path === "src/main.js", + ), + ); + if (node === undefined) throw new Error("Missing main source module"); + expect( + node.observations.some( + ({ properties }) => typeof properties.source_sha256 === "string", + ), + ).toBe(true); + return node; + }); + const inputPath = join(root, "comparison.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); + const result = applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + + expect(result.items).toContainEqual( + expect.objectContaining({ + left_node_id: sourceModules[0]?.node_id, + right_node_id: sourceModules[1]?.node_id, + status: "unchanged", + match: expect.objectContaining({ + status: "matched", + basis: "exact-module-source-digest", + confidence: "exact", + }), + }), + ); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index cdd95bc979..3bdb8f6427 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -15,6 +16,22 @@ import { } from "../../../support/javascriptApplicationFixture.js"; describe("TypeScript source module loader semantics", () => { + it("publishes each source module's own content digest", async () => { + const root = await createTestTempDirectory("rea-source-module-digest-"); + const source = "module.exports = 42;\n"; + await writeFixtureFiles(root, { "main.js": source }); + + const { graph } = await reconstructJavaScriptFixture(root); + const sourceModule = findSourceModule(graph, "main.js"); + const digest = createHash("sha256").update(source).digest("hex"); + + expect( + sourceModule?.observations.find( + ({ properties }) => properties.semantic_role === "source-module", + )?.properties, + ).toMatchObject({ source_sha256: digest }); + }); + it("keeps whole type-only declarations out of runtime module relationships", async () => { const root = await createTestTempDirectory("rea-type-module-links-"); await writeFixtureFiles(root, {