diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 0093710351..592276e15e 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -104,6 +104,20 @@ graph and read literal values from `properties.value`, rather than decoding role keys or labels. Previously stored graphs and their original IDs remain valid inputs. +Endpoint nodes keep the exact endpoint in each observation's `properties.value`; +semantic request and response nodes keep it in `properties.endpoint`. Endpoint +identity keys use `value-sha256:` over the canonical JSON string when +that is shorter, or when the original value starts with the reserved digest +prefix. Other short endpoint keys remain unchanged. Endpoint labels use the +shorter of the exact value and ` endpoint`; semantic request/response +labels use the shorter of the endpoint and operation method. These labels are +display metadata, not endpoint lookup values. + +New analyses therefore change hashed endpoint IDs and longer endpoint labels. +Use IDs from the selected graph and read the complete endpoint properties; +previously stored graphs still support their original IDs. Literal endpoint +search, semantic tracing, and version comparison use the preserved values. + ## Version comparison REA pairs entities only when a tier produces one unique candidate on each side. diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index cff140220a..580a4d0355 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; +import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -162,16 +163,22 @@ const addEndpoint = ( const source = sourceNodeFor(context, input.file.path, input.value.module_key) ?? input.asset; + const digestPrefix = "value-sha256:"; + const valueKey = `${digestPrefix}${digestCanonicalValue(input.value.value, "JavaScript endpoint")}`; + // Reserve the digest prefix so an exact caller value cannot alias a digest key. + const key = + input.value.value.length <= valueKey.length && + !input.value.value.startsWith(digestPrefix) + ? input.value.value + : valueKey; + const label = `${input.value.kind} endpoint`; const node = context.accumulator.addNode({ kind: "endpoint", - identity: artifactLocalIdentity( - input.file.sha256, - input.value.kind, - input.value.value, - ), + identity: artifactLocalIdentity(input.file.sha256, input.value.kind, key), observations: [ { - label: input.value.value, + label: + input.value.value.length <= label.length ? input.value.value : label, properties: { endpoint_kind: input.value.kind, value: input.value.value, diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index 51efcd2ec6..b1c44e8f8c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -137,7 +137,11 @@ const addRequestNode = ( kind: operation.kind === "request" ? "request" : "response", roleKey: operation.requestId, location: operation.location, - label: operation.endpoint ?? operation.method, + label: + operation.endpoint !== null && + operation.endpoint.length <= operation.method.length + ? operation.endpoint + : operation.method, functionNodeId: operation.ownerCallableId === null ? null diff --git a/tests/acceptance/applications/javascriptEndpointPayload.test.ts b/tests/acceptance/applications/javascriptEndpointPayload.test.ts new file mode 100644 index 0000000000..6a980aa7ad --- /dev/null +++ b/tests/acceptance/applications/javascriptEndpointPayload.test.ts @@ -0,0 +1,263 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; +import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; +import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "keeps full endpoint evidence usable without copying large values into metadata", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-endpoint-payload-"); + const endpoint = `https://example.invalid/${"part/ğ".repeat(160_000)}`; + const digestLookalike = `value-sha256:${createHash("sha256").update(JSON.stringify(endpoint)).digest("hex")}`; + // UTF-8 replacement would conflate these distinct JavaScript strings. + const surrogate = "\ud800".repeat(100); + const replacement = "\ufffd".repeat(100); + const values = [ + endpoint, + digestLookalike, + "/ok", + "/ok", + surrogate, + replacement, + ]; + const source = values + .map((value) => `fetch(${JSON.stringify(value)});`) + .join("\n"); + const sourcePath = join(root, "main.js"); + await writeFile(sourcePath, source); + const cliResponse = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + timeoutMs: 60_000, + }); + expect(cliResponse.exitCode).toBe(0); + const cliEvidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(cliResponse.json); + + const client = new Client({ name: "endpoint-payload-e2e", version: "1" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "" }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + try { + await client.connect(transport); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, format: "directory" }, + }); + expect(response.isError).not.toBe(true); + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(response.structuredContent); + expect(evidence.evidence_id).toBe(cliEvidence.evidence_id); + const text = response.content.find((item) => item.type === "text"); + if (text?.type !== "text") throw new Error("Missing MCP text result"); + const textEvidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(JSON.parse(text.text)); + expect(textEvidence.evidence_id).toBe(evidence.evidence_id); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + const endpoints = result.graph.nodes.filter( + (node) => node.kind === "endpoint", + ); + const nodeFor = (value: string) => { + const node = endpoints.find((candidate) => + candidate.observations.some( + ({ properties }) => properties.value === value, + ), + ); + if (node === undefined) throw new Error("Complete endpoint was lost"); + return node; + }; + const endpointNode = nodeFor(endpoint); + expect( + new Set( + [endpoint, digestLookalike, surrogate, replacement].map( + (value) => nodeFor(value).node_id, + ), + ).size, + ).toBe(4); + const repeatedNode = nodeFor("/ok"); + expect( + endpoints.filter((node) => + node.observations.some( + ({ properties }) => properties.value === "/ok", + ), + ), + ).toHaveLength(1); + expect(repeatedNode.observations).toHaveLength(2); + expect(repeatedNode.identity).toMatchObject({ key: "/ok" }); + expect( + repeatedNode.observations.every(({ label }) => label === "/ok"), + ).toBe(true); + expect(endpointNode.identity).toMatchObject({ + artifact_sha256: createHash("sha256").update(source).digest("hex"), + }); + expect(endpointNode.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "main.js" }, + }); + const requests = result.semantic_graph.nodes.filter( + (node) => + node.kind === "request" && node.properties.endpoint === endpoint, + ); + expect(requests).toHaveLength(1); + + await verifyEndpointWorkflows({ + client, + evidenceId: evidence.evidence_id, + endpoint, + endpointNodeId: endpointNode.node_id, + requestNodeIds: requests.map(({ node_id }) => node_id), + root, + source, + sourcePath, + }); + await client.ping(); + } finally { + await client.close(); + await transport.close(); + } + }, + 120_000, +); + +interface EndpointWorkflowInput { + readonly client: Client; + readonly evidenceId: string; + readonly endpoint: string; + readonly endpointNodeId: string; + readonly requestNodeIds: readonly string[]; + readonly root: string; + readonly source: string; + readonly sourcePath: string; +} + +const verifyEndpointWorkflows = async ({ + client, + evidenceId, + endpoint, + endpointNodeId, + requestNodeIds, + root, + source, + sourcePath, +}: EndpointWorkflowInput): Promise => { + const application = { + kind: "retained-evidence", + evidence_id: evidenceId, + }; + const featureResponse = await client.callTool({ + name: "trace_application_feature", + arguments: { + application, + seed: { + kind: "string", + value: endpoint, + match: "exact", + case_sensitive: true, + }, + }, + }); + expect(featureResponse.isError).not.toBe(true); + const featureEvidence = toolContract( + "trace_application_feature", + ).outputSchema.parse(featureResponse.structuredContent); + const feature = applicationFeatureTraceResultSchema.parse( + featureEvidence.normalized_result, + ); + expect(feature.seed_matches.map(({ node_id }) => node_id)).toContain( + endpointNodeId, + ); + + const traceResponse = await client.callTool({ + name: "trace_javascript_semantics", + arguments: { + application, + query: { + seed: { kind: "endpoint", value: endpoint }, + direction: "backward-provenance", + }, + }, + }); + expect(traceResponse.isError).not.toBe(true); + const traceEvidence = toolContract( + "trace_javascript_semantics", + ).outputSchema.parse(traceResponse.structuredContent); + const trace = javaScriptSemanticTraceResultSchema.parse( + traceEvidence.normalized_result, + ); + expect(trace.seed_node_ids).toEqual(requestNodeIds); + expect( + trace.nodes.some((node) => node.properties.endpoint === endpoint), + ).toBe(true); + + await writeFile(sourcePath, `// next artifact version\n${source}`); + const nextResponse = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, format: "directory" }, + }); + expect(nextResponse.isError).not.toBe(true); + const nextEvidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(nextResponse.structuredContent); + const next = javascriptApplicationAnalysisResultSchema.parse( + nextEvidence.normalized_result, + ); + const nextEndpoint = next.graph.nodes.find( + (node) => + node.kind === "endpoint" && + node.observations.some(({ properties }) => properties.value === endpoint), + ); + if (nextEndpoint === undefined) throw new Error("Next endpoint was lost"); + const comparisonResponse = await client.callTool({ + name: "compare_application_versions", + arguments: { + left: application, + right: { + kind: "retained-evidence", + evidence_id: nextEvidence.evidence_id, + }, + }, + }); + expect(comparisonResponse.isError).not.toBe(true); + const comparisonEvidence = toolContract( + "compare_application_versions", + ).outputSchema.parse(comparisonResponse.structuredContent); + const comparison = applicationVersionComparisonResultSchema.parse( + comparisonEvidence.normalized_result, + ); + expect(comparison.items).toContainEqual( + expect.objectContaining({ + node_kind: "endpoint", + left_node_id: endpointNodeId, + right_node_id: nextEndpoint.node_id, + match: expect.objectContaining({ + status: "matched", + basis: "semantic-key", + }), + }), + ); +};