diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index 4546877d7f..318b1184cb 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -80,6 +80,9 @@ const contextFor = ( graph_sha256: SHA256, }, nodes: [{ artifact_id: `art_${SHA256}`, sha256: SHA256, size: 1 }], + // These fixtures model complete directory traversal; source-analysis gaps + // remain on the individual files and do not imply missing inventory paths. + coverage: { scope: "root", status: "complete" }, } as unknown as ArtifactInventorySnapshot; const fileSet = { files: [file], @@ -157,6 +160,47 @@ describe("skipped JavaScript parse reporting", () => { }); }); +it("preserves omitted inventory facts on an unavailable artifact", () => { + const file = skippedFile(); + const base = contextFor(file, analysisFor([])); + const context: JavaScriptArtifactGraphContext = { + ...base, + fileSet: { + ...base.fileSet, + files: [], + unavailable_relevant_files: [ + { + path: file.path, + container_sha256: file.container_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: null, + }, + ], + }, + }; + addJavaScriptArtifactFiles(context); + const entry = context.accumulator + .nodes() + .find( + ({ identity }) => + identity.strategy === "canonical-path" && identity.path === file.path, + ); + expect(entry?.observations[0]?.properties).toEqual({ + path: file.path, + entry_sha256: null, + bytes: null, + inventory_artifact_id: null, + content_available: false, + }); + expect( + context.accumulator + .edges() + .find(({ target_node_id }) => target_node_id === entry?.node_id) + ?.properties, + ).toEqual({ path: file.path, content_available: false }); +}); + it.each([false, true])( "retains possible setter owners independently of proven imports (possible owner: %s)", (possibleOwner) => { diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 55e145bb53..18c912c93b 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -390,8 +390,12 @@ const addUnavailableArtifactEntries = ( ...(file.inventory_occurrence_id === undefined ? {} : { inventory_occurrence_id: file.inventory_occurrence_id }), - hash_status: file.hash_status, - artifact_kind: file.artifact_kind, + ...(file.hash_status === undefined + ? {} + : { hash_status: file.hash_status }), + ...(file.artifact_kind === undefined + ? {} + : { artifact_kind: file.artifact_kind }), ...(file.kind === undefined ? {} : { kind: file.kind }), content_available: false, }, @@ -414,7 +418,9 @@ const addUnavailableArtifactEntries = ( properties: { path: file.path, content_available: false, - hash_status: file.hash_status, + ...(file.hash_status === undefined + ? {} + : { hash_status: file.hash_status }), }, evidence: artifactObservationEvidence({ sha256: file.container_sha256, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 79226e184d..7dc372c211 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -1,4 +1,7 @@ -import { primitiveByteExpansionSource } from "../../../tests/fixtures/javascriptPrimitiveExpansion.js"; +import { + primitiveByteExpansionSource, + primitiveCandidateExpansionExpression, +} from "../../../tests/fixtures/javascriptPrimitiveExpansion.js"; import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js"; import * as t from "@babel/types"; import { expect, it } from "vitest"; @@ -250,8 +253,7 @@ it("keeps dynamic calls explicit and returns complete deterministic results", () }); it("preserves primitive candidate budget unknowns in semantic graph coverage", () => { - const term = '(true ? "a" : "b")'; - const source = `const answer = ${Array.from({ length: 20 }, () => term).join(" + ")};`; + const source = `const answer = ${primitiveCandidateExpansionExpression()};`; const ir = analyzeJavaScriptSemantics(source); const graph = graphFor(source, ir); @@ -287,8 +289,7 @@ it("preserves derived string-byte limit reasons in semantic graph unknowns", () }); it("retains resource-limit reasons at nested object property slots", () => { - const term = '(true ? "a" : "b")'; - const source = `const answer = { nested: { value: ${Array.from({ length: 20 }, () => term).join(" + ")} } };`; + const source = `const answer = { nested: { value: ${primitiveCandidateExpansionExpression()} } };`; const ir = analyzeJavaScriptSemantics(source); const graph = graphFor(source, ir); diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts index fdb36b5dc5..d434cbae29 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createEvidence } from "../evidence.js"; +import type { JsonValue } from "../jsonValue.js"; import { createJavaScriptApplicationEdge, createJavaScriptApplicationGraph, @@ -143,6 +144,121 @@ const nativeEvidence = (sha256: string | undefined, ordinal: number) => }, ); +const occurrenceGraph = ( + rootSha256: string, + occurrence: JsonValue, +): JavaScriptApplicationGraph => { + const sourceSha256 = "c".repeat(64); + const module = createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: sourceSha256, + namespace: "source-module", + key: `${rootSha256}:main.js`, + }, + observations: [ + { + label: "main.js", + properties: { + semantic_role: "source-module", + source_sha256: sourceSha256, + file_occurrence: occurrence, + }, + evidence: artifactEvidence(sourceSha256, "main.js"), + }, + ], + }); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [module.node_id], + nodes: [module], + edges: [], + coverage: completeCoverage, + limitations: [], + }); +}; + +const occurrenceDifferences: readonly { + readonly name: string; + readonly leftOccurrence: JsonValue; + readonly rightOccurrence: JsonValue; +}[] = [ + { + name: "path", + leftOccurrence: { + container_sha256: LEFT_SHA, + path: "main.js", + sha256: "c".repeat(64), + }, + rightOccurrence: { + container_sha256: RIGHT_SHA, + path: "moved.js", + sha256: "c".repeat(64), + }, + }, + { + name: "content digest", + leftOccurrence: { + container_sha256: LEFT_SHA, + path: "main.js", + sha256: "c".repeat(64), + }, + rightOccurrence: { + container_sha256: RIGHT_SHA, + path: "main.js", + sha256: "d".repeat(64), + }, + }, + { + name: "noncanonical data", + leftOccurrence: { container_sha256: LEFT_SHA }, + rightOccurrence: { container_sha256: RIGHT_SHA }, + }, +]; + +it.each(occurrenceDifferences)( + "keeps occurrence $name differences visible without altering source evidence", + ({ leftOccurrence, rightOccurrence }) => { + const left = occurrenceGraph(LEFT_SHA, leftOccurrence); + const right = occurrenceGraph(RIGHT_SHA, rightOccurrence); + const original = JSON.stringify([left, right]); + const result = compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: LEFT_SHA, + graph: left, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: RIGHT_SHA, + graph: right, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + + expect(result.items).toMatchObject([ + { + status: "changed", + match: { basis: "exact-module-source-digest", confidence: "exact" }, + dimensions: ["properties"], + }, + ]); + expect(JSON.stringify([left, right])).toBe(original); + expect(left.nodes[0]?.observations[0]?.properties.file_occurrence).toEqual( + leftOccurrence, + ); + expect(right.nodes[0]?.observations[0]?.properties.file_occurrence).toEqual( + rightOccurrence, + ); + expect(left.nodes[0]?.observations[0]?.evidence).toEqual( + right.nodes[0]?.observations[0]?.evidence, + ); + }, +); + describe("application version comparison Evidence links", () => { it("links every exact digest observation only to its paired or unmatched item", () => { const left = buildGraph(LEFT_SHA, 1); diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 57f000a8e4..6744b4d1ac 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -7,6 +7,7 @@ import type { ApplicationNode, } from "./javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; +import { javaScriptFileOccurrenceSchema } from "./javascriptArtifactFiles.js"; import { applicationEndpointCorrespondence } from "./javascriptApplicationEndpointCorrespondence.js"; import type { ApplicationVersionComparisonItem } from "./javascriptApplicationVersionComparisonSchemas.js"; import type { @@ -506,13 +507,33 @@ const properties = (node: ApplicationNode) => node.observations .map(({ label, properties: values, source_map_reference }) => ({ label, - properties: values, + properties: comparisonPropertyValues(values), source_map_reference, })) .sort((left, right) => compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)), ); +const comparisonPropertyValues = ( + values: ApplicationNode["observations"][number]["properties"], +) => { + const occurrence = javaScriptFileOccurrenceSchema.safeParse( + values.file_occurrence, + ); + if (!occurrence.success) return values; + // 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, + file_occurrence: { + path: occurrence.data.path, + sha256: occurrence.data.sha256, + }, + }; +}; + const indexRelationships = ( graph: JavaScriptApplicationGraph, ): RelationshipIndex => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 714d5b8e5b..13c03208d0 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -1,5 +1,5 @@ import { - primitiveCandidateExpansionSource, + primitiveCandidateExpansionExpression, primitiveByteExpansionSource, } from "../../../fixtures/javascriptPrimitiveExpansion.js"; import { createHash } from "node:crypto"; @@ -743,7 +743,10 @@ const rejectInvalidTransfer = async ( it("reports semantic value resource limits in application graph coverage", async () => { const root = await createTestTempDirectory("rea-javascript-semantic-limit-"); - await writeFile(join(root, "app.js"), primitiveCandidateExpansionSource()); + await writeFile( + join(root, "app.js"), + `const answer = { nested: ${primitiveCandidateExpansionExpression()} };`, + ); await writeFile(join(root, "growth.js"), primitiveByteExpansionSource()); const snapshot = await scanCanonicalArtifactInventory(root, {}); const reader = createJavaScriptArtifactReader(root, "directory"); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index 9a89e3d81b..70a08c3ca6 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -9,6 +9,7 @@ import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascri import { compareJavaScriptApplicationVersions } from "../../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; import { semanticResourceLimitReason } from "../../../../src/domain/javascript/javascriptSemanticResourceLimits.js"; import { getterExpansionSource } from "../../../fixtures/javascriptGetterExpansion.js"; +import { primitiveCandidateExpansionExpression } from "../../../fixtures/javascriptPrimitiveExpansion.js"; import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js"; import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js"; import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; @@ -95,7 +96,7 @@ it.each([ ["getter-expansion", getterExpansionSource()], [ "primitive-candidates", - `const result = ${Array.from({ length: 20 }, () => '(true ? "a" : "b")').join(" + ")};`, + `const result = ${primitiveCandidateExpansionExpression()};`, ], ] as const)( "deduplicates %s limitations on import and export relationships", @@ -137,11 +138,13 @@ it.each([ describe("cross-version source module identity", () => { it("keeps exact source identity without claiming unchanged unresolved relationships", async () => { const root = await createTestTempDirectory("rea-source-module-sibling-"); + const mainSource = 'require("./util.js");\n'; + const sourceSha256 = createHash("sha256").update(mainSource).digest("hex"); const versions = await Promise.all( ["left", "right"].map(async (name) => { const path = join(root, name); await writeFixtureFiles(path, { - "main.js": 'require("./util.js");\n', + "main.js": mainSource, "util.js": `module.exports = ${JSON.stringify(name)};\n`, }); return reconstructJavaScriptFixture(path); @@ -160,12 +163,34 @@ describe("cross-version source module identity", () => { const rightObservation = rightModule.observations.find( ({ properties }) => properties.semantic_role === "source-module", ); - expect(leftObservation?.properties).not.toHaveProperty("container_sha256"); - expect(rightObservation?.properties).not.toHaveProperty("container_sha256"); - expect(leftObservation?.properties).toEqual(rightObservation?.properties); - expect(leftObservation?.properties.source_sha256).toBe( - rightObservation?.properties.source_sha256, - ); + if (leftObservation === undefined || rightObservation === undefined) + throw new Error("Missing main source observation"); + expect(left.root_artifact_sha256).not.toBe(right.root_artifact_sha256); + expect(leftModule.node_id).not.toBe(rightModule.node_id); + expect(leftObservation.properties).not.toHaveProperty("container_sha256"); + expect(rightObservation.properties).not.toHaveProperty("container_sha256"); + const { file_occurrence: leftOccurrence, ...leftSourceFacts } = + leftObservation.properties; + const { file_occurrence: rightOccurrence, ...rightSourceFacts } = + rightObservation.properties; + expect(leftOccurrence).toEqual({ + container_sha256: left.root_artifact_sha256, + path: "main.js", + sha256: sourceSha256, + }); + expect(rightOccurrence).toEqual({ + container_sha256: right.root_artifact_sha256, + path: "main.js", + sha256: sourceSha256, + }); + expect(leftSourceFacts).toEqual(rightSourceFacts); + expect(leftSourceFacts.source_sha256).toBe(sourceSha256); + expect(leftObservation.evidence).toEqual(rightObservation.evidence); + expect(leftObservation.evidence.artifact).toMatchObject({ + available: true, + artifact_id: `art_${sourceSha256}`, + sha256: sourceSha256, + }); const comparison = compareJavaScriptApplicationVersions({ left: { diff --git a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts index 67b167bdfb..512a36b3a3 100644 --- a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts +++ b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts @@ -1,5 +1,5 @@ import { - primitiveCandidateExpansionSource, + primitiveCandidateExpansionExpression, primitiveByteExpansionSource, } from "../../fixtures/javascriptPrimitiveExpansion.js"; import { createHash } from "node:crypto"; @@ -135,7 +135,10 @@ it("keeps an ASAR-named file without an ASAR header opaque during JavaScript rec it("retains semantic resource limits alongside recorded integrity contradictions", async () => { const { archive } = await createStrippedAsarAddon([ - { path: "app.js", contents: primitiveCandidateExpansionSource() }, + { + path: "app.js", + contents: `const answer = { nested: ${primitiveCandidateExpansionExpression()} };`, + }, { path: "growth.js", contents: primitiveByteExpansionSource() }, ]); const analyzed = await analyzeJavaScriptApplication({ diff --git a/tests/fixtures/javascriptPrimitiveExpansion.ts b/tests/fixtures/javascriptPrimitiveExpansion.ts index 1189dc9025..03f2d3c120 100644 --- a/tests/fixtures/javascriptPrimitiveExpansion.ts +++ b/tests/fixtures/javascriptPrimitiveExpansion.ts @@ -1,11 +1,6 @@ -/** Source whose concatenated alternatives exceed primitive candidate storage. */ -export const primitiveCandidateExpansionSource = (): string => { - const expression = Array.from( - { length: 20 }, - () => '(true ? "a" : "b")', - ).join(" + "); - return `const answer = { nested: ${expression} };`; -}; +/** Expression whose unresolved alternatives exceed primitive candidate storage. */ +export const primitiveCandidateExpansionExpression = (): string => + Array.from({ length: 20 }, () => '(choice ? "a" : "b")').join(" + "); /** Source whose repeated doubling exceeds derived primitive string storage. */ export const primitiveByteExpansionSource = (): string => {