Files
reverse-engineering-anything/src/domain/javascript/javascriptApplicationVersionItems.ts
T
morluto 30db499db3 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.
2026-10-24 06:20:36 +08:00

694 lines
22 KiB
TypeScript

import { absenceClaimable, canonicalJson } from "../comparisonSemantics.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import type { Evidence } from "../evidence.js";
import type {
ApplicationEdge,
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 {
ApplicationVersionMatchingProjection,
ApplicationVersionNodePair,
} from "./javascriptApplicationVersionKeys.js";
/** All classified entities and their complete ambiguity candidate sets. */
export interface ApplicationVersionItemProjection {
readonly items: ApplicationVersionComparisonItem[];
}
interface ItemContext {
readonly leftGraph: JavaScriptApplicationGraph;
readonly rightGraph: JavaScriptApplicationGraph;
readonly leftEvidenceId: string;
readonly rightEvidenceId: string;
readonly leftNativeEvidenceByDigest: ReadonlyMap<string, readonly string[]>;
readonly rightNativeEvidenceByDigest: ReadonlyMap<string, readonly string[]>;
readonly pairByLeft: ReadonlyMap<string, string>;
readonly pairByRight: ReadonlyMap<string, string>;
readonly leftRelationships: RelationshipIndex;
readonly rightRelationships: RelationshipIndex;
readonly leftUnresolvedEndpointKinds: ReadonlySet<string | null>;
readonly rightUnresolvedEndpointKinds: ReadonlySet<string | null>;
}
type ItemContextInput = Omit<
ItemContext,
| "leftNativeEvidenceByDigest"
| "rightNativeEvidenceByDigest"
| "pairByLeft"
| "pairByRight"
| "leftRelationships"
| "rightRelationships"
| "leftUnresolvedEndpointKinds"
| "rightUnresolvedEndpointKinds"
> & {
readonly leftNativeEvidence: readonly Evidence[];
readonly rightNativeEvidence: readonly Evidence[];
};
interface IndexedRelationship {
readonly direction: "in" | "out";
readonly relation: ApplicationEdge["relation"];
readonly neighborId: string;
}
interface RelationshipIndex {
readonly nodeById: ReadonlyMap<string, ApplicationNode>;
readonly adjacency: ReadonlyMap<string, readonly IndexedRelationship[]>;
}
type OneSidedNode =
| {
readonly left: ApplicationNode;
readonly right?: never;
readonly candidateRightNodeIds: readonly string[];
readonly candidateLeftNodeIds?: never;
}
| {
readonly left?: never;
readonly right: ApplicationNode;
readonly candidateRightNodeIds?: never;
readonly candidateLeftNodeIds: readonly string[];
};
type ComparisonItemSemantic<
Item extends ApplicationVersionComparisonItem =
ApplicationVersionComparisonItem,
> = Item extends ApplicationVersionComparisonItem
? Omit<Item, "item_id">
: never;
/** Classify matched, unmatched, and ambiguous nodes without inventing absence. */
export const classifyJavaScriptApplicationVersions = (
matching: ApplicationVersionMatchingProjection,
context: ItemContextInput,
): ApplicationVersionItemProjection => {
const pairByLeft = new Map(
matching.pairs.map(({ left, right }) => [
left.node_id,
`pair:${left.node_id}:${right.node_id}`,
]),
);
const pairByRight = new Map(
matching.pairs.map(({ left, right }) => [
right.node_id,
`pair:${left.node_id}:${right.node_id}`,
]),
);
const fullContext: ItemContext = {
leftGraph: context.leftGraph,
rightGraph: context.rightGraph,
leftEvidenceId: context.leftEvidenceId,
rightEvidenceId: context.rightEvidenceId,
leftNativeEvidenceByDigest: indexNativeEvidence(context.leftNativeEvidence),
rightNativeEvidenceByDigest: indexNativeEvidence(
context.rightNativeEvidence,
),
pairByLeft,
pairByRight,
leftRelationships: indexRelationships(context.leftGraph),
rightRelationships: indexRelationships(context.rightGraph),
leftUnresolvedEndpointKinds: unresolvedEndpointKinds(context.leftGraph),
rightUnresolvedEndpointKinds: unresolvedEndpointKinds(context.rightGraph),
};
const leftItems = matching.unmatchedLeft.map((node) => {
const candidates = matching.candidatesForLeft.get(node.node_id) ?? [];
return unmatchedItem(
{
left: node,
candidateRightNodeIds: candidates,
},
fullContext,
);
});
const rightItems = matching.unmatchedRight.map((node) => {
const candidates = matching.candidatesForRight.get(node.node_id) ?? [];
return unmatchedItem(
{
right: node,
candidateLeftNodeIds: candidates,
},
fullContext,
);
});
return {
items: [
...matching.pairs.map((pair) => matchedItem(pair, fullContext)),
...leftItems,
...rightItems,
].sort((left, right) =>
compareUnicodeCodePoints(left.item_id, right.item_id),
),
};
};
const matchedItem = (
pair: ApplicationVersionNodePair,
context: ItemContext,
): ApplicationVersionComparisonItem => {
const relationships = compareRelationships(pair, context);
const dimensions = changedDimensions(pair, relationships);
const complete = graphsComplete(context);
const ownEvidenceUncertain =
ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right);
const endpointTargetUncertain =
unresolvedEndpointTarget(pair.left) || unresolvedEndpointTarget(pair.right);
const observedChange =
!ownEvidenceUncertain &&
(dimensions.includes("content") || dimensions.includes("properties"));
// Coordinates remain comparison evidence; relocation alone is not a
// content, property, or relationship change.
const completeChange =
!ownEvidenceUncertain &&
complete &&
dimensions.some((dimension) => dimension !== "location");
const status: ApplicationVersionComparisonItem["status"] =
ownEvidenceUncertain
? "unknown"
: observedChange || completeChange
? "changed"
: complete && !relationships.unresolved && !endpointTargetUncertain
? "unchanged"
: "unknown";
const finalDimensions = ownEvidenceUncertain
? []
: status === "unknown" && !complete
? [...dimensions, "coverage" as const]
: dimensions;
const semantic: ComparisonItemSemantic = {
status,
node_kind: pair.left.kind,
left_node_id: pair.left.node_id,
right_node_id: pair.right.node_id,
match: matchedMatch(pair),
dimensions: finalDimensions,
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
limitations: [
...new Set([
...ownComparisonLimitations(pair.left),
...ownComparisonLimitations(pair.right),
]),
...matchLimitations(pair),
...(status === "unknown" && !complete
? [
"Incomplete graph coverage prevents a complete node-level conclusion.",
]
: []),
...(relationships.unresolved
? [
"Relationship target identity remains unresolved; equal node kinds do not establish cross-version alignment.",
]
: []),
],
};
return itemWithId(semantic);
};
const unmatchedItem = (
member: OneSidedNode,
context: ItemContext,
): ApplicationVersionComparisonItem =>
member.left === undefined
? rightOnlyItem(member.right, member.candidateLeftNodeIds, context)
: leftOnlyItem(member.left, member.candidateRightNodeIds, context);
const leftOnlyItem = (
node: ApplicationNode,
candidateNodeIds: readonly string[],
context: ItemContext,
): ApplicationVersionComparisonItem => {
const [firstCandidate, ...remainingCandidates] = candidateNodeIds;
const common = {
node_kind: node.kind,
evidence_links: itemEvidenceLinks(node, undefined, context),
};
if (firstCandidate !== undefined)
return itemWithId({
...common,
status: "unknown",
left_node_id: node.node_id,
right_node_id: null,
match: {
status: "ambiguous",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: [],
candidate_right_node_ids: [firstCandidate, ...remainingCandidates],
},
dimensions: ["availability"],
limitations: [
...ownComparisonLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node) || unresolvedEndpointTarget(node))
return itemWithId({
...common,
status: "unknown",
left_node_id: node.node_id,
right_node_id: null,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownComparisonLimitations(node),
});
const endpointGap = oppositeEndpointUncertain(
node,
context.rightUnresolvedEndpointKinds,
);
const absenceObserved =
!endpointGap && absenceClaimable(context.rightGraph.coverage);
return itemWithId({
...common,
status: absenceObserved ? "removed" : "unknown",
left_node_id: node.node_id,
right_node_id: null,
match: unmatchedMatch(),
dimensions:
absenceObserved || endpointGap
? ["availability"]
: ["availability", "coverage"],
limitations: [
...ownComparisonLimitations(node),
...(endpointGap
? [ENDPOINT_ABSENCE_LIMITATION]
: absenceObserved
? []
: [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
const rightOnlyItem = (
node: ApplicationNode,
candidateNodeIds: readonly string[],
context: ItemContext,
): ApplicationVersionComparisonItem => {
const [firstCandidate, ...remainingCandidates] = candidateNodeIds;
const common = {
node_kind: node.kind,
evidence_links: itemEvidenceLinks(undefined, node, context),
};
if (firstCandidate !== undefined)
return itemWithId({
...common,
status: "unknown",
left_node_id: null,
right_node_id: node.node_id,
match: {
status: "ambiguous",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: [firstCandidate, ...remainingCandidates],
candidate_right_node_ids: [],
},
dimensions: ["availability"],
limitations: [
...ownComparisonLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node) || unresolvedEndpointTarget(node))
return itemWithId({
...common,
status: "unknown",
left_node_id: null,
right_node_id: node.node_id,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownComparisonLimitations(node),
});
const endpointGap = oppositeEndpointUncertain(
node,
context.leftUnresolvedEndpointKinds,
);
const absenceObserved =
!endpointGap && absenceClaimable(context.leftGraph.coverage);
return itemWithId({
...common,
status: absenceObserved ? "added" : "unknown",
left_node_id: null,
right_node_id: node.node_id,
match: unmatchedMatch(),
dimensions:
absenceObserved || endpointGap
? ["availability"]
: ["availability", "coverage"],
limitations: [
...ownComparisonLimitations(node),
...(endpointGap
? [ENDPOINT_ABSENCE_LIMITATION]
: absenceObserved
? []
: [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
const matchedMatch = (
pair: ApplicationVersionNodePair,
): Extract<
ApplicationVersionComparisonItem["match"],
{ readonly status: "matched" }
> => {
const candidates: {
readonly candidate_left_node_ids: [];
readonly candidate_right_node_ids: [];
} = {
candidate_left_node_ids: [],
candidate_right_node_ids: [],
};
switch (pair.basis) {
case "exact-node-identity":
case "exact-content-digest":
case "exact-module-source-digest":
return {
status: "matched",
basis: pair.basis,
confidence: "exact",
...candidates,
};
case "source-map-identity":
return {
status: "matched",
basis: pair.basis,
confidence: "high",
...candidates,
};
case "structural-fingerprint":
case "semantic-key":
return {
status: "matched",
basis: pair.basis,
confidence: "medium",
...candidates,
};
}
};
const unmatchedMatch = (): Extract<
ApplicationVersionComparisonItem["match"],
{ readonly status: "unmatched" }
> => ({
status: "unmatched",
basis: "none",
confidence: "unknown",
candidate_left_node_ids: [],
candidate_right_node_ids: [],
});
const AMBIGUOUS_MATCH_LIMITATION =
"Multiple nodes share the same permitted match key; no cross-version pair was selected.";
const INCOMPLETE_GRAPH_LIMITATION =
"The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed.";
const ENDPOINT_TARGET_LIMITATION =
"Endpoint target identity remains unresolved; any matched source site aligns retained syntax, not an established runtime destination.";
const ENDPOINT_ABSENCE_LIMITATION =
"An unresolved endpoint of the same kind on the opposite side prevents proving this endpoint target absent.";
const unresolvedEndpointTarget = (node: ApplicationNode): boolean =>
node.kind === "endpoint" &&
applicationEndpointCorrespondence(node).targetResolution !== "exact";
const unresolvedEndpointKinds = (
graph: JavaScriptApplicationGraph,
): ReadonlySet<string | null> => {
const kinds = new Set<string | null>();
for (const node of graph.nodes) {
if (node.kind !== "endpoint") continue;
const identity = applicationEndpointCorrespondence(node);
if (identity.targetResolution !== "exact") kinds.add(identity.endpointKind);
}
return kinds;
};
const oppositeEndpointUncertain = (
node: ApplicationNode,
kinds: ReadonlySet<string | null>,
): boolean => {
if (node.kind !== "endpoint" || kinds.size === 0) return false;
const { endpointKind } = applicationEndpointCorrespondence(node);
return endpointKind === null || kinds.has(null) || kinds.has(endpointKind);
};
const ownPresenceUncertain = (node: ApplicationNode): boolean =>
node.observations.every(
({ evidence }) =>
evidence.state === "unknown" || evidence.state === "unavailable",
);
const ownComparisonLimitations = (node: ApplicationNode): string[] => {
const limitations = new Set<string>();
const endpointTargetUncertain = unresolvedEndpointTarget(node);
if (endpointTargetUncertain) limitations.add(ENDPOINT_TARGET_LIMITATION);
for (const { evidence } of node.observations)
if (
endpointTargetUncertain ||
evidence.state === "unknown" ||
evidence.state === "unavailable"
)
for (const limitation of evidence.limitations)
limitations.add(limitation);
return [...limitations].sort(compareUnicodeCodePoints);
};
const changedDimensions = (
pair: ApplicationVersionNodePair,
relationships: RelationshipComparison,
): ApplicationVersionComparisonItem["dimensions"] => {
const dimensions: ApplicationVersionComparisonItem["dimensions"] = [];
const leftContent = contentIdentity(pair.left);
const rightContent = contentIdentity(pair.right);
if (
leftContent !== null &&
rightContent !== null &&
leftContent !== rightContent
)
dimensions.push("content");
if (
canonicalJson(locations(pair.left)) !== canonicalJson(locations(pair.right))
)
dimensions.push("location");
if (
canonicalJson(properties(pair.left)) !==
canonicalJson(properties(pair.right))
)
dimensions.push("properties");
if (relationships.different) dimensions.push("relationships");
return dimensions;
};
const contentIdentity = (node: ApplicationNode): string | null => {
if (node.identity.strategy === "content-digest")
return `content:${node.identity.sha256}`;
if (node.kind === "javascript-module")
return stringProperty(node, "source_sha256");
if (
node.kind === "source-module" &&
node.identity.strategy === "source-map-original"
)
return node.identity.source_sha256;
return null;
};
const locations = (node: ApplicationNode) =>
node.observations
.map(({ evidence }) =>
evidence.location.available ? evidence.location.value : evidence.location,
)
.sort((left, right) =>
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
);
const properties = (node: ApplicationNode) =>
node.observations
.map(({ label, properties: values, source_map_reference }) => ({
label,
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 => {
const adjacency = new Map<string, IndexedRelationship[]>();
const add = (nodeId: string, relationship: IndexedRelationship): void => {
const existing = adjacency.get(nodeId);
if (existing === undefined) adjacency.set(nodeId, [relationship]);
else existing.push(relationship);
};
for (const edge of graph.edges) {
add(edge.source_node_id, {
direction: "out",
relation: edge.relation,
neighborId: edge.target_node_id,
});
if (edge.target_node_id !== edge.source_node_id)
add(edge.target_node_id, {
direction: "in",
relation: edge.relation,
neighborId: edge.source_node_id,
});
}
return {
nodeById: new Map(graph.nodes.map((value) => [value.node_id, value])),
adjacency,
};
};
/** Positive topology differences and unresolved neighbor identity are independent facts. */
interface RelationshipComparison {
readonly different: boolean;
readonly unresolved: boolean;
}
const compareRelationships = (
pair: ApplicationVersionNodePair,
context: ItemContext,
): RelationshipComparison => {
const left = relationshipFacts(pair.left, "left", context);
const right = relationshipFacts(pair.right, "right", context);
return {
different:
left.paired !== right.paired ||
left.unpairedShape !== right.unpairedShape,
unresolved: left.unresolved || right.unresolved,
};
};
const relationshipFacts = (
node: ApplicationNode,
side: "left" | "right",
context: ItemContext,
): {
readonly paired: string;
readonly unpairedShape: string;
readonly unresolved: boolean;
} => {
const { nodeById, adjacency } =
side === "left" ? context.leftRelationships : context.rightRelationships;
const paired = side === "left" ? context.pairByLeft : context.pairByRight;
const matched: string[] = [];
const unpaired: string[] = [];
for (const { direction, relation, neighborId } of adjacency.get(
node.node_id,
) ?? []) {
const mapped = paired.get(neighborId);
if (mapped !== undefined)
matched.push(`${direction}\0${relation}\0${mapped}`);
else
unpaired.push(
`${direction}\0${relation}\0${nodeById.get(neighborId)?.kind ?? "unknown"}`,
);
}
return {
paired: canonicalJson(matched.sort(compareUnicodeCodePoints)),
unpairedShape: canonicalJson(unpaired.sort(compareUnicodeCodePoints)),
unresolved: unpaired.length > 0,
};
};
const itemEvidenceLinks = (
left: ApplicationNode | undefined,
right: ApplicationNode | undefined,
context: ItemContext,
): string[] =>
[
...new Set([
context.leftEvidenceId,
context.rightEvidenceId,
...nativeLinks(left, context.leftNativeEvidenceByDigest),
...nativeLinks(right, context.rightNativeEvidenceByDigest),
]),
].sort(compareUnicodeCodePoints);
const nativeLinks = (
node: ApplicationNode | undefined,
evidenceByDigest: ReadonlyMap<string, readonly string[]>,
): readonly string[] => {
const identity = node === undefined ? null : contentIdentity(node);
if (identity === null) return [];
const digest = identity.replace(/^content:/u, "");
return evidenceByDigest.get(digest) ?? [];
};
const indexNativeEvidence = (
evidence: readonly Evidence[],
): ReadonlyMap<string, readonly string[]> => {
const evidenceByDigest = new Map<string, string[]>();
for (const { evidence_id: id, subject } of evidence) {
const digest = subject?.digest.sha256;
if (digest === undefined) continue;
const ids = evidenceByDigest.get(digest);
if (ids === undefined) evidenceByDigest.set(digest, [id]);
else ids.push(id);
}
return evidenceByDigest;
};
const matchLimitations = (pair: ApplicationVersionNodePair): string[] => [
...(pair.basis === "structural-fingerprint"
? [
"Structural fingerprint equality is rename-resistant inference; it is not byte identity or proof of semantic equivalence.",
]
: []),
...(pair.basis === "source-map-identity"
? [
"Source-map original names identify a candidate source lineage; changed or unavailable source content remains explicit.",
]
: []),
...(pair.basis === "semantic-key"
? [
"Semantic-key pairing is a deterministic inference and is never promoted to exact identity.",
]
: []),
];
const itemWithId = <Semantic extends ComparisonItemSemantic>(
semantic: Semantic,
): Semantic & { readonly item_id: string } => ({
...semantic,
item_id: `javc_item_${digestCanonicalValue(semantic, "Application version item")}`,
});
const stringProperty = (node: ApplicationNode, key: string): string | null => {
const values = [
...new Set(
node.observations
.map(({ properties: values }) => values[key])
.filter((value): value is string => typeof value === "string"),
),
];
return values.length === 1 ? (values[0] ?? null) : null;
};
const graphsComplete = (context: ItemContext): boolean =>
absenceClaimable(context.leftGraph.coverage) &&
absenceClaimable(context.rightGraph.coverage);