diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 6782c77efb..9e07565f1e 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -145,20 +145,28 @@ const matchedItem = ( const relationships = compareRelationships(pair, context); const dimensions = changedDimensions(pair, relationships); const complete = graphsComplete(context); + const ownEvidenceUncertain = + ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right); const observedChange = - dimensions.includes("content") || dimensions.includes("properties"); + !ownEvidenceUncertain && + (dimensions.includes("content") || dimensions.includes("properties")); // Coordinates remain comparison evidence; relocation alone is not a // content, property, or relationship change. const completeChange = - complete && dimensions.some((dimension) => dimension !== "location"); + !ownEvidenceUncertain && + complete && + dimensions.some((dimension) => dimension !== "location"); const status: ApplicationVersionComparisonItem["status"] = - observedChange || completeChange - ? "changed" - : complete && !relationships.unresolved - ? "unchanged" - : "unknown"; - const finalDimensions = - status === "unknown" && !complete + ownEvidenceUncertain + ? "unknown" + : observedChange || completeChange + ? "changed" + : complete && !relationships.unresolved + ? "unchanged" + : "unknown"; + const finalDimensions = ownEvidenceUncertain + ? [] + : status === "unknown" && !complete ? [...dimensions, "coverage" as const] : dimensions; const semantic: ComparisonItemSemantic = { @@ -170,6 +178,10 @@ const matchedItem = ( dimensions: finalDimensions, evidence_links: itemEvidenceLinks(pair.left, pair.right, context), limitations: [ + ...new Set([ + ...ownAvailabilityLimitations(pair.left), + ...ownAvailabilityLimitations(pair.right), + ]), ...matchLimitations(pair), ...(status === "unknown" && !complete ? [ @@ -218,7 +230,20 @@ const leftOnlyItem = ( candidate_right_node_ids: [firstCandidate, ...remainingCandidates], }, dimensions: ["availability"], - limitations: [AMBIGUOUS_MATCH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + AMBIGUOUS_MATCH_LIMITATION, + ], + }); + if (ownPresenceUncertain(node)) + return itemWithId({ + ...common, + status: "unknown", + left_node_id: node.node_id, + right_node_id: null, + match: unmatchedMatch(), + dimensions: ["availability"], + limitations: ownAvailabilityLimitations(node), }); const absenceObserved = absenceClaimable(context.rightGraph.coverage); return itemWithId({ @@ -230,7 +255,10 @@ const leftOnlyItem = ( dimensions: absenceObserved ? ["availability"] : ["availability", "coverage"], - limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + ...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]), + ], }); }; @@ -258,7 +286,20 @@ const rightOnlyItem = ( candidate_right_node_ids: [], }, dimensions: ["availability"], - limitations: [AMBIGUOUS_MATCH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + AMBIGUOUS_MATCH_LIMITATION, + ], + }); + if (ownPresenceUncertain(node)) + return itemWithId({ + ...common, + status: "unknown", + left_node_id: null, + right_node_id: node.node_id, + match: unmatchedMatch(), + dimensions: ["availability"], + limitations: ownAvailabilityLimitations(node), }); const absenceObserved = absenceClaimable(context.leftGraph.coverage); return itemWithId({ @@ -270,7 +311,10 @@ const rightOnlyItem = ( dimensions: absenceObserved ? ["availability"] : ["availability", "coverage"], - limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + ...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]), + ], }); }; @@ -331,6 +375,21 @@ const AMBIGUOUS_MATCH_LIMITATION = const INCOMPLETE_GRAPH_LIMITATION = "The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed."; +const ownPresenceUncertain = (node: ApplicationNode): boolean => + node.observations.every( + ({ evidence }) => + evidence.state === "unknown" || evidence.state === "unavailable", + ); + +const ownAvailabilityLimitations = (node: ApplicationNode): string[] => { + const limitations = new Set(); + for (const { evidence } of node.observations) + if (evidence.state === "unknown" || evidence.state === "unavailable") + for (const limitation of evidence.limitations) + limitations.add(limitation); + return [...limitations].sort(compareUnicodeCodePoints); +}; + const changedDimensions = ( pair: ApplicationVersionNodePair, relationships: RelationshipComparison, diff --git a/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts new file mode 100644 index 0000000000..ff7e3260b7 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts @@ -0,0 +1,207 @@ +import { expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "./javascriptApplicationGraph.js"; +import { + artifactEvidence, + completeCoverage, +} from "./javascriptApplicationGraph.fixture.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const LEFT_SHA = "a".repeat(64); +const RIGHT_SHA = "b".repeat(64); +const LIMITATION = + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed."; +const evidenceId = (character: string) => `ev_${character.repeat(64)}`; +const node = (sha256: string, path: string) => + createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { strategy: "content-digest", stability: "global-exact", sha256 }, + observations: [ + { + label: path, + properties: { path }, + evidence: artifactEvidence(sha256, path), + }, + ], + }); +const unavailableScope = ( + artifactSha: string, + state: "unknown" | "unavailable" = "unavailable", +) => + createJavaScriptApplicationNode({ + kind: "unknown", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: artifactSha, + namespace: "static-parse-scope", + key: "select-javascript-sources:lib.js", + }, + observations: [ + { + label: "lib.js parse scope", + properties: { path: "lib.js", operation: "select-javascript-sources" }, + evidence: { + ...artifactEvidence(artifactSha, "lib.js"), + state, + confidence: "unknown", + limitations: [LIMITATION], + }, + }, + ], + }); +const observedAssetWithUnavailableFact = () => { + const digest = "e".repeat(64); + return createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: digest, + }, + observations: [ + { + label: "new.js", + properties: { path: "new.js" }, + evidence: artifactEvidence(digest, "new.js"), + }, + { + label: "new.js parse scope", + properties: { operation: "parse-javascript" }, + evidence: { + ...artifactEvidence(digest, "new.js"), + state: "unavailable", + confidence: "unknown", + limitations: [LIMITATION], + }, + }, + ], + }); +}; +const graph = (nodes: ReturnType[]) => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [nodes[0]!.node_id], + nodes, + edges: [], + coverage: completeCoverage, + limitations: [], + }); +const compare = ( + left: ReturnType[], + right: ReturnType[], +) => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: evidenceId("1"), + rootArtifactSha256: LEFT_SHA, + graph: graph(left), + }, + right: { + evidenceId: evidenceId("2"), + rootArtifactSha256: RIGHT_SHA, + graph: graph(right), + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + +it("keeps unmatched unavailable scopes unknown in either direction", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const leftScope = unavailableScope(LEFT_SHA); + const rightScope = unavailableScope(RIGHT_SHA); + const forward = compare([leftRoot], [rightRoot, rightScope]); + const reverse = compare([leftRoot, leftScope], [rightRoot]); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === rightScope.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [LIMITATION], + evidence_links: [evidenceId("1"), evidenceId("2")], + }); + expect( + reverse.items.find( + ({ left_node_id }) => left_node_id === leftScope.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [LIMITATION], + }); + expect(forward.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 }); + expect(reverse.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 }); +}); + +it("keeps matched unavailable scopes unknown with their source limitation", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const leftScope = unavailableScope(LEFT_SHA); + const rightScope = unavailableScope(LEFT_SHA); + const result = compare([leftRoot, leftScope], [rightRoot, rightScope]); + + expect( + result.items.find(({ left_node_id }) => left_node_id === leftScope.node_id), + ).toMatchObject({ + right_node_id: rightScope.node_id, + status: "unknown", + dimensions: [], + limitations: [LIMITATION], + }); + expect(result.summary).toMatchObject({ unchanged: 1, unknown: 1 }); +}); + +it("retains unknown scope facts and reports genuine entity changes", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const unknown = unavailableScope(RIGHT_SHA, "unknown"); + const added = node("e".repeat(64), "new.js"); + const removed = node("f".repeat(64), "removed.js"); + const mixedEvidenceAddition = observedAssetWithUnavailableFact(); + const unknownResult = compare([leftRoot], [rightRoot, unknown]); + const addedResult = compare([leftRoot], [rightRoot, added]); + const removedResult = compare([leftRoot, removed], [rightRoot]); + const mixedResult = compare([leftRoot], [rightRoot, mixedEvidenceAddition]); + + expect( + unknownResult.items.find( + ({ right_node_id }) => right_node_id === unknown.node_id, + ), + ).toMatchObject({ status: "unknown", limitations: [LIMITATION] }); + expect(unknownResult.summary).toMatchObject({ added: 0, unknown: 1 }); + expect( + addedResult.items.find( + ({ right_node_id }) => right_node_id === added.node_id, + ), + ).toMatchObject({ status: "added", dimensions: ["availability"] }); + expect(addedResult.summary.added).toBe(1); + expect( + mixedResult.items.find( + ({ right_node_id }) => right_node_id === mixedEvidenceAddition.node_id, + ), + ).toMatchObject({ status: "added", limitations: [LIMITATION] }); + expect(mixedResult.summary.added).toBe(1); + expect( + removedResult.items.find( + ({ left_node_id }) => left_node_id === removed.node_id, + ), + ).toMatchObject({ status: "removed", dimensions: ["availability"] }); + expect(removedResult.summary.removed).toBe(1); + expect( + compare( + [leftRoot, node("c".repeat(64), "same.js")], + [rightRoot, node("c".repeat(64), "same.js")], + ).summary, + ).toMatchObject({ + unchanged: 2, + added: 0, + removed: 0, + }); +}); diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 889c605e04..683ec9a338 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -9,6 +9,113 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest( + "keeps unavailable source-selection scopes unknown in version comparisons", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-scope-"); + const application = join(root, "application"); + const copy = join(root, "copy"); + await Promise.all([mkdir(application), mkdir(copy)]); + const files = [ + ["lib.js", "export function shared() { return 1; }\n"], + ["main.js", 'export const name = "one";\n'], + ] as const; + for (const [name, contents] of files) { + await Promise.all([ + writeFile(join(application, name), contents), + writeFile(join(copy, name), contents), + ]); + } + + const analyze = async (input: string, selected: boolean) => { + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + input, + ...(selected ? ["--source-path", "main.js"] : []), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return parseEvidence(response.json); + }; + const full = await analyze(application, false); + const selected = await analyze(application, true); + const fullCopy = await analyze(copy, false); + const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse( + selected.normalized_result, + ); + const parseScope = selectedAnalysis.graph.nodes.find( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => + properties.path === "lib.js" && + properties.operation === "select-javascript-sources", + ), + ); + expect(parseScope?.observations[0]?.evidence).toMatchObject({ + state: "unavailable", + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + + const compare = async ( + left: ReturnType, + right: ReturnType, + name: string, + ) => { + const inputPath = join(root, `${name}.json`); + await writeFile(inputPath, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", inputPath, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + }; + const forward = await compare(full, selected, "forward"); + const reverse = await compare(selected, full, "reverse"); + const unchangedCopy = await compare(full, fullCopy, "full-copy"); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === parseScope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + evidence_links: expect.arrayContaining([ + full.evidence_id, + selected.evidence_id, + ]), + }); + expect( + reverse.items.find( + ({ left_node_id }) => left_node_id === parseScope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + expect(forward.summary.added).toBe(0); + expect(forward.summary.removed).toBe(0); + expect(reverse.summary.added).toBe(0); + expect(reverse.summary.removed).toBe(0); + expect(unchangedCopy.summary).toMatchObject({ added: 0, removed: 0 }); + }, +); + cliTest( "uses syntax-aware bundle fingerprints for changed syntax and renamed bindings", async ({ cli }) => { diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 77795085cf..482e6238f8 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -25,6 +25,7 @@ import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { z } from "zod"; @@ -186,23 +187,31 @@ const expectSelfImportEvidence = ( const analyzeThroughStdioMcp = async ( inputPath: string, ): Promise => { + const evidence = await analyzeEvidenceThroughStdioMcp(inputPath); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect(result).toMatchObject({ input_path: inputPath, format: "directory" }); + return result; +}; + +const analyzeEvidenceThroughStdioMcp = async ( + inputPath: string, + sourcePaths?: readonly string[], +) => { const client = await connectedMcpClient(); const response = await client.callTool({ name: "analyze_javascript_application", - arguments: { input_path: inputPath }, + arguments: { + input_path: inputPath, + ...(sourcePaths === undefined ? {} : { source_paths: [...sourcePaths] }), + }, }); expect(response.isError).not.toBe(true); expect(response.content).toEqual( expect.arrayContaining([expect.objectContaining({ type: "text" })]), ); - const result = z - .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) - .parse(response.structuredContent).normalized_result; - expect(result).toMatchObject({ - input_path: inputPath, - format: "directory", - }); - return result; + return parseEvidence(response.structuredContent); }; const expectCliAnalysisParity = async ( @@ -1220,6 +1229,73 @@ describe("application workflow CLI input", () => { }); }); +describe("application version source-scope MCP comparison", () => { + it("keeps unselected parse scopes unknown through stdio MCP", async () => { + const root = await createTestTempDirectory("rea-version-scope-mcp-"); + await Promise.all([ + writeFile( + join(root, "lib.js"), + "export function shared() { return 1; }\n", + ), + writeFile(join(root, "main.js"), 'export const name = "one";\n'), + ]); + const full = await analyzeEvidenceThroughStdioMcp(root); + const selected = await analyzeEvidenceThroughStdioMcp(root, ["main.js"]); + const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse( + selected.normalized_result, + ); + const scope = selectedAnalysis.graph.nodes.find( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => + properties.path === "lib.js" && + properties.operation === "select-javascript-sources", + ), + ); + expect(scope?.observations[0]?.evidence).toMatchObject({ + state: "unavailable", + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + const client = await connectedMcpClient(); + const compare = async (left: unknown, right: unknown) => { + const response = await client.callTool({ + name: "compare_application_versions", + arguments: { left, right }, + }); + expect(response.isError).not.toBe(true); + return applicationVersionComparisonResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + }; + const forward = await compare(full, selected); + const reverse = await compare(selected, full); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === scope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + evidence_links: expect.arrayContaining([ + full.evidence_id, + selected.evidence_id, + ]), + }); + expect( + reverse.items.find(({ left_node_id }) => left_node_id === scope?.node_id), + ).toMatchObject({ status: "unknown", dimensions: ["availability"] }); + expect(forward.summary).toMatchObject({ added: 0, removed: 0 }); + expect(reverse.summary).toMatchObject({ added: 0, removed: 0 }); + }); +}); + describe("application workflow CLI copy boundaries", () => { it("compares source values excluded from escaped rest and spread copies", async () => { const root = await createTestTempDirectory("rea-copy-boundary-cli-");