diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index e3c8dacff8..b232d86b86 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -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. diff --git a/src/domain/javascript/javascriptApplicationEndpointCorrespondence.test.ts b/src/domain/javascript/javascriptApplicationEndpointCorrespondence.test.ts new file mode 100644 index 0000000000..feae11c054 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationEndpointCorrespondence.test.ts @@ -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); + }, +); diff --git a/src/domain/javascript/javascriptApplicationEndpointCorrespondence.ts b/src/domain/javascript/javascriptApplicationEndpointCorrespondence.ts new file mode 100644 index 0000000000..c4b5427781 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationEndpointCorrespondence.ts @@ -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(); + const keys = new Set(); + 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 | 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; diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 9e07565f1e..57f000a8e4 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -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; readonly leftRelationships: RelationshipIndex; readonly rightRelationships: RelationshipIndex; + readonly leftUnresolvedEndpointKinds: ReadonlySet; + readonly rightUnresolvedEndpointKinds: ReadonlySet; } 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 => { + const kinds = new Set(); + 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, +): 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(); + 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); diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index dec1a9d2ee..5d8cd379f7 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -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>, ): 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, diff --git a/tests/acceptance/applications/javascriptEndpointCorrespondenceCli.test.ts b/tests/acceptance/applications/javascriptEndpointCorrespondenceCli.test.ts new file mode 100644 index 0000000000..415bca2ea4 --- /dev/null +++ b/tests/acceptance/applications/javascriptEndpointCorrespondenceCli.test.ts @@ -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); + }, +);