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.
This commit is contained in:
morluto
2026-10-24 06:19:05 +08:00
parent 9dde3fd6a4
commit 30db499db3
9 changed files with 243 additions and 29 deletions
@@ -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) => {
@@ -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,
@@ -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);
@@ -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);
@@ -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 => {
@@ -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");
@@ -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: {
@@ -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({
+3 -8
View File
@@ -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 => {