diff --git a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts index 77823fc545..dd09dca96e 100644 --- a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts +++ b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts @@ -20,6 +20,14 @@ it("reports a candidate endpoint through the worker boundary in the public CLI", import * as vscode from "vscode"; client.post("session"); (client.get as any)("/api/items"); + const records = new Map([["/accounts", { name: "Alice" }]]); + console.log(records.get("/accounts")); + function inspect() { + const records = new Map(); + const alias = records; + return alias.get("/local-accounts"); + } + fetch("/network"); (new Headers().get as any)("content-type"); (new URLSearchParams() as any).delete("q"); ((vscode.workspace.getConfiguration("editor") as any).get as any)("tabSize"); @@ -32,6 +40,33 @@ it("reports a candidate endpoint through the worker boundary in the public CLI", (cache.open as any)("fixture-cache"); `, ); + for (const [name, source] of Object.entries({ + "escaped.mjs": `const box = {}; + box.prototype = Map.prototype; + function patch(b) { b.prototype.get = fetch; } + patch(box); new Map().get("/escaped");`, + "returned.mjs": + 'patch((() => Map.prototype)()); new Map().get("/returned");', + "opaque.mjs": + 'eval("Map.prototype.get = fetch"); new Map().get("/opaque");', + "private.mjs": + 'const box = {}; box.prototype = Map.prototype; new Map().get("/private");', + "returned-write.mjs": + 'const prototype = (() => Map.prototype)(); prototype.get = fetch; new Map().get("/returned-write");', + "prototype.mjs": + 'Object.getPrototypeOf(new Map()).get = fetch; new Map().get("/prototype");', + "indirect.mjs": + 'globalThis.eval("Map.prototype.get = fetch"); new Map().get("/indirect");', + "late-link.mjs": + 'function store() { box.child.prototype = Map.prototype; } const box = {}; const inner = {}; box.child = inner; store(); patch(inner); new Map().get("/late-link");', + "constructor.mjs": + 'new Map().constructor.prototype.get = fetch; new Map().get("/constructor");', + "getter.mjs": + 'const holder = { get prototype() { return Map.prototype; } }; holder.prototype.get = fetch; new Map().get("/getter");', + "assignment.mjs": + 'let run; (run = eval)("Map.prototype.get = fetch"); new Map().get("/assignment");', + })) + await writeFile(join(root, name), source); const { stdout } = await execute( process.execPath, ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], @@ -49,7 +84,53 @@ it("reports a candidate endpoint through the worker boundary in the public CLI", ) .sort(), ) - .toEqual(["/api/items", "session"]); + .toEqual([ + "/api/items", + "/assignment", + "/constructor", + "/escaped", + "/getter", + "/indirect", + "/late-link", + "/network", + "/opaque", + "/prototype", + "/returned", + "/returned-write", + "session", + ]); + for (const value of [ + "/api/items", + "/assignment", + "/constructor", + "/escaped", + "/getter", + "/indirect", + "/late-link", + "/opaque", + "/prototype", + "/returned", + "/returned-write", + ]) { + const escaped = result.graph.nodes.find( + ({ kind, observations }) => + kind === "endpoint" && + observations.some(({ properties }) => properties.value === value), + ); + expect(escaped?.observations[0]).toMatchObject({ + properties: { interpretation: "candidate" }, + evidence: { state: "inferred", confidence: "low" }, + }); + } + const request = result.graph.nodes.find( + ({ kind, observations }) => + kind === "endpoint" && + observations.some(({ properties }) => properties.value === "/network"), + ); + expect(request?.observations[0]?.evidence).toMatchObject({ + state: "observed", + confidence: "exact", + }); expect( result.graph.nodes .filter(({ kind }) => kind === "storage") diff --git a/tests/boundary/mcp/javascriptEndpointCandidate.test.ts b/tests/boundary/mcp/javascriptEndpointCandidate.test.ts new file mode 100644 index 0000000000..6c315cf0b7 --- /dev/null +++ b/tests/boundary/mcp/javascriptEndpointCandidate.test.ts @@ -0,0 +1,84 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("preserves Map omission and uncertain endpoint Evidence through public MCP", async () => { + const root = await createTestTempDirectory("rea-map-endpoint-mcp-"); + await writeFile( + join(root, "app.mjs"), + ` + const records = new Map(); + const alias = records; + alias.get('/accounts'); + records.delete('/accounts'); + client.get('/uncertain'); + fetch('/network'); + `, + ); + const client = new Client({ name: "map-endpoint-regression", 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", + }); + try { + await client.connect(transport); + const output = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root }, + }); + expect(output.isError).not.toBe(true); + const evidence = parseEvidence(output.structuredContent); + const text = output.content.find((block) => block.type === "text"); + expect(text?.type === "text" ? JSON.parse(text.text) : undefined).toEqual( + evidence, + ); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect( + endpoints + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.value), + ) + .sort(), + ).toEqual(["/network", "/uncertain"]); + const candidate = endpoints.find( + ({ observations }) => observations[0]?.properties.value === "/uncertain", + ); + expect(candidate?.observations[0]).toMatchObject({ + properties: { interpretation: "candidate" }, + evidence: { + state: "inferred", + confidence: "low", + limitations: expect.arrayContaining([ + expect.stringContaining("only a candidate"), + ]), + }, + }); + expect( + result.graph.edges.find( + ({ target_node_id }) => target_node_id === candidate?.node_id, + )?.evidence, + ).toMatchObject({ state: "inferred", confidence: "low" }); + expect( + endpoints.find( + ({ observations }) => observations[0]?.properties.value === "/network", + )?.observations[0]?.evidence, + ).toMatchObject({ state: "observed", confidence: "exact" }); + } finally { + await client.close(); + await transport.close(); + } +}, 20_000);