mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
a6f76f5da9
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { canonicalJson } from "../comparisonSemantics.js";
|
|
import {
|
|
compareUnicodeCodePoints,
|
|
compositeKey,
|
|
} from "../unicodeCodePointOrder.js";
|
|
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
|
|
/** One deterministic one-to-one match between two graph versions. */
|
|
type ApplicationVersionPairMatch =
|
|
| {
|
|
readonly basis:
|
|
| "exact-node-identity"
|
|
| "exact-content-digest"
|
|
| "exact-module-source-digest";
|
|
readonly confidence: "exact";
|
|
}
|
|
| {
|
|
readonly basis: "source-map-identity";
|
|
readonly confidence: "high";
|
|
}
|
|
| {
|
|
readonly basis: "structural-fingerprint" | "semantic-key";
|
|
readonly confidence: "medium";
|
|
};
|
|
|
|
export type ApplicationVersionNodePair = {
|
|
readonly left: ApplicationNode;
|
|
readonly right: ApplicationNode;
|
|
} & ApplicationVersionPairMatch;
|
|
|
|
/** Matched nodes plus ambiguity candidates that were never forced into pairs. */
|
|
export interface ApplicationVersionMatchingProjection {
|
|
readonly pairs: ApplicationVersionNodePair[];
|
|
readonly unmatchedLeft: ApplicationNode[];
|
|
readonly unmatchedRight: ApplicationNode[];
|
|
readonly candidatesForLeft: ReadonlyMap<string, readonly string[]>;
|
|
readonly candidatesForRight: ReadonlyMap<string, readonly string[]>;
|
|
}
|
|
|
|
interface MatchTier {
|
|
readonly match: ApplicationVersionPairMatch;
|
|
readonly key: (node: ApplicationNode) => string | null;
|
|
}
|
|
|
|
const matchTiers = (): readonly MatchTier[] => [
|
|
{
|
|
match: { basis: "exact-content-digest", confidence: "exact" },
|
|
key: contentDigestKey,
|
|
},
|
|
{
|
|
match: { basis: "exact-module-source-digest", confidence: "exact" },
|
|
key: moduleSourceDigestKey,
|
|
},
|
|
{
|
|
match: { basis: "exact-node-identity", confidence: "exact" },
|
|
key: nodeIdentityKey,
|
|
},
|
|
{
|
|
match: { basis: "source-map-identity", confidence: "high" },
|
|
key: sourceMapKey,
|
|
},
|
|
{
|
|
match: { basis: "structural-fingerprint", confidence: "medium" },
|
|
key: structuralFingerprintKey,
|
|
},
|
|
{
|
|
match: { basis: "semantic-key", confidence: "medium" },
|
|
key: semanticKey,
|
|
},
|
|
];
|
|
|
|
/** Apply ordered, unique-only matching tiers without fuzzy pairing. */
|
|
export const matchJavaScriptApplicationVersions = (
|
|
leftNodes: readonly ApplicationNode[],
|
|
rightNodes: readonly ApplicationNode[],
|
|
): ApplicationVersionMatchingProjection => {
|
|
const left = new Map(leftNodes.map((node) => [node.node_id, node]));
|
|
const right = new Map(rightNodes.map((node) => [node.node_id, node]));
|
|
const leftCandidates = new Map<string, Set<string>>();
|
|
const rightCandidates = new Map<string, Set<string>>();
|
|
const pairs: ApplicationVersionNodePair[] = [];
|
|
for (const tier of matchTiers())
|
|
applyTier({ left, right, pairs, leftCandidates, rightCandidates, tier });
|
|
const remainingLeft = sortedNodes(left.values());
|
|
const remainingRight = sortedNodes(right.values());
|
|
const leftIds = new Set(remainingLeft.map(({ node_id: id }) => id));
|
|
const rightIds = new Set(remainingRight.map(({ node_id: id }) => id));
|
|
return {
|
|
pairs: pairs.sort((a, b) =>
|
|
compareUnicodeCodePoints(
|
|
`${a.left.node_id}\0${a.right.node_id}`,
|
|
`${b.left.node_id}\0${b.right.node_id}`,
|
|
),
|
|
),
|
|
unmatchedLeft: remainingLeft,
|
|
unmatchedRight: remainingRight,
|
|
candidatesForLeft: filteredCandidates(leftCandidates, rightIds),
|
|
candidatesForRight: filteredCandidates(rightCandidates, leftIds),
|
|
};
|
|
};
|
|
|
|
interface TierContext {
|
|
readonly left: Map<string, ApplicationNode>;
|
|
readonly right: Map<string, ApplicationNode>;
|
|
readonly pairs: ApplicationVersionNodePair[];
|
|
readonly leftCandidates: Map<string, Set<string>>;
|
|
readonly rightCandidates: Map<string, Set<string>>;
|
|
readonly tier: MatchTier;
|
|
}
|
|
|
|
const applyTier = (context: TierContext): void => {
|
|
const leftByKey = nodesByKey(context.left.values(), context.tier.key);
|
|
const rightByKey = nodesByKey(context.right.values(), context.tier.key);
|
|
for (const key of [...leftByKey.keys()].sort(compareUnicodeCodePoints)) {
|
|
const leftGroup = leftByKey.get(key) ?? [];
|
|
const rightGroup = rightByKey.get(key) ?? [];
|
|
if (rightGroup.length === 0) continue;
|
|
recordCandidates(
|
|
leftGroup,
|
|
rightGroup,
|
|
context.leftCandidates,
|
|
context.rightCandidates,
|
|
);
|
|
if (leftGroup.length !== 1 || rightGroup.length !== 1) continue;
|
|
const leftNode = leftGroup[0];
|
|
const rightNode = rightGroup[0];
|
|
if (leftNode === undefined || rightNode === undefined) continue;
|
|
context.pairs.push({
|
|
left: leftNode,
|
|
right: rightNode,
|
|
...context.tier.match,
|
|
});
|
|
context.left.delete(leftNode.node_id);
|
|
context.right.delete(rightNode.node_id);
|
|
}
|
|
};
|
|
|
|
const nodesByKey = (
|
|
nodes: Iterable<ApplicationNode>,
|
|
keyFor: (node: ApplicationNode) => string | null,
|
|
): Map<string, ApplicationNode[]> => {
|
|
const output = new Map<string, ApplicationNode[]>();
|
|
for (const node of nodes) {
|
|
const key = keyFor(node);
|
|
if (key === null) continue;
|
|
output.set(key, [...(output.get(key) ?? []), node]);
|
|
}
|
|
return output;
|
|
};
|
|
|
|
const recordCandidates = (
|
|
left: readonly ApplicationNode[],
|
|
right: readonly ApplicationNode[],
|
|
leftCandidates: Map<string, Set<string>>,
|
|
rightCandidates: Map<string, Set<string>>,
|
|
): void => {
|
|
for (const leftNode of left) {
|
|
const candidates =
|
|
leftCandidates.get(leftNode.node_id) ?? new Set<string>();
|
|
for (const rightNode of right) candidates.add(rightNode.node_id);
|
|
leftCandidates.set(leftNode.node_id, candidates);
|
|
}
|
|
for (const rightNode of right) {
|
|
const candidates =
|
|
rightCandidates.get(rightNode.node_id) ?? new Set<string>();
|
|
for (const leftNode of left) candidates.add(leftNode.node_id);
|
|
rightCandidates.set(rightNode.node_id, candidates);
|
|
}
|
|
};
|
|
|
|
const filteredCandidates = (
|
|
candidates: ReadonlyMap<string, ReadonlySet<string>>,
|
|
retained: ReadonlySet<string>,
|
|
): ReadonlyMap<string, readonly string[]> =>
|
|
new Map(
|
|
[...candidates.entries()].flatMap(([nodeId, values]) => {
|
|
const filtered = [...values]
|
|
.filter((value) => retained.has(value))
|
|
.sort(compareUnicodeCodePoints);
|
|
return filtered.length === 0 ? [] : [[nodeId, filtered] as const];
|
|
}),
|
|
);
|
|
|
|
const nodeIdentityKey = (node: ApplicationNode): string =>
|
|
`node\0${node.kind}\0${node.node_id}`;
|
|
|
|
const contentDigestKey = (node: ApplicationNode): string | null =>
|
|
node.identity.strategy === "content-digest"
|
|
? `content\0${node.kind}\0${node.identity.sha256}`
|
|
: null;
|
|
|
|
const moduleSourceDigestKey = (node: ApplicationNode): string | null => {
|
|
if (node.kind !== "javascript-module") return null;
|
|
const value = uniqueStringProperty(node, "source_sha256");
|
|
return value === null ? null : `module-source\0${node.kind}\0${value}`;
|
|
};
|
|
|
|
const sourceMapKey = (node: ApplicationNode): string | null =>
|
|
node.kind === "source-module" &&
|
|
node.identity.strategy === "source-map-original"
|
|
? `source-map\0${canonicalJson([node.identity.source_root, node.identity.original_source])}`
|
|
: null;
|
|
|
|
const structuralFingerprintKey = (node: ApplicationNode): string | null => {
|
|
if (node.kind !== "javascript-module") return null;
|
|
const digest = uniqueStringProperty(node, "structural_fingerprint_sha256");
|
|
const algorithm = uniqueStringProperty(
|
|
node,
|
|
"structural_fingerprint_algorithm",
|
|
);
|
|
return digest === null || algorithm === null
|
|
? null
|
|
: `structural\0${node.kind}\0${algorithm}\0${digest}`;
|
|
};
|
|
|
|
const semanticKey = (node: ApplicationNode): string | null => {
|
|
if (
|
|
[
|
|
"javascript-module",
|
|
"javascript-chunk",
|
|
"runtime-script-instance",
|
|
"unknown",
|
|
].includes(node.kind)
|
|
)
|
|
return null;
|
|
const properties = firstProperties(node);
|
|
const value = semanticValue(node, properties);
|
|
return value === null ? null : `semantic\0${node.kind}\0${value}`;
|
|
};
|
|
|
|
const semanticValue = (
|
|
node: ApplicationNode,
|
|
properties: Readonly<Record<string, JsonValue>>,
|
|
): string | null => {
|
|
if (node.kind === "endpoint") {
|
|
const kind = nonEmptyString(properties.endpoint_kind);
|
|
const value = exactString(properties.value);
|
|
return kind === null || value === null ? null : `${kind}\0${value}`;
|
|
}
|
|
if (node.kind === "storage")
|
|
return joined(
|
|
properties.storage_kind,
|
|
properties.name ?? properties.mechanism,
|
|
);
|
|
if (node.kind === "ipc-channel")
|
|
return properties.resolution === "dynamic"
|
|
? null
|
|
: (exactString(properties.channel) ?? firstLabel(node));
|
|
if (node.kind === "context-bridge-api")
|
|
return (
|
|
firstLabel(node) ??
|
|
exactString(properties.api_key) ??
|
|
nonEmptyString(properties.api_name)
|
|
);
|
|
if (node.kind === "native-export")
|
|
return (
|
|
joined(
|
|
properties.specifier,
|
|
compositeKey([
|
|
stringList(properties.requested_members),
|
|
typeof properties.namespace_access === "boolean"
|
|
? properties.namespace_access
|
|
: null,
|
|
typeof properties.dynamic_member_access === "boolean"
|
|
? properties.dynamic_member_access
|
|
: null,
|
|
]),
|
|
) ?? firstLabel(node)
|
|
);
|
|
if (
|
|
[
|
|
"electron-main",
|
|
"electron-preload",
|
|
"electron-renderer",
|
|
"electron-utility",
|
|
].includes(node.kind)
|
|
)
|
|
return joined(properties.declared_path, properties.resolved_path);
|
|
if (node.kind === "package") return nonEmptyString(properties.name);
|
|
return canonicalPath(node) ?? nonEmptyString(properties.path);
|
|
};
|
|
|
|
const canonicalPath = (node: ApplicationNode): string | null =>
|
|
node.identity.strategy === "canonical-path" ? node.identity.path : null;
|
|
|
|
const firstProperties = (node: ApplicationNode) =>
|
|
node.observations[0]?.properties ?? {};
|
|
|
|
const firstLabel = (node: ApplicationNode): string | null =>
|
|
node.observations.find(({ label }) => label !== null)?.label ?? null;
|
|
|
|
const uniqueStringProperty = (
|
|
node: ApplicationNode,
|
|
name: string,
|
|
): string | null => {
|
|
const values = [
|
|
...new Set(
|
|
node.observations
|
|
.map(({ properties }) => properties[name])
|
|
.filter((value): value is string => typeof value === "string"),
|
|
),
|
|
];
|
|
return values.length === 1 ? (values[0] ?? null) : null;
|
|
};
|
|
|
|
const joined = (...values: readonly unknown[]): string | null => {
|
|
const strings = values.map(nonEmptyString);
|
|
return strings.some((value) => value === null) ? null : compositeKey(strings);
|
|
};
|
|
|
|
const nonEmptyString = (value: unknown): string | null =>
|
|
typeof value === "string" && value !== "" ? value : null;
|
|
|
|
/** A literal value such as `ipcMain.handle("")` keeps its exact empty text. */
|
|
const exactString = (value: unknown): string | null =>
|
|
typeof value === "string" ? value : null;
|
|
|
|
const stringList = (value: unknown): string[] =>
|
|
Array.isArray(value)
|
|
? value
|
|
.filter((item): item is string => typeof item === "string")
|
|
.sort(compareUnicodeCodePoints)
|
|
: [];
|
|
|
|
const sortedNodes = (nodes: Iterable<ApplicationNode>): ApplicationNode[] =>
|
|
[...nodes].sort((left, right) =>
|
|
compareUnicodeCodePoints(left.node_id, right.node_id),
|
|
);
|