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

+5
View File
@@ -76,6 +76,11 @@ identities even when their displayed values match. Partial identities also retai
the source site: matching expression text at different sites does not establish
the same runtime target. This records observed source syntax; it does not
establish that the call ran or contacted that endpoint.
Version comparison can align equal unresolved source sites and syntax across
artifact changes, while target identity remains unknown. Equal unresolved text
at different sites and partial versus exact values cannot establish a pair.
Unresolved targets qualify endpoint absence only within the same endpoint kind;
known metadata differences on an established pair remain visible.
Graph coverage describes the analyzed artifact scope and resource limits, not
runtime execution. A partial target alone does not imply omitted source.
@@ -0,0 +1,217 @@
import { expect, it } from "vitest";
import {
artifactEvidence,
completeCoverage,
fixtureNode,
} from "./javascriptApplicationGraph.fixture.js";
import { createJavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js";
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
const endpoint = (options: {
readonly sha: string;
readonly path?: string;
readonly line?: number;
readonly partial?: boolean;
readonly kind?: string;
readonly method?: string;
readonly candidate?: boolean;
readonly value?: string;
}): ApplicationNode => {
const path = options.path ?? "main.js";
const line = options.line ?? 1;
return fixtureNode(
"endpoint",
{
strategy: "artifact-local-key",
stability: "artifact-version",
artifact_sha256: options.sha,
namespace: options.kind ?? "fetch",
key: JSON.stringify([path, line, options.partial, options.candidate]),
},
"API_BASE",
{
endpoint_kind: options.kind ?? "fetch",
value: options.value ?? "API_BASE",
...(options.partial ? { resolution: "partial" } : {}),
...(options.candidate ? { interpretation: "candidate" } : {}),
...(options.method === undefined ? {} : { method: options.method }),
},
{
...artifactEvidence(options.sha, path, "ast-static-analysis"),
location: {
available: true,
value: {
kind: "source-range",
source: path,
start: { line, column: 0 },
end: { line, column: 15 },
},
},
},
);
};
const compare = (left: ApplicationNode, right: ApplicationNode) => {
const graph = (node: ApplicationNode) =>
createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [node.node_id],
nodes: [node],
edges: [],
coverage: completeCoverage,
limitations: [],
});
return compareJavaScriptApplicationVersions({
left: {
evidenceId: `ev_${"1".repeat(64)}`,
rootArtifactSha256: "a".repeat(64),
graph: graph(left),
},
right: {
evidenceId: `ev_${"2".repeat(64)}`,
rootArtifactSha256: "b".repeat(64),
graph: graph(right),
},
leftNativeEvidence: [],
rightNativeEvidence: [],
});
};
it.each(["moved-partial", "exact", "candidate"] as const)(
"does not select endpoint correspondence from equal display text for %s",
(control) => {
const result = compare(
endpoint({ sha: "a".repeat(64), partial: true }),
endpoint({
sha: "b".repeat(64),
path: control === "moved-partial" ? "other.js" : "main.js",
partial: control === "moved-partial",
candidate: control === "candidate",
}),
);
expect(result.items).toHaveLength(2);
expect(
result.items.every(
({ status, match }) =>
status === "unknown" && match.status === "unmatched",
),
).toBe(true);
},
);
it("aligns equal partial source sites while keeping target identity unknown across binding context changes", () => {
// Different source digests can hide changed bindings even when this call site stays fixed.
const result = compare(
endpoint({ sha: "a".repeat(64), partial: true }),
endpoint({ sha: "b".repeat(64), partial: true }),
);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
status: "unknown",
match: { status: "matched", basis: "semantic-key" },
limitations: expect.arrayContaining([
expect.stringContaining("target identity remains unresolved"),
]),
});
});
it.each([false, true])(
"preserves known metadata changes on an established endpoint pair (partial=%s)",
(partial) => {
const result = compare(
endpoint({ sha: "a".repeat(64), partial, method: "GET" }),
endpoint({ sha: "b".repeat(64), partial, method: "POST" }),
);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
status: "changed",
dimensions: ["properties"],
match: { status: "matched", basis: "semantic-key" },
});
},
);
it("keeps exact endpoint target matching portable across source relocation", () => {
const result = compare(
endpoint({ sha: "a".repeat(64) }),
endpoint({ sha: "b".repeat(64), path: "moved.js", line: 4 }),
);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
status: "unchanged",
dimensions: ["location"],
match: { status: "matched", basis: "semantic-key" },
});
});
it("keeps unrelated unresolved endpoint kinds from hiding exact target absence", () => {
const result = compare(
endpoint({ sha: "a".repeat(64) }),
endpoint({ sha: "b".repeat(64), kind: "route", partial: true }),
);
expect(
result.items.find(({ left_node_id }) => left_node_id !== null),
).toMatchObject({ status: "removed", match: { status: "unmatched" } });
expect(
result.items.find(({ right_node_id }) => right_node_id !== null),
).toMatchObject({ status: "unknown", match: { status: "unmatched" } });
});
it.each([null, "unresolved"] as const)(
"preserves explicit unknown endpoint resolution %s",
(resolution) => {
const right = endpoint({ sha: "b".repeat(64) });
const result = compare(endpoint({ sha: "a".repeat(64) }), {
...right,
observations: right.observations.map((observation) => ({
...observation,
properties: { ...observation.properties, resolution },
})),
});
expect(result.items).toHaveLength(2);
expect(
result.items.every(
({ status, match }) =>
status === "unknown" && match.status === "unmatched",
),
).toBe(true);
},
);
it.each(["unknown", "unavailable"] as const)(
"keeps %s endpoint Evidence from proving another target absent",
(state) => {
const right = endpoint({ sha: "b".repeat(64), value: "unestablished" });
const uncertain: ApplicationNode = {
...right,
observations: right.observations.map((observation) => ({
...observation,
evidence: {
...observation.evidence,
state,
authority: "unknown",
confidence: "unknown",
coverage: {
...observation.evidence.coverage,
status: "unknown",
omitted_count: null,
},
limitations: ["Endpoint target was not established."],
},
})),
};
const result = compare(
endpoint({ sha: "a".repeat(64), value: "known" }),
uncertain,
);
expect(result.items).toHaveLength(2);
expect(
result.items.every(
({ status, match }) =>
status === "unknown" && match.status === "unmatched",
),
).toBe(true);
},
);
@@ -0,0 +1,88 @@
import { compositeKey } from "../unicodeCodePointOrder.js";
import { canonicalJson } from "../comparisonSemantics.js";
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js";
/** Endpoint target identity stays distinct from correspondence of unresolved syntax. */
export interface ApplicationEndpointCorrespondence {
readonly endpointKind: string | null;
readonly targetResolution: "exact" | "unresolved";
readonly semanticKey: string | null;
}
/** Consume retained producer facts without treating expression text as a target. */
export const applicationEndpointCorrespondence = (
node: ApplicationNode,
): ApplicationEndpointCorrespondence => {
const kinds = new Set<string>();
const keys = new Set<string>();
let exact = node.observations.length > 0;
let keyUnavailable = false;
let kindUnavailable = node.observations.length === 0;
for (const { properties, evidence } of node.observations) {
const kind = properties.endpoint_kind;
const value = properties.value;
const interpretation =
properties.interpretation === undefined
? "literal"
: properties.interpretation;
const resolution =
properties.resolution === undefined ? "complete" : properties.resolution;
if (typeof kind !== "string" || kind === "") {
kindUnavailable = true;
keyUnavailable = true;
exact = false;
continue;
}
kinds.add(kind);
if (evidence.state === "unknown" || evidence.state === "unavailable") {
exact = false;
keyUnavailable = true;
continue;
}
if (
typeof value !== "string" ||
(interpretation !== "literal" && interpretation !== "candidate") ||
(resolution !== "complete" && resolution !== "partial")
) {
exact = false;
keyUnavailable = true;
continue;
}
if (resolution === "complete" && interpretation === "literal") {
keys.add(compositeKey([kind, "exact", value]));
continue;
}
exact = false;
const sourceSite = sourceSiteKey(evidence.location);
if (sourceSite === null) {
keyUnavailable = true;
continue;
}
keys.add(
compositeKey([
kind,
"source-site",
interpretation,
resolution,
value,
sourceSite,
]),
);
}
return {
endpointKind: kindUnavailable ? null : uniqueIdentity(kinds),
targetResolution: exact && keys.size === 1 ? "exact" : "unresolved",
semanticKey: keyUnavailable ? null : uniqueIdentity(keys),
};
};
const uniqueIdentity = (values: ReadonlySet<string>): string | null =>
values.size === 1 ? (values.values().next().value ?? null) : null;
const sourceSiteKey = (
location: ApplicationGraphEvidence["location"],
): string | null =>
location.available && location.value.kind === "source-range"
? canonicalJson(location.value)
: null;
@@ -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);
@@ -5,6 +5,7 @@ import {
} from "../unicodeCodePointOrder.js";
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
import type { JsonValue } from "../jsonValue.js";
import { applicationEndpointCorrespondence } from "./javascriptApplicationEndpointCorrespondence.js";
/** One deterministic one-to-one match between two graph versions. */
type ApplicationVersionPairMatch =
@@ -253,11 +254,8 @@ 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 === "endpoint")
return applicationEndpointCorrespondence(node).semanticKey;
if (node.kind === "storage")
return joined(
properties.storage_kind,
@@ -0,0 +1,51 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"keeps unresolved endpoint text separate from an equal exact literal through version comparison",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-endpoint-correspondence-");
const applications = [];
for (const [name, source] of [
["before", "fetch(API_BASE);"],
["after", 'fetch("API_BASE");'],
] as const) {
const path = join(root, name);
await mkdir(path);
await writeFile(join(path, "main.js"), source);
const analyzed = await cli.run({
arguments: ["analyze-javascript-application", path, "--format", "json"],
});
expect(analyzed.exitCode, analyzed.stderr).toBe(0);
applications.push(parseEvidence(analyzed.json));
}
const input = join(root, "comparison.json");
await writeFile(
input,
JSON.stringify({ left: applications[0], right: applications[1] }),
);
const response = await cli.run({
arguments: ["compare-application-versions", input, "--format", "json"],
});
expect(response.exitCode, response.stderr).toBe(0);
const result = applicationVersionComparisonResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
const endpoints = result.items.filter(
({ node_kind }) => node_kind === "endpoint",
);
expect(endpoints).toHaveLength(2);
expect(
endpoints.every(
({ status, match }) =>
status === "unknown" && match.status === "unmatched",
),
).toBe(true);
},
);