diff --git a/src/domain/javascript/javascriptFeatureSeed.ts b/src/domain/javascript/javascriptFeatureSeed.ts index d5f06b8172..2cd238487d 100644 --- a/src/domain/javascript/javascriptFeatureSeed.ts +++ b/src/domain/javascript/javascriptFeatureSeed.ts @@ -40,17 +40,9 @@ const seedMatch = ( node: ApplicationNode, seed: ApplicationFeatureSeed, ): ApplicationFeatureSeedMatch[] => { - if (seed.kind === "node-id") - return node.node_id === seed.value - ? [ - { - node_id: node.node_id, - kind: node.kind, - basis: "node-id", - field: "node_id", - }, - ] - : []; + const mode = featureSeedMatchMode(seed); + const caseSensitive = seed.case_sensitive; + const query = normalized(seed.value, caseSensitive); if (!kindMatches(node, seed.kind)) return []; const requestedEntryPath = seed.kind === "api" && "entry_path" in seed ? seed.entry_path : undefined; @@ -69,9 +61,6 @@ const seedMatch = ( }) ) return []; - const mode = featureSeedMatchMode(seed); - const caseSensitive = seed.case_sensitive; - const query = normalized(seed.value, caseSensitive); const match = candidateFields(node, seed.kind).find((candidate) => { const value = normalized(candidate.value, caseSensitive); return mode === "exact" ? value === query : value.includes(query); @@ -90,9 +79,9 @@ const seedMatch = ( const kindMatches = ( node: ApplicationNode, - kind: Exclude, + kind: ApplicationFeatureSeed["kind"], ): boolean => { - if (kind === "string") return true; + if (kind === "string" || kind === "node-id") return true; if (kind === "route") return ( node.kind === "endpoint" && @@ -116,8 +105,25 @@ const kindMatches = ( const candidateFields = ( node: ApplicationNode, - seedKind: Exclude, + seedKind: ApplicationFeatureSeed["kind"], ): CandidateField[] => { + if (seedKind === "node-id") + return [{ basis: "node-id", field: "node_id", value: node.node_id }]; + if (seedKind === "route") + return node.observations.flatMap((observation, index) => + (["value", "path"] as const).flatMap((key) => { + const value = observation.properties[key]; + return typeof value === "string" + ? [ + { + basis: "property" as const, + field: `observations[${String(index)}].properties.${key}`, + value, + }, + ] + : []; + }), + ); const fields: CandidateField[] = [ ...identityFields(node), ...node.observations.flatMap((observation, index) => [ diff --git a/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts new file mode 100644 index 0000000000..a0438556d7 --- /dev/null +++ b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts @@ -0,0 +1,256 @@ +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 } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { + applicationFeatureSeedSchema, + applicationFeatureTraceResultSchema, +} from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +type FeatureSeed = z.input; +type Trace = z.infer; +type SeedScenario = { + readonly name: string; + readonly seed: FeatureSeed; + readonly expected: { + readonly nodeId: string; + readonly basis: "node-id" | "property"; + } | null; +}; + +const createSeedScenarios = ( + graph: z.infer["graph"], +): SeedScenario[] => { + const notificationRoute = graph.nodes.find( + (node) => + node.kind === "endpoint" && + node.observations.some( + ({ properties }) => + properties.endpoint_kind === "route" && + properties.value === "/accounts/preferences/notifications", + ), + ); + const tinyRoute = graph.nodes.find( + (node) => + node.kind === "endpoint" && + node.observations.some( + ({ properties }) => + properties.endpoint_kind === "route" && properties.value === "/tiny", + ), + ); + const labeledNode = graph.nodes.find((node) => + node.observations.some( + ({ label }) => label !== null && label !== node.node_id, + ), + ); + if (notificationRoute === undefined || tinyRoute === undefined) + throw new Error( + "Expected literal route endpoints in the application graph", + ); + if (labeledNode === undefined) + throw new Error("Expected a labeled node in the application graph"); + const label = labeledNode.observations.find( + ({ label: value }) => value !== null && value !== labeledNode.node_id, + )?.label; + if (label === undefined || label === null) + throw new Error("Expected the selected graph node to have a display label"); + + const id = labeledNode.node_id; + return [ + { + name: "node ID contains prefix", + seed: { kind: "node-id", value: id.slice(0, 24), match: "contains" }, + expected: { nodeId: id, basis: "node-id" }, + }, + { + name: "node ID exact ignores case by default", + seed: { kind: "node-id", value: id.toUpperCase(), match: "exact" }, + expected: { nodeId: id, basis: "node-id" }, + }, + { + name: "node ID exact can be case sensitive", + seed: { + kind: "node-id", + value: id.toUpperCase(), + match: "exact", + case_sensitive: true, + }, + expected: null, + }, + { + name: "node ID never falls back to a display label", + seed: { kind: "node-id", value: label, match: "exact" }, + expected: null, + }, + { + name: "generated route label exact", + seed: { + kind: "route", + value: "route endpoint", + match: "exact", + case_sensitive: true, + }, + expected: null, + }, + { + name: "generated route label substring", + seed: { kind: "route", value: "route", match: "contains" }, + expected: null, + }, + { + name: "preserved long route exact", + seed: { + kind: "route", + value: "/accounts/preferences/notifications", + match: "exact", + }, + expected: { nodeId: notificationRoute.node_id, basis: "property" }, + }, + { + name: "preserved route substring", + seed: { + kind: "route", + value: "/accounts/preferences", + match: "contains", + }, + expected: { nodeId: notificationRoute.node_id, basis: "property" }, + }, + { + name: "short preserved route exact", + seed: { kind: "route", value: "/tiny", match: "exact" }, + expected: { nodeId: tinyRoute.node_id, basis: "property" }, + }, + ]; +}; + +const analyzeFixture = async (cli: TestCli, root: string) => { + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(response.exitCode).toBe(0); + const evidence = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(response.json); + return { + evidence, + result: javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ), + }; +}; + +const traceThroughCli = async ( + cli: TestCli, + application: unknown, + scenario: SeedScenario, +): Promise => { + const response = await cli.run({ + arguments: [ + "trace-application-feature", + JSON.stringify({ + application, + seed: scenario.seed, + direction: "both", + }), + "--json", + ], + }); + expect(response.exitCode, `${scenario.name} via CLI`).toBe(0); + const evidence = toolContract("trace_application_feature").outputSchema.parse( + response.json, + ); + return applicationFeatureTraceResultSchema.parse(evidence.normalized_result); +}; + +const traceThroughMcp = async ( + client: Client, + application: unknown, + scenario: SeedScenario, +): Promise => { + const response = await client.callTool({ + name: "trace_application_feature", + arguments: { + application, + seed: scenario.seed, + direction: "both", + }, + }); + expect(response.isError, `${scenario.name} via MCP`).not.toBe(true); + const evidence = toolContract("trace_application_feature").outputSchema.parse( + response.structuredContent, + ); + return applicationFeatureTraceResultSchema.parse(evidence.normalized_result); +}; + +const expectTrace = (trace: Trace, scenario: SeedScenario): void => { + if (scenario.expected === null) { + expect(trace.seed_matches).toEqual([]); + expect(trace.summary.matched_seeds).toBe(0); + expect(trace.coverage.status).toBe("no-match"); + return; + } + expect(trace.seed_matches).toHaveLength(1); + expect(trace.seed_matches[0]).toMatchObject({ + node_id: scenario.expected.nodeId, + basis: scenario.expected.basis, + }); + expect(trace.summary.matched_seeds).toBe(1); + expect(trace.coverage.status).toBe("complete-within-source"); + if (scenario.seed.kind === "node-id") + expect(trace.seed_matches[0]).toMatchObject({ + basis: "node-id", + field: "node_id", + }); + else + expect(trace.seed_matches[0]?.field).toMatch( + /\.properties\.(value|path)$/u, + ); +}; + +cliTest( + "matches feature seeds against node IDs and preserved route values through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-feature-seed-selection-"); + await writeFile( + join(root, "routes.js"), + `const notificationRoute = { path: "/accounts/preferences/notifications" };\nconst tinyRoute = { path: "/tiny" };\nmodule.exports = { notificationRoute, tinyRoute };\n`, + ); + const { evidence, result } = await analyzeFixture(cli, root); + const client = new Client({ name: "feature-seed-selection", version: "1" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + try { + await client.connect(transport); + for (const scenario of createSeedScenarios(result.graph)) { + const cliTrace = await traceThroughCli(cli, evidence, scenario); + const mcpTrace = await traceThroughMcp(client, evidence, scenario); + expectTrace(cliTrace, scenario); + expectTrace(mcpTrace, scenario); + expect(mcpTrace.seed_matches).toEqual(cliTrace.seed_matches); + expect(mcpTrace.coverage).toEqual(cliTrace.coverage); + } + } finally { + await client.close(); + await transport.close(); + } + }, +);