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:
morluto
2026-10-17 12:51:04 +08:00
parent 556504edcb
commit 4d980d0176
6 changed files with 425 additions and 53 deletions
+12 -1
View File
@@ -289,7 +289,8 @@ links, limitations, and a `changed_from` graph containing all compared nodes,
their observations, and relationships.
A matched pair whose only differing dimension is `location` counts as
`unchanged` when both input graphs have complete coverage. This includes an
`unchanged` when both input graphs have complete coverage and its neighboring
relationship identities are resolved. This includes an
identical module shifted by an earlier bundle edit and a named IPC channel
observed at a new offset. The item still reports `dimensions: ["location"]`
and both node IDs; the graph retains both original observations and coordinates.
@@ -298,6 +299,16 @@ Content, property, and complete relationship differences still count as
retains the location and coverage dimensions. Matching confidence is unchanged:
an inferred pair is not promoted to exact identity by this classification.
Complete inventory does not resolve ambiguous or unmatched relationship targets.
A matched module with identical bytes can therefore remain `unknown` when a
neighbor, including its containing chunk, has no cross-version match. The item
retains its exact content match, coordinates, candidate inventory, and an explicit
relationship-identity limitation. `coverage` is added to its dimensions only for
incomplete inventory. Definite content, property, or complete topology differences
remain `changed` while retaining any separate relationship uncertainty.
An unknown item with no observed content, property, or relationship difference
does not emit a `changed_from` edge; its pairing and uncertainty remain in the item.
One-sided absence is `added` or `removed` only when the opposite input graph has
complete coverage. With partial, unavailable, or truncated input, the same
condition is `unknown`. The comparison retains every classified item and
@@ -118,6 +118,11 @@ const changedFromEdge = (
if (
item.match.status !== "matched" ||
item.status === "unchanged" ||
// Pairing and uncertainty remain in the item; neither establishes a change.
(item.status === "unknown" &&
!item.dimensions.some(
(dimension) => dimension !== "location" && dimension !== "coverage",
)) ||
left === null ||
right === null ||
left === right ||
@@ -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>(
@@ -0,0 +1,229 @@
import { expect, it } from "vitest";
import {
artifactEvidence,
completeCoverage,
fixtureNode,
} from "./javascriptApplicationGraph.fixture.js";
import {
createJavaScriptApplicationGraph,
createJavaScriptApplicationEdge,
type JavaScriptApplicationGraph,
} from "./javascriptApplicationGraph.js";
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
const graph = (
sha: string,
peers: "ambiguous" | "unique" | "unmatched",
options: {
relation?: "imports" | "invokes";
target?: "peer-one" | "peer-two";
direction?: "in" | "out";
content?: string;
partial?: true;
} = {},
) => {
const evidence = artifactEvidence(sha, "main.js");
const nodes = ["main", "peer-one", "peer-two"].map((key) =>
fixtureNode(
"javascript-module",
{
strategy: "artifact-local-key",
stability: "artifact-version",
artifact_sha256: sha,
namespace: "module",
key,
},
key === "main" ? "main" : "peer",
{
source_sha256:
key === "main"
? (options.content ?? "d".repeat(64))
: peers === "ambiguous"
? "c".repeat(64)
: peers === "unique"
? (key === "peer-one" ? "e" : "f").repeat(64)
: (key === "peer-one" ? "1" : "2").repeat(64),
...(key === "main"
? {
structural_fingerprint_sha256: "9".repeat(64),
structural_fingerprint_algorithm: "fixture",
}
: {}),
},
evidence,
),
);
const main = nodes[0],
target = nodes[options.target === "peer-two" ? 2 : 1];
if (main === undefined || target === undefined)
throw new Error("Missing fixture nodes");
return {
main,
graph: createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [main.node_id],
nodes,
edges: [
createJavaScriptApplicationEdge({
source_node_id:
options.direction === "in" ? target.node_id : main.node_id,
target_node_id:
options.direction === "in" ? main.node_id : target.node_id,
relation: options.relation ?? "imports",
properties: {},
evidence,
}),
],
coverage: options.partial
? { ...completeCoverage, status: "partial", omitted_count: null }
: completeCoverage,
limitations: options.partial ? ["Controlled incomplete inventory."] : [],
}),
};
};
const compare = (
left: ReturnType<typeof graph>,
right: ReturnType<typeof graph>,
) => {
const result = compareJavaScriptApplicationVersions({
left: {
evidenceId: `ev_${"1".repeat(64)}`,
rootArtifactSha256: "a".repeat(64),
graph: left.graph,
},
right: {
evidenceId: `ev_${"2".repeat(64)}`,
rootArtifactSha256: "b".repeat(64),
graph: right.graph,
},
leftNativeEvidence: [],
rightNativeEvidence: [],
});
const item = result.items.find(
(item) => item.left_node_id === left.main.node_id,
);
if (item === undefined) throw new Error("Missing main comparison item");
return { result, item };
};
it.each(["ambiguous", "unmatched"] as const)(
"keeps %s neighbor alignment unknown despite complete inventories",
(peers) => {
const { result, item } = compare(
graph("a".repeat(64), "ambiguous"),
graph("b".repeat(64), peers),
);
expect(item).toMatchObject({
status: "unknown",
dimensions: [],
match: {
status: "matched",
basis: "exact-module-source-digest",
confidence: "exact",
},
});
expect(item.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
expect(item.limitations.join(" ")).not.toContain(
"Incomplete graph coverage",
);
expect(result.coverage.status).toBe("complete-within-inputs");
expect(
result.graph.edges.filter((edge) => edge.relation === "changed_from"),
).toEqual([]);
expect(result.graph.nodes).toHaveLength(6);
if (peers === "ambiguous") {
expect(result.summary).toEqual({
unchanged: 0,
added: 0,
removed: 0,
changed: 0,
unknown: 5,
});
expect(
result.items.filter((i) => i.match.status === "ambiguous"),
).toHaveLength(4);
}
},
);
it("keeps equal relationships to uniquely paired peers unchanged", () => {
const { item } = compare(
graph("a".repeat(64), "unique"),
graph("b".repeat(64), "unique"),
);
expect(item).toMatchObject({ status: "unchanged", dimensions: [] });
expect(item.limitations).toEqual([]);
});
it.each([{ relation: "invokes" as const }, { direction: "in" as const }])(
"retains positive relation/direction differences beside unknown peer identity %j",
(options) => {
const { item } = compare(
graph("a".repeat(64), "ambiguous"),
graph("b".repeat(64), "ambiguous", options),
);
expect(item).toMatchObject({
status: "changed",
dimensions: ["relationships"],
});
expect(item.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
},
);
it("reports a resolved target change", () => {
const { item } = compare(
graph("a".repeat(64), "unique"),
graph("b".repeat(64), "unique", { target: "peer-two" }),
);
expect(item).toMatchObject({
status: "changed",
dimensions: ["relationships"],
});
});
it("preserves definite content change alongside unresolved relationships", () => {
const { item } = compare(
graph("a".repeat(64), "ambiguous"),
graph("b".repeat(64), "ambiguous", { content: "8".repeat(64) }),
);
expect(item).toMatchObject({
status: "changed",
dimensions: ["content", "properties"],
match: { basis: "structural-fingerprint" },
});
expect(item.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
});
it.each(["imports", "invokes"] as const)(
"keeps partial inventory and neighbor identity uncertainty distinct (%s)",
(relation) => {
const { result, item } = compare(
graph("a".repeat(64), "ambiguous"),
graph("b".repeat(64), "ambiguous", { partial: true, relation }),
);
expect(item).toMatchObject({
status: "unknown",
dimensions:
relation === "imports" ? ["coverage"] : ["relationships", "coverage"],
});
const changes = result.graph.edges.filter(
(edge) => edge.relation === "changed_from",
);
expect(changes).toHaveLength(relation === "imports" ? 0 : 1);
if (relation === "invokes")
expect(changes[0]).toMatchObject({
properties: { status: "unknown" },
evidence: { coverage: { status: "partial" } },
});
expect(item.limitations.join(" ")).toContain("Incomplete graph coverage");
expect(item.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
},
);
@@ -243,7 +243,7 @@ cliTest(
);
cliTest(
"matches an unchanged source module after a sibling file changes",
"retains exact source-module pairing while a changed sibling's identity is unresolved",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-source-module-version-");
const versions = await Promise.all(
@@ -309,17 +309,41 @@ cliTest(
parseEvidence(response.json).normalized_result,
);
expect(result.items).toContainEqual(
expect.objectContaining({
left_node_id: sourceModules[0]?.node_id,
right_node_id: sourceModules[1]?.node_id,
status: "unchanged",
match: expect.objectContaining({
status: "matched",
basis: "exact-module-source-digest",
confidence: "exact",
}),
}),
const item = result.items.find(
({ left_node_id }) => left_node_id === sourceModules[0]?.node_id,
);
expect(item).toMatchObject({
left_node_id: sourceModules[0]?.node_id,
right_node_id: sourceModules[1]?.node_id,
status: "unknown",
dimensions: [],
match: {
status: "matched",
basis: "exact-module-source-digest",
confidence: "exact",
},
});
expect(item?.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
expect(result.coverage.status).toBe("complete-within-inputs");
const mainId = sourceModules[0]?.node_id;
const neighbors = new Set(
analyses[0]?.graph.edges.flatMap((edge) =>
edge.source_node_id === mainId
? [edge.target_node_id]
: edge.target_node_id === mainId
? [edge.source_node_id]
: [],
),
);
expect(
result.items.some(
(other) =>
other.left_node_id !== null &&
neighbors.has(other.left_node_id) &&
other.match.status !== "matched",
),
).toBe(true);
},
);
@@ -8,7 +8,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"retains unchanged bundled modules after an earlier edit shifts their source offsets",
"retains exact factory bytes and relocation while exposing unresolved chunk alignment",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-relocation-");
const bundle =
@@ -62,10 +62,13 @@ cliTest(
expect(moduleItems).toHaveLength(2);
for (const item of moduleItems) {
expect(item).toMatchObject({
status: "unchanged",
status: "unknown",
dimensions: ["location"],
match: { basis: "exact-module-source-digest" },
});
expect(item.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
const a = modules[0]?.find((node) => node.node_id === item.left_node_id),
b = modules[1]?.find((node) => node.node_id === item.right_node_id);
expect(typeof a?.observations[0]?.properties.source_sha256).toBe(
@@ -84,7 +87,7 @@ cliTest(
result.graph.nodes.find((node) => node.node_id === item.right_node_id),
).toEqual(b);
}
expect(result.summary.unchanged).toBeGreaterThanOrEqual(2);
expect(result.summary.unknown).toBeGreaterThanOrEqual(2);
expect(
result.items.find((item) => item.node_kind === "javascript-asset"),
).toMatchObject({
@@ -93,3 +96,76 @@ cliTest(
});
},
);
cliTest(
"preserves ambiguous imported factory identity through public version comparison",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-version-ambiguous-import-");
const bundle =
"globalThis.webpackChunkApp.push([[1], {11:function(module,exports,__webpack_require__){exports.value=__webpack_require__(22).value;},22:function(module,exports){exports.value=1;},33:function(module,exports){exports.value=1;}}]);";
const versions = [];
for (const side of [1, 2]) {
const application = join(root, String(side));
await mkdir(application);
await writeFile(
join(application, "main.js"),
`${side === 1 ? "" : "/* shifted */\n"}${bundle}`,
);
const run = await cli.run({
arguments: ["analyze-javascript-application", application, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(run.exitCode, run.stderr).toBe(0);
versions.push(parseEvidence(run.json));
}
const [left, right] = versions;
if (left === undefined || right === undefined)
throw new Error("Missing versions");
const analyses = versions.map((e) =>
javascriptApplicationAnalysisResultSchema.parse(e.normalized_result),
);
const main = analyses[0]?.graph.nodes.find(
(n) =>
n.kind === "javascript-module" &&
n.observations.some((o) => o.properties.module_key === "11"),
);
const peer = analyses[0]?.graph.nodes.find(
(n) =>
n.kind === "javascript-module" &&
n.observations.some((o) => o.properties.module_key === "22"),
);
if (main === undefined || peer === undefined)
throw new Error("Missing fixture factories");
expect(analyses[0]?.graph.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
source_node_id: main.node_id,
target_node_id: peer.node_id,
relation: "imports",
}),
]),
);
const input = join(root, "compare.json");
await writeFile(input, JSON.stringify({ left, right }));
const run = await cli.run({
arguments: ["compare-application-versions", input, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(run.exitCode, run.stderr).toBe(0);
const result = applicationVersionComparisonResultSchema.parse(
parseEvidence(run.json).normalized_result,
);
const item = result.items.find((i) => i.left_node_id === main.node_id),
target = result.items.find((i) => i.left_node_id === peer.node_id);
expect(result.coverage.status).toBe("complete-within-inputs");
expect(item).toMatchObject({
status: "unknown",
match: { basis: "exact-module-source-digest", confidence: "exact" },
});
expect(item?.limitations.join(" ")).toMatch(
/relationship.*identity.*unresolved/iu,
);
expect(target?.match).toMatchObject({ status: "ambiguous" });
expect(target?.match.candidate_right_node_ids).toHaveLength(2);
},
);