From 30db499db310f073d9bf3d3acfd77985e1cb7e2e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:19:05 +0800 Subject: [PATCH] fix(javascript): preserve graph facts across omissions and versions Omit absent inventory facts without erasing explicit unknowns. Compare canonical file occurrences by source path and digest while preserving complete container provenance in observations and Evidence. Consolidate candidate-capacity fixtures around unresolved alternatives and update inventory coverage and source-occurrence oracles. Preserve resource, identity, relationship uncertainty, cancellation, and transfer regressions. --- .../JavaScriptArtifactGraphStructure.test.ts | 44 +++++++ .../JavaScriptArtifactGraphStructure.ts | 12 +- .../JavaScriptSemanticGraphBuilder.test.ts | 11 +- ...scriptApplicationVersionComparison.test.ts | 116 ++++++++++++++++++ .../javascriptApplicationVersionItems.ts | 23 +++- .../javascriptArtifactReconstruction.test.ts | 7 +- .../javascriptModuleRelationships.test.ts | 41 +++++-- .../javascriptIntegrityPolicy.test.ts | 7 +- .../fixtures/javascriptPrimitiveExpansion.ts | 11 +- 9 files changed, 243 insertions(+), 29 deletions(-) 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 => {