fix(javascript): preserve unresolved version relationships
Keep positive topology differences separate from unpaired neighbor identity. Preserve exact content pairing and source observations, report unresolved relationships without inventing incomplete inventory, and omit comparison edges when no substantive difference was observed. Validated with 45 focused domain/CLI/MCP tests, nine expanded domain controls, check:fast, and docs:check. Clean baseline fails seven controls and passes two positive controls. Controlled static fixtures only; no target execution or large-bundle profiling. Refs #4681
This commit is contained in:
1 parent
556504edcb
commit
4d980d0176
6 files changed
+425
-53
No files matched your search
@@ -142,7 +142,8 @@ const matchedItem = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
context: ItemContext,
|
||||
): ApplicationVersionComparisonItem => {
|
||||
const dimensions = changedDimensions(pair, context);
|
||||
const relationships = compareRelationships(pair, context);
|
||||
const dimensions = changedDimensions(pair, relationships);
|
||||
const complete = graphsComplete(context);
|
||||
const observedChange =
|
||||
dimensions.includes("content") || dimensions.includes("properties");
|
||||
@@ -153,11 +154,11 @@ const matchedItem = (
|
||||
const status: ApplicationVersionComparisonItem["status"] =
|
||||
observedChange || completeChange
|
||||
? "changed"
|
||||
: complete
|
||||
: complete && !relationships.unresolved
|
||||
? "unchanged"
|
||||
: "unknown";
|
||||
const finalDimensions =
|
||||
status === "unknown" && !dimensions.includes("coverage")
|
||||
status === "unknown" && !complete
|
||||
? [...dimensions, "coverage" as const]
|
||||
: dimensions;
|
||||
const semantic: ComparisonItemSemantic = {
|
||||
@@ -168,7 +169,19 @@ const matchedItem = (
|
||||
match: matchedMatch(pair),
|
||||
dimensions: finalDimensions,
|
||||
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
|
||||
limitations: pairLimitations(pair, status),
|
||||
limitations: [
|
||||
...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);
|
||||
};
|
||||
@@ -320,7 +333,7 @@ const INCOMPLETE_GRAPH_LIMITATION =
|
||||
|
||||
const changedDimensions = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
context: ItemContext,
|
||||
relationships: RelationshipComparison,
|
||||
): ApplicationVersionComparisonItem["dimensions"] => {
|
||||
const dimensions: ApplicationVersionComparisonItem["dimensions"] = [];
|
||||
const leftContent = contentIdentity(pair.left);
|
||||
@@ -340,11 +353,7 @@ const changedDimensions = (
|
||||
canonicalJson(properties(pair.right))
|
||||
)
|
||||
dimensions.push("properties");
|
||||
if (
|
||||
relationshipSignature(pair.left, "left", context) !==
|
||||
relationshipSignature(pair.right, "right", context)
|
||||
)
|
||||
dimensions.push("relationships");
|
||||
if (relationships.different) dimensions.push("relationships");
|
||||
return dimensions;
|
||||
};
|
||||
|
||||
@@ -409,32 +418,56 @@ const indexRelationships = (
|
||||
};
|
||||
};
|
||||
|
||||
const relationshipSignature = (
|
||||
/** 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,
|
||||
): string => {
|
||||
): {
|
||||
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 signatures = (adjacency.get(node.node_id) ?? []).map((relationship) =>
|
||||
edgeSignature({ ...relationship, paired, nodeById }),
|
||||
);
|
||||
return canonicalJson(signatures.sort(compareUnicodeCodePoints));
|
||||
};
|
||||
|
||||
interface EdgeSignatureContext {
|
||||
readonly direction: "in" | "out";
|
||||
readonly relation: ApplicationEdge["relation"];
|
||||
readonly neighborId: string;
|
||||
readonly paired: ReadonlyMap<string, string>;
|
||||
readonly nodeById: ReadonlyMap<string, ApplicationNode>;
|
||||
}
|
||||
|
||||
const edgeSignature = (context: EdgeSignatureContext): string => {
|
||||
const mapped = context.paired.get(context.neighborId);
|
||||
const neighbor = context.nodeById.get(context.neighborId);
|
||||
return `${context.direction}\0${context.relation}\0${mapped ?? `unmatched:${neighbor?.kind ?? "unknown"}`}`;
|
||||
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 = (
|
||||
@@ -475,10 +508,7 @@ const indexNativeEvidence = (
|
||||
return evidenceByDigest;
|
||||
};
|
||||
|
||||
const pairLimitations = (
|
||||
pair: ApplicationVersionNodePair,
|
||||
status: ApplicationVersionComparisonItem["status"],
|
||||
): string[] => [
|
||||
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.",
|
||||
@@ -494,9 +524,6 @@ const pairLimitations = (
|
||||
"Semantic-key pairing is a deterministic inference and is never promoted to exact identity.",
|
||||
]
|
||||
: []),
|
||||
...(status === "unknown"
|
||||
? ["Incomplete graph coverage prevents a complete node-level conclusion."]
|
||||
: []),
|
||||
];
|
||||
|
||||
const itemWithId = <Semantic extends ComparisonItemSemantic>(
|
||||
|
||||
Reference in new issue
Block a user