278 lines
9.2 KiB
TypeScript
278 lines
9.2 KiB
TypeScript
import {
|
|
createJavaScriptApplicationEdge,
|
|
createImmutableJavaScriptApplicationGraphSteps,
|
|
createJavaScriptApplicationNode,
|
|
type JavaScriptApplicationGraph,
|
|
} from "./javascriptApplicationGraph.js";
|
|
import type {
|
|
ApplicationEdge,
|
|
ApplicationNode,
|
|
} from "./javascriptApplicationGraphSchemas.js";
|
|
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
|
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|
import type { ApplicationVersionComparisonItem } from "./javascriptApplicationVersionComparisonSchemas.js";
|
|
|
|
interface ChangeGraphInput {
|
|
readonly left: JavaScriptApplicationGraph;
|
|
readonly right: JavaScriptApplicationGraph;
|
|
readonly leftEvidenceId: string;
|
|
readonly rightEvidenceId: string;
|
|
readonly items: readonly ApplicationVersionComparisonItem[];
|
|
}
|
|
|
|
/** Complete cross-version graph projection. */
|
|
export interface ApplicationChangeGraphProjection {
|
|
readonly graph: JavaScriptApplicationGraph;
|
|
}
|
|
|
|
/** Merge compared nodes and add inferred right-to-left changed_from edges. */
|
|
export const buildJavaScriptApplicationChangeGraph = (
|
|
input: ChangeGraphInput,
|
|
): ApplicationChangeGraphProjection => {
|
|
const allNodes = nodeCandidates(input);
|
|
const preferredRoots = uniqueSorted([
|
|
...input.right.root_node_ids,
|
|
...input.left.root_node_ids,
|
|
]).filter((nodeId) => allNodes.has(nodeId));
|
|
const orderedNodeIds = uniqueSorted([
|
|
...preferredRoots,
|
|
...input.items.flatMap(({ left_node_id: left, right_node_id: right }) => [
|
|
...(right === null ? [] : [right]),
|
|
...(left === null ? [] : [left]),
|
|
]),
|
|
]);
|
|
const retained = new Set(orderedNodeIds);
|
|
const mergedNodes = mergeNodes(
|
|
orderedNodeIds.flatMap((nodeId) => allNodes.get(nodeId) ?? []),
|
|
);
|
|
const sourceEdges = uniqueEdges([
|
|
...input.left.edges,
|
|
...input.right.edges,
|
|
]).filter(
|
|
({ source_node_id: source, target_node_id: target }) =>
|
|
retained.has(source) && retained.has(target),
|
|
);
|
|
const leftById = new Map(
|
|
input.left.nodes.map((node) => [node.node_id, node]),
|
|
);
|
|
const rightById = new Map(
|
|
input.right.nodes.map((node) => [node.node_id, node]),
|
|
);
|
|
const comparisonEdges = input.items.flatMap((item) =>
|
|
changedFromEdge(item, input, retained, leftById, rightById),
|
|
);
|
|
const candidateEdges = uniqueEdges([...sourceEdges, ...comparisonEdges]);
|
|
const rootNodeIds = preferredRoots.filter((nodeId) => retained.has(nodeId));
|
|
const fallbackRoot = mergedNodes[0]?.node_id;
|
|
const steps = createImmutableJavaScriptApplicationGraphSteps({
|
|
schema: "JavaScriptApplicationGraph",
|
|
root_node_ids:
|
|
rootNodeIds.length > 0
|
|
? rootNodeIds
|
|
: fallbackRoot === undefined
|
|
? []
|
|
: [fallbackRoot],
|
|
nodes: mergedNodes,
|
|
edges: candidateEdges,
|
|
coverage: changeGraphCoverage(input),
|
|
limitations: uniqueSorted([
|
|
...input.left.limitations.map((value) => `Left: ${value}`),
|
|
...input.right.limitations.map((value) => `Right: ${value}`),
|
|
"changed_from edges are cross-version inferences and never promote structural or semantic matches to exact identity.",
|
|
"The change graph contains compared entities and their retained relationships; it is not an executable application.",
|
|
]),
|
|
});
|
|
let next = steps.next();
|
|
while (!next.done) next = steps.next();
|
|
return { graph: next.value };
|
|
};
|
|
|
|
const nodeCandidates = (
|
|
input: ChangeGraphInput,
|
|
): Map<string, ApplicationNode[]> => {
|
|
const required = new Set([
|
|
...input.left.root_node_ids,
|
|
...input.right.root_node_ids,
|
|
...input.items.flatMap(({ left_node_id: left, right_node_id: right }) => [
|
|
...(left === null ? [] : [left]),
|
|
...(right === null ? [] : [right]),
|
|
]),
|
|
]);
|
|
const output = new Map<string, ApplicationNode[]>();
|
|
for (const node of [...input.left.nodes, ...input.right.nodes])
|
|
if (required.has(node.node_id))
|
|
output.set(node.node_id, [...(output.get(node.node_id) ?? []), node]);
|
|
return output;
|
|
};
|
|
|
|
const changedFromEdge = (
|
|
item: ApplicationVersionComparisonItem,
|
|
input: ChangeGraphInput,
|
|
retained: ReadonlySet<string>,
|
|
leftById: ReadonlyMap<string, ApplicationNode>,
|
|
rightById: ReadonlyMap<string, ApplicationNode>,
|
|
): ApplicationEdge[] => {
|
|
const left = item.left_node_id;
|
|
const right = item.right_node_id;
|
|
if (
|
|
item.match.status !== "matched" ||
|
|
item.status === "unchanged" ||
|
|
left === null ||
|
|
right === null ||
|
|
left === right ||
|
|
!retained.has(left) ||
|
|
!retained.has(right)
|
|
)
|
|
return [];
|
|
const evidence = comparisonEvidence(
|
|
item,
|
|
input,
|
|
leftById.get(left),
|
|
rightById.get(right),
|
|
);
|
|
return [
|
|
createJavaScriptApplicationEdge({
|
|
source_node_id: right,
|
|
target_node_id: left,
|
|
relation: "changed_from",
|
|
properties: {
|
|
comparison_item_id: item.item_id,
|
|
status: item.status,
|
|
match_basis: item.match.basis,
|
|
match_confidence: item.match.confidence,
|
|
dimensions: item.dimensions,
|
|
},
|
|
evidence,
|
|
}),
|
|
];
|
|
};
|
|
|
|
const comparisonEvidence = (
|
|
item: ApplicationVersionComparisonItem,
|
|
input: ChangeGraphInput,
|
|
left: ApplicationNode | undefined,
|
|
right: ApplicationNode | undefined,
|
|
): ApplicationGraphEvidence => {
|
|
const source =
|
|
right?.observations[0]?.evidence ?? left?.observations[0]?.evidence;
|
|
const complete =
|
|
input.left.coverage.status === "complete" &&
|
|
input.right.coverage.status === "complete";
|
|
return {
|
|
authority: "cross-version-comparison",
|
|
state: "inferred",
|
|
confidence: item.match.confidence === "medium" ? "medium" : "high",
|
|
artifact: source?.artifact ?? {
|
|
available: false,
|
|
reason: "unresolved",
|
|
detail: "Compared node has no artifact-backed observation.",
|
|
},
|
|
location: source?.location ?? {
|
|
available: false,
|
|
reason: "unresolved",
|
|
detail: "Compared node has no actionable source location.",
|
|
},
|
|
extractor: {
|
|
name: "rea-application-version-comparison",
|
|
version: "1",
|
|
operation: "compare_application_versions",
|
|
executable_sha256: null,
|
|
},
|
|
coverage: complete
|
|
? {
|
|
status: "complete",
|
|
truncated: false,
|
|
omitted_count: 0,
|
|
limits: sourceCoverageLimits(input),
|
|
}
|
|
: {
|
|
status: "partial",
|
|
truncated:
|
|
input.left.coverage.truncated || input.right.coverage.truncated,
|
|
omitted_count: combineOmittedCounts(
|
|
input.left.coverage.omitted_count,
|
|
input.right.coverage.omitted_count,
|
|
),
|
|
limits: sourceCoverageLimits(input),
|
|
},
|
|
limitations: [
|
|
"Cross-version pairing is inferred from the stated match basis; changed_from does not prove runtime reachability.",
|
|
...item.limitations,
|
|
],
|
|
evidence_ids: [input.leftEvidenceId, input.rightEvidenceId],
|
|
};
|
|
};
|
|
|
|
const changeGraphCoverage = (
|
|
input: ChangeGraphInput,
|
|
): JavaScriptApplicationGraph["coverage"] => {
|
|
if (
|
|
input.left.coverage.status === "complete" &&
|
|
input.right.coverage.status === "complete"
|
|
)
|
|
return {
|
|
status: "complete",
|
|
truncated: false,
|
|
omitted_count: 0,
|
|
limits: sourceCoverageLimits(input),
|
|
};
|
|
return {
|
|
status: "partial",
|
|
truncated: input.left.coverage.truncated || input.right.coverage.truncated,
|
|
omitted_count: combineOmittedCounts(
|
|
input.left.coverage.omitted_count,
|
|
input.right.coverage.omitted_count,
|
|
),
|
|
limits: sourceCoverageLimits(input),
|
|
};
|
|
};
|
|
|
|
const sourceCoverageLimits = (input: ChangeGraphInput) =>
|
|
[
|
|
...new Map(
|
|
[...input.left.coverage.limits, ...input.right.coverage.limits].map(
|
|
(limit) => [`${limit.name}\0${limit.value}\0${limit.unit}`, limit],
|
|
),
|
|
).values(),
|
|
].sort((left, right) => compareUnicodeCodePoints(left.name, right.name));
|
|
|
|
const combineOmittedCounts = (left: number | null, right: number | null) =>
|
|
left === null || right === null ? null : left + right;
|
|
|
|
const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => {
|
|
const groups = new Map<string, ApplicationNode[]>();
|
|
for (const node of nodes)
|
|
groups.set(node.node_id, [...(groups.get(node.node_id) ?? []), node]);
|
|
const merged = [...groups.values()].map((group) => {
|
|
const first = group[0];
|
|
if (first === undefined)
|
|
throw new TypeError("Empty change-graph node group");
|
|
const observations = [
|
|
...new Map(
|
|
group
|
|
.flatMap(({ observations: values }) => values)
|
|
.map((observation) => [observation.observation_id, observation]),
|
|
).values(),
|
|
].sort((left, right) =>
|
|
compareUnicodeCodePoints(left.observation_id, right.observation_id),
|
|
);
|
|
return createJavaScriptApplicationNode({
|
|
kind: first.kind,
|
|
identity: first.identity,
|
|
observations: observations.map(
|
|
({ observation_id: _id, identifier_strategy: _strategy, ...value }) =>
|
|
value,
|
|
),
|
|
});
|
|
});
|
|
return merged.sort((left, right) =>
|
|
compareUnicodeCodePoints(left.node_id, right.node_id),
|
|
);
|
|
};
|
|
|
|
const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] =>
|
|
[...new Map(edges.map((edge) => [edge.edge_id, edge])).values()].sort(
|
|
(left, right) => compareUnicodeCodePoints(left.edge_id, right.edge_id),
|
|
);
|