diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 55d391017d..2b85cd3a3c 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -249,16 +249,14 @@ export const createArtifactEdges = ( rootArtifactId; const semantic = { parent_artifact_id: parentArtifactId, - child_artifact_id: occurrence.artifact_id, relation: occurrence.entry_kind === "slice" ? ("slice-of" as const) : relationFor(occurrence.logical_path), occurrence_id: occurrence.occurrence_id, - logical_path: occurrence.logical_path, }; edges.push({ - edge_id: artifactEdgeId(semantic), + edge_id: artifactEdgeId(semantic, occurrence), ...semantic, producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null, ordinal: edges.length, diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 3a11011823..04bc33ec9f 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -350,19 +350,20 @@ const relationsByPath = ( inventory.occurrences.map((item) => [item.occurrence_id, item]), ); for (const edge of inventory.edges) { - const path = edge.logical_path ?? "."; - const values = output.get(path); const occurrence = occurrences.get(edge.occurrence_id); + // An incomplete inventory can omit the occurrence that names the child. + if (occurrence?.artifact_id === null || occurrence === undefined) continue; + const path = occurrence.logical_path; + const values = output.get(path); const projection: RelationProjection = { parent_logical_path: - occurrence?.parent_occurrence_id === null || - occurrence?.parent_occurrence_id === undefined + occurrence.parent_occurrence_id === null ? null : (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ?? null), - child_artifact_id: edge.child_artifact_id, + child_artifact_id: occurrence.artifact_id, relation: edge.relation, - logical_path: edge.logical_path, + logical_path: occurrence.logical_path, producer: edge.producer, }; if (values === undefined) output.set(path, [projection]); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 34e4af9323..02f612a224 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -151,12 +151,14 @@ export const integrityContradictionSchema = z.object({ limitations: z.array(z.string()).min(1), }); -/** Parent-child derivation relation between two content-addressed artifacts. */ +/** + * Parent-child derivation relation between two content-addressed artifacts. + * The child artifact and its logical path are those of the edge's occurrence. + */ const artifactEdgeSchema = z.object({ edge_id: edgeIdSchema, ordinal: z.number().int().min(0), parent_artifact_id: artifactIdSchema, - child_artifact_id: artifactIdSchema, relation: z.enum([ "contains", "extracts", @@ -167,7 +169,6 @@ const artifactEdgeSchema = z.object({ "derived-from", ]), occurrence_id: occurrenceIdSchema, - logical_path: boundedRelativePathSchema.nullable(), producer: artifactCommandSchema.nullable(), }); diff --git a/src/domain/artifactIdentity.ts b/src/domain/artifactIdentity.ts index ad773de332..3c46a3b975 100644 --- a/src/domain/artifactIdentity.ts +++ b/src/domain/artifactIdentity.ts @@ -71,14 +71,21 @@ export const artifactContradictionId = (input: { "Artifact", )}`; -/** Identify an edge by its semantic relationship and exact occurrence. */ +/** + * Identify an edge by its semantic relationship and exact occurrence. The + * child artifact and logical path come from that occurrence. + */ export const artifactEdgeId = ( - semantic: Pick< - ArtifactEdge, - | "parent_artifact_id" - | "child_artifact_id" - | "relation" - | "occurrence_id" - | "logical_path" - >, -): string => `edge_${digestCanonicalValue(semantic, "Artifact")}`; + edge: Pick, + occurrence: Pick, +): string => + `edge_${digestCanonicalValue( + { + parent_artifact_id: edge.parent_artifact_id, + child_artifact_id: occurrence.artifact_id, + relation: edge.relation, + occurrence_id: edge.occurrence_id, + logical_path: occurrence.logical_path, + }, + "Artifact", + )}`; diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index a1c74794ac..33491c7a77 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -211,8 +211,14 @@ const validateCompleteInventory = (inventory: InventorySet): void => { ); for (const occurrence of inventory.occurrences) validateOccurrence(occurrence, inventory, nodeIds, occurrenceIds); + const occurrencesById = new Map( + inventory.occurrences.map((occurrence) => [ + occurrence.occurrence_id, + occurrence, + ]), + ); for (const edge of inventory.edges) - validateEdge(edge, nodeIds, occurrenceIds); + validateEdge(edge, nodeIds, occurrencesById); for (const contradiction of inventory.integrityContradictions) { if ( !nodeIds.has(contradiction.parent_artifact_id) || @@ -273,22 +279,17 @@ const validateOccurrence = ( const validateEdge = ( edge: ArtifactInventoryResult["edges"][number], nodeIds: ReadonlySet, - occurrenceIds: ReadonlySet, + occurrences: ReadonlyMap, ): void => { + const occurrence = occurrences.get(edge.occurrence_id); if ( !nodeIds.has(edge.parent_artifact_id) || - !nodeIds.has(edge.child_artifact_id) || - !occurrenceIds.has(edge.occurrence_id) + occurrence?.artifact_id === null || + occurrence === undefined || + !nodeIds.has(occurrence.artifact_id) ) throw new TypeError("Artifact edge references a missing graph member"); - const semantic = { - parent_artifact_id: edge.parent_artifact_id, - child_artifact_id: edge.child_artifact_id, - relation: edge.relation, - occurrence_id: edge.occurrence_id, - logical_path: edge.logical_path, - }; - if (edge.edge_id !== artifactEdgeId(semantic)) + if (edge.edge_id !== artifactEdgeId(edge, occurrence)) throw new TypeError("Artifact edge ID does not match its identity"); }; diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index 9a6fbd2f64..4d40227f32 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -50,10 +50,9 @@ describe("artifact inventory entry order", () => { const parent = artifactOccurrenceAt(observed, parentPath); expect(observed.edges).toContainEqual( expect.objectContaining({ - logical_path: path, + occurrence_id: occurrence.occurrence_id, relation: "contains", parent_artifact_id: parent.artifact_id, - child_artifact_id: occurrence.artifact_id, }), ); } diff --git a/tests/boundary/filesystem/asarInventoryContainment.test.ts b/tests/boundary/filesystem/asarInventoryContainment.test.ts index 1be8a81739..0f4b6bab9f 100644 --- a/tests/boundary/filesystem/asarInventoryContainment.test.ts +++ b/tests/boundary/filesystem/asarInventoryContainment.test.ts @@ -75,10 +75,9 @@ describe("expanded ASAR inventory containment", () => { const child = artifactOccurrenceAt(nested, path); expect(nested.edges).toContainEqual( expect.objectContaining({ - logical_path: path, + occurrence_id: child.occurrence_id, relation: "contains", parent_artifact_id: artifactOccurrenceAt(nested, parent).artifact_id, - child_artifact_id: child.artifact_id, }), ); if (path.startsWith("resources/app.asar/")) diff --git a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts index f6e226a1df..31f5915f7e 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts @@ -65,7 +65,7 @@ describe("artifact directory inventory", () => { expect.objectContaining({ relation: "maps-source", parent_artifact_id: script?.artifact_id, - child_artifact_id: sourceMap?.artifact_id, + occurrence_id: sourceMap?.occurrence_id, }), ]), );