diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index 7c2d1775e7..b7cafd38ed 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -480,3 +480,45 @@ it("attaches sender validation to the innermost handler after a long minified li ), ).toBe(true); }); + +it("projects package parse status from the package observation", () => { + const file = { + ...skippedFile(), + path: "package.json", + kind: "package-json" as const, + }; + const entry: AnalyzedJavaScriptArtifactFile = { + file, + javascript: null, + semantic: null, + }; + const analysis = { + ...analysisFor([entry]), + packages: [ + { + path: file.path, + sha256: file.sha256, + status: "included" as const, + name: "fixture", + version: null, + main: null, + main_status: "default" as const, + main_entrypoint: "index.js", + renderer_declarations: {}, + renderer_entrypoint: null, + limitation: null, + }, + ], + }; + const context = contextFor(file, analysis); + addJavaScriptArtifactFiles(context); + const observation = context.accumulator + .nodes() + .flatMap(({ observations }) => observations) + .find(({ properties }) => properties.file_kind === "package-json"); + expect(observation?.properties).toMatchObject({ + file_kind: "package-json", + package_parse_status: "included", + json_parse_status: null, + }); +}); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 4ce33e7e2a..c1ec6281e3 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -156,6 +156,7 @@ export const addJavaScriptArtifactFiles = ( file, analyzed.javascript, jsonValue, + packagesByPath.get(file.path), isSkippedJavaScriptAnalysis(analyzed), ); if ( @@ -632,6 +633,7 @@ const createFileTarget = ( file: JavaScriptArtifactFile, javascript: JavaScriptArtifactAnalysis["files"][number]["javascript"], json: JavaScriptArtifactAnalysis["json_modules"][number] | undefined, + packageValue: JavaScriptArtifactAnalysis["packages"][number] | undefined, skipped: boolean, ): ApplicationNode => { const kind = artifactFileNodeKind(file.kind); @@ -671,6 +673,9 @@ const createFileTarget = ( } : {}), json_parse_status: json?.status ?? null, + ...(file.kind === "package-json" + ? { package_parse_status: packageValue?.status ?? null } + : {}), json_top_level_keys: json?.top_level_keys ?? [], omitted_json_top_level_keys: json === undefined diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 6744b4d1ac..f94de042ce 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -151,7 +151,13 @@ const matchedItem = ( context: ItemContext, ): ApplicationVersionComparisonItem => { const relationships = compareRelationships(pair, context); - const dimensions = changedDimensions(pair, relationships); + const sourceAnalysisUncertain = + hasUnexaminedSourceFile(pair.left) || hasUnexaminedSourceFile(pair.right); + const dimensions = changedDimensions( + pair, + relationships, + sourceAnalysisUncertain, + ); const complete = graphsComplete(context); const ownEvidenceUncertain = ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right); @@ -164,6 +170,7 @@ const matchedItem = ( // content, property, or relationship change. const completeChange = !ownEvidenceUncertain && + !sourceAnalysisUncertain && complete && dimensions.some((dimension) => dimension !== "location"); const status: ApplicationVersionComparisonItem["status"] = @@ -171,12 +178,15 @@ const matchedItem = ( ? "unknown" : observedChange || completeChange ? "changed" - : complete && !relationships.unresolved && !endpointTargetUncertain + : complete && + !relationships.unresolved && + !endpointTargetUncertain && + !sourceAnalysisUncertain ? "unchanged" : "unknown"; const finalDimensions = ownEvidenceUncertain ? [] - : status === "unknown" && !complete + : status === "unknown" && (!complete || sourceAnalysisUncertain) ? [...dimensions, "coverage" as const] : dimensions; const semantic: ComparisonItemSemantic = { @@ -193,6 +203,11 @@ const matchedItem = ( ...ownComparisonLimitations(pair.right), ]), ...matchLimitations(pair), + ...(sourceAnalysisUncertain + ? [ + "Source file analysis was not complete on both sides; retained file identity does not establish equality of unexamined facts.", + ] + : []), ...(status === "unknown" && !complete ? [ "Incomplete graph coverage prevents a complete node-level conclusion.", @@ -458,6 +473,7 @@ const ownComparisonLimitations = (node: ApplicationNode): string[] => { const changedDimensions = ( pair: ApplicationVersionNodePair, relationships: RelationshipComparison, + sourceAnalysisUncertain: boolean, ): ApplicationVersionComparisonItem["dimensions"] => { const dimensions: ApplicationVersionComparisonItem["dimensions"] = []; const leftContent = contentIdentity(pair.left); @@ -473,8 +489,8 @@ const changedDimensions = ( ) dimensions.push("location"); if ( - canonicalJson(properties(pair.left)) !== - canonicalJson(properties(pair.right)) + canonicalJson(properties(pair.left, sourceAnalysisUncertain)) !== + canonicalJson(properties(pair.right, sourceAnalysisUncertain)) ) dimensions.push("properties"); if (relationships.different) dimensions.push("relationships"); @@ -503,11 +519,38 @@ const locations = (node: ApplicationNode) => compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)), ); -const properties = (node: ApplicationNode) => +const SOURCE_FILE_KINDS = new Set([ + "javascript", + "html", + "json", + "package-json", + "source-map", +]); + +const hasUnexaminedSourceFile = (node: ApplicationNode): boolean => + node.observations.some( + ({ properties: values }) => + typeof values.file_kind === "string" && + SOURCE_FILE_KINDS.has(values.file_kind) && + (values.text_status === "outside-analysis-scope" || + values.text_status === "invalid-utf8" || + (values.file_kind === "json" && + "json_parse_status" in values && + values.json_parse_status !== "included") || + (values.file_kind === "package-json" && + values.package_parse_status !== "included") || + (values.file_kind === "javascript" && + (("parse_status" in values && values.parse_status === null) || + values.parse_status === "failed" || + values.parse_status === "skipped" || + values.parse_status === "partial"))), + ); + +const properties = (node: ApplicationNode, sourceAnalysisUncertain: boolean) => node.observations .map(({ label, properties: values, source_map_reference }) => ({ label, - properties: comparisonPropertyValues(values), + properties: comparisonPropertyValues(values, sourceAnalysisUncertain), source_map_reference, })) .sort((left, right) => @@ -516,17 +559,38 @@ const properties = (node: ApplicationNode) => const comparisonPropertyValues = ( values: ApplicationNode["observations"][number]["properties"], + sourceAnalysisUncertain: boolean, ) => { + const comparable = + sourceAnalysisUncertain && + typeof values.file_kind === "string" && + SOURCE_FILE_KINDS.has(values.file_kind) + ? Object.fromEntries( + Object.entries(values).filter( + ([key]) => + ![ + "text_status", + "parse_status", + "analysis_coverage", + "vendor_markers", + "json_parse_status", + "package_parse_status", + "json_top_level_keys", + "omitted_json_top_level_keys", + ].includes(key), + ), + ) + : values; const occurrence = javaScriptFileOccurrenceSchema.safeParse( - values.file_occurrence, + comparable.file_occurrence, ); - if (!occurrence.success) return values; + if (!occurrence.success) return comparable; // A containing artifact's digest locates this occurrence in one version; // sibling changes do not establish a change to this file's own facts. // Keep the occurrence's path and content digest comparable, and retain the // complete provenance record on the original observation. return { - ...values, + ...comparable, file_occurrence: { path: occurrence.data.path, sha256: occurrence.data.sha256, diff --git a/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts index ff7e3260b7..13d415e334 100644 --- a/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts @@ -205,3 +205,128 @@ it("retains unknown scope facts and reports genuine entity changes", () => { removed: 0, }); }); + +it("does not treat unexamined source metadata as a file change", () => { + const asset = (digest: string, selected: boolean, unpacked: boolean) => + createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: digest, + }, + observations: [ + { + label: "lib.js", + properties: { + path: "lib.js", + file_kind: "javascript", + bytes: 5, + unpacked, + text_status: selected ? "included" : "outside-analysis-scope", + parse_status: selected ? "complete" : null, + vendor_markers: selected ? ["example"] : [], + }, + evidence: artifactEvidence(digest, "lib.js"), + }, + ], + }); + const left = asset("e".repeat(64), true, false); + const right = asset("e".repeat(64), false, false); + for (const [before, after] of [ + [left, right], + [right, left], + ] as const) { + const result = compare([before], [after]); + expect(result.items[0]).toMatchObject({ + status: "unknown", + dimensions: ["coverage"], + limitations: [ + expect.stringContaining("Source file analysis was not complete"), + ], + }); + } + const changed = compare([left], [asset("e".repeat(64), false, true)]); + expect(changed.items[0]).toMatchObject({ + status: "changed", + dimensions: ["properties"], + }); +}); + +it("keeps excluded JSON observations unknown without hiding inventoried differences", () => { + const json = ( + state: "included" | "excluded" | "invalid", + unpacked: boolean, + ) => + createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: "d".repeat(64), + }, + observations: [ + { + label: "data.json", + properties: { + path: "data.json", + file_kind: "json", + unpacked, + text_status: + state === "excluded" ? "outside-analysis-scope" : "included", + json_parse_status: state === "excluded" ? null : state, + json_top_level_keys: state === "included" ? ["key"] : [], + omitted_json_top_level_keys: state === "excluded" ? null : 0, + }, + evidence: artifactEvidence("d".repeat(64), "data.json"), + }, + ], + }); + expect( + compare([json("included", false)], [json("excluded", false)]).items[0], + ).toMatchObject({ + status: "unknown", + dimensions: ["coverage"], + }); + expect( + compare([json("included", false)], [json("excluded", true)]).items[0], + ).toMatchObject({ + status: "changed", + dimensions: ["properties"], + }); + expect( + compare([json("included", false)], [json("invalid", false)]).items[0], + ).toMatchObject({ status: "unknown", dimensions: ["coverage"] }); +}); + +it("uses package parsing rather than JSON-module status for package files", () => { + const asset = (status: "included" | "invalid" | null) => + createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: "c".repeat(64), + }, + observations: [ + { + label: "package.json", + properties: { + path: "package.json", + file_kind: "package-json", + text_status: "included", + json_parse_status: null, + package_parse_status: status, + }, + evidence: artifactEvidence("c".repeat(64), "package.json"), + }, + ], + }); + expect( + compare([asset("included")], [asset("included")]).items[0], + ).toMatchObject({ status: "unchanged" }); + for (const status of ["invalid", null] as const) + expect( + compare([asset("included")], [asset(status)]).items[0], + ).toMatchObject({ status: "unknown", dimensions: ["coverage"] }); +}); diff --git a/tests/acceptance/applications/applicationVersionSelectedSourceCli.test.ts b/tests/acceptance/applications/applicationVersionSelectedSourceCli.test.ts new file mode 100644 index 0000000000..de5f456545 --- /dev/null +++ b/tests/acceptance/applications/applicationVersionSelectedSourceCli.test.ts @@ -0,0 +1,76 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "keeps unexamined identical sources unknown in both comparison directions", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-selected-"); + await writeFile(join(root, "main.js"), "export const name = 'one';\n"); + await writeFile( + join(root, "lib.js"), + "export function shared() { return 1; }\n", + ); + const run = async (args: string[]) => { + const result = await cli.run({ + arguments: args, + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseEvidence(result.json); + }; + const complete = await run([ + "analyze-javascript-application", + root, + "--json", + ]); + const selected = await run([ + "analyze-javascript-application", + root, + "--source-path", + "main.js", + "--json", + ]); + const graph = javascriptApplicationAnalysisResultSchema.parse( + complete.normalized_result, + ).graph; + const asset = graph.nodes.find( + (node) => + node.kind === "javascript-asset" && + node.observations.some( + ({ properties }) => properties.path === "lib.js", + ), + ); + expect(asset).toBeDefined(); + for (const [left, right] of [ + [complete, selected], + [selected, complete], + ] as const) { + const input = join(root, "comparison.json"); + await writeFile(input, JSON.stringify({ left, right })); + const result = applicationVersionComparisonResultSchema.parse( + (await run(["compare-application-versions", input, "--json"])) + .normalized_result, + ); + const item = result.items.find( + (entry) => + entry.left_node_id === asset?.node_id || + entry.right_node_id === asset?.node_id, + ); + expect(item).toMatchObject({ + status: "unknown", + match: { basis: "exact-content-digest" }, + dimensions: expect.arrayContaining(["coverage"]), + }); + expect(item?.dimensions).not.toContain("properties"); + expect(item?.limitations.join(" ")).toContain( + "Source file analysis was not complete", + ); + } + }, +); diff --git a/tests/acceptance/applications/packageVersionComparison.test.ts b/tests/acceptance/applications/packageVersionComparison.test.ts new file mode 100644 index 0000000000..97c9b96a72 --- /dev/null +++ b/tests/acceptance/applications/packageVersionComparison.test.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; + +it("compares a fully analyzed package manifest as unchanged", async () => { + const root = await createTestTempDirectory("rea-package-version-comparison-"); + await writeFile( + join(root, "package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0" }), + ); + const otherRoot = await createTestTempDirectory("rea-package-version-other-"); + await writeFile( + join(otherRoot, "package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0" }), + ); + const other = await analyzeJavaScriptApplication({ input_path: otherRoot }); + if (!other.ok) throw other.error; + const otherResult = javascriptApplicationAnalysisResultSchema.parse( + other.value.normalized_result, + ); + const analyzed = await analyzeJavaScriptApplication({ input_path: root }); + if (!analyzed.ok) throw analyzed.error; + const result = javascriptApplicationAnalysisResultSchema.parse( + analyzed.value.normalized_result, + ); + const packageFile = result.graph.nodes.find((node) => + node.observations.some( + ({ properties }) => properties.file_kind === "package-json", + ), + ); + if (packageFile === undefined) + throw new Error("Missing package file observation"); + const side = { + evidenceId: analyzed.value.evidence_id, + rootArtifactSha256: result.root_artifact_sha256, + graph: result.graph, + }; + const comparison = compareJavaScriptApplicationVersions({ + left: side, + right: { + evidenceId: other.value.evidence_id, + rootArtifactSha256: otherResult.root_artifact_sha256, + graph: otherResult.graph, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + expect( + comparison.items.find( + ({ left_node_id }) => left_node_id === packageFile.node_id, + ), + ).toMatchObject({ status: "unchanged", dimensions: [] }); +});