fix(javascript): separate endpoint syntax correspondence from target identity

This commit is contained in:
morluto committed 2026-10-24 03:59:44 +08:00
1 parent 6c307e9c86
commit f6b525d3af
6 files changed
+451 -28

No files matched your search

@@ -7,6 +7,7 @@ import type {
ApplicationNode,
} from "./javascriptApplicationGraphSchemas.js";
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
import { applicationEndpointCorrespondence } from "./javascriptApplicationEndpointCorrespondence.js";
import type { ApplicationVersionComparisonItem } from "./javascriptApplicationVersionComparisonSchemas.js";
import type {
ApplicationVersionMatchingProjection,
@@ -29,6 +30,8 @@ interface ItemContext {
readonly pairByRight: ReadonlyMap<string, string>;
readonly leftRelationships: RelationshipIndex;
readonly rightRelationships: RelationshipIndex;
readonly leftUnresolvedEndpointKinds: ReadonlySet<string | null>;
readonly rightUnresolvedEndpointKinds: ReadonlySet<string | null>;
}
type ItemContextInput = Omit<
@@ -39,6 +42,8 @@ type ItemContextInput = Omit<
| "pairByRight"
| "leftRelationships"
| "rightRelationships"
| "leftUnresolvedEndpointKinds"
| "rightUnresolvedEndpointKinds"
> & {
readonly leftNativeEvidence: readonly Evidence[];
readonly rightNativeEvidence: readonly Evidence[];
@@ -106,6 +111,8 @@ export const classifyJavaScriptApplicationVersions = (
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) ?? [];
@@ -147,6 +154,8 @@ const matchedItem = (
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"));
@@ -161,7 +170,7 @@ const matchedItem = (
? "unknown"
: observedChange || completeChange
? "changed"
: complete && !relationships.unresolved
: complete && !relationships.unresolved && !endpointTargetUncertain
? "unchanged"
: "unknown";
const finalDimensions = ownEvidenceUncertain
@@ -179,8 +188,8 @@ const matchedItem = (
evidence_links: itemEvidenceLinks(pair.left, pair.right, context),
limitations: [
...new Set([
...ownAvailabilityLimitations(pair.left),
...ownAvailabilityLimitations(pair.right),
...ownComparisonLimitations(pair.left),
...ownComparisonLimitations(pair.right),
]),
...matchLimitations(pair),
...(status === "unknown" && !complete
@@ -231,11 +240,11 @@ const leftOnlyItem = (
},
dimensions: ["availability"],
limitations: [
...ownAvailabilityLimitations(node),
...ownComparisonLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node))
if (ownPresenceUncertain(node) || unresolvedEndpointTarget(node))
return itemWithId({
...common,
status: "unknown",
@@ -243,21 +252,31 @@ const leftOnlyItem = (
right_node_id: null,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownAvailabilityLimitations(node),
limitations: ownComparisonLimitations(node),
});
const absenceObserved = absenceClaimable(context.rightGraph.coverage);
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
? ["availability"]
: ["availability", "coverage"],
dimensions:
absenceObserved || endpointGap
? ["availability"]
: ["availability", "coverage"],
limitations: [
...ownAvailabilityLimitations(node),
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
...ownComparisonLimitations(node),
...(endpointGap
? [ENDPOINT_ABSENCE_LIMITATION]
: absenceObserved
? []
: [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
@@ -287,11 +306,11 @@ const rightOnlyItem = (
},
dimensions: ["availability"],
limitations: [
...ownAvailabilityLimitations(node),
...ownComparisonLimitations(node),
AMBIGUOUS_MATCH_LIMITATION,
],
});
if (ownPresenceUncertain(node))
if (ownPresenceUncertain(node) || unresolvedEndpointTarget(node))
return itemWithId({
...common,
status: "unknown",
@@ -299,21 +318,31 @@ const rightOnlyItem = (
right_node_id: node.node_id,
match: unmatchedMatch(),
dimensions: ["availability"],
limitations: ownAvailabilityLimitations(node),
limitations: ownComparisonLimitations(node),
});
const absenceObserved = absenceClaimable(context.leftGraph.coverage);
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
? ["availability"]
: ["availability", "coverage"],
dimensions:
absenceObserved || endpointGap
? ["availability"]
: ["availability", "coverage"],
limitations: [
...ownAvailabilityLimitations(node),
...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]),
...ownComparisonLimitations(node),
...(endpointGap
? [ENDPOINT_ABSENCE_LIMITATION]
: absenceObserved
? []
: [INCOMPLETE_GRAPH_LIMITATION]),
],
});
};
@@ -374,6 +403,35 @@ 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(
@@ -381,10 +439,16 @@ const ownPresenceUncertain = (node: ApplicationNode): boolean =>
evidence.state === "unknown" || evidence.state === "unavailable",
);
const ownAvailabilityLimitations = (node: ApplicationNode): string[] => {
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 (evidence.state === "unknown" || evidence.state === "unavailable")
if (
endpointTargetUncertain ||
evidence.state === "unknown" ||
evidence.state === "unavailable"
)
for (const limitation of evidence.limitations)
limitations.add(limitation);
return [...limitations].sort(compareUnicodeCodePoints);