mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): separate endpoint syntax correspondence from target identity
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user