diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 592276e15e..f26c16d1f6 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -118,6 +118,16 @@ 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. +Event and listener nodes likewise preserve the complete event name in +`properties.event_name`. Event role keys use the same reserved-prefix string +digest rule, scoped to the emitter; repeated registrations, removals, and +dispatches keep one event per exact emitter/name pair. Long event labels use +`event`, and listener labels use the shorter of `:` and +`:listener`. Dynamic names remain `null` with explicit uncertainty. New analyses +change hashed event IDs and longer event/listener labels; query the preserved +name or select IDs from the parent graph. Previously stored graphs keep their +original IDs and remain valid trace inputs. + ## 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 580a4d0355..41f480daf3 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; +import { compactStringIdentityKey } from "../../domain/compactStringIdentity.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"; @@ -163,14 +163,7 @@ 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 key = compactStringIdentityKey(input.value.value); const label = `${input.value.kind} endpoint`; const node = context.accumulator.addNode({ kind: "endpoint", diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index a8814281f3..f54c5c4f31 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -1,3 +1,4 @@ +import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js"; import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { @@ -21,7 +22,7 @@ export const projectSemanticEvents = ( const eventKey = operation.eventName === null ? operation.eventId - : `${operation.emitterKey}\u0000${operation.eventName}`; + : `${operation.emitterKey}\u0000${compactStringIdentityKey(operation.eventName)}`; const event = eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey); if (event === null) continue; @@ -59,7 +60,11 @@ const addEventNode = ( kind: "event", roleKey: `event:${eventKey}`, location: operation.eventName === null ? operation.location : null, - label: operation.eventName, + label: + operation.eventName === null || + operation.eventName.length <= "event".length + ? operation.eventName + : "event", functionNodeId: null, properties: { emitter_key: operation.emitterKey, @@ -78,7 +83,9 @@ const addListenerNode = ( label: operation.eventName === null ? operation.method - : `${operation.method}:${operation.eventName}`, + : operation.eventName.length <= "listener".length + ? `${operation.method}:${operation.eventName}` + : `${operation.method}:listener`, functionNodeId: operation.ownerCallableId === null ? null diff --git a/src/domain/compactStringIdentity.ts b/src/domain/compactStringIdentity.ts new file mode 100644 index 0000000000..421555a908 --- /dev/null +++ b/src/domain/compactStringIdentity.ts @@ -0,0 +1,10 @@ +import { digestCanonicalValue } from "./canonicalDigest.js"; + +/** Retain short exact string keys or commit their value in a reserved digest namespace. */ +export const compactStringIdentityKey = (value: string): string => { + const prefix = "value-sha256:"; + const digestKey = `${prefix}${digestCanonicalValue(value, "String identity")}`; + return value.length <= digestKey.length && !value.startsWith(prefix) + ? value + : digestKey; +}; diff --git a/tests/acceptance/applications/javascriptEventPayload.test.ts b/tests/acceptance/applications/javascriptEventPayload.test.ts new file mode 100644 index 0000000000..c4689250de --- /dev/null +++ b/tests/acceptance/applications/javascriptEventPayload.test.ts @@ -0,0 +1,164 @@ +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 { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "retains large event names and emitter-scoped listener ownership through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-event-payload-"); + const name = "payload/ğ".repeat(80_000); + const lookalike = `value-sha256:${createHash("sha256").update(JSON.stringify(name)).digest("hex")}`; + const source = [ + 'import {EventEmitter} from "node:events";', + "const bus = new EventEmitter(); const other = new EventEmitter(); const handler = () => {};", + `bus.on(${JSON.stringify(name)}, handler);`, + `bus.emit(${JSON.stringify(name)});`, + `bus.off(${JSON.stringify(name)}, handler);`, + `bus.on(${JSON.stringify(lookalike)}, handler); bus.emit(${JSON.stringify(lookalike)});`, + 'bus.on("ok", handler); bus.emit("ok"); other.on("ok", handler); other.emit("ok");', + "bus.on(getEventName(), handler);", + ].join("\n"); + await writeFile(join(root, "main.js"), 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: "event-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"); + expect( + toolContract("analyze_javascript_application").outputSchema.parse( + JSON.parse(text.text), + ).evidence_id, + ).toBe(evidence.evidence_id); + const graph = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ).semantic_graph; + const events = graph.nodes.filter((node) => node.kind === "event"); + const selected = events.filter( + (node) => node.properties.event_name === name, + ); + expect(selected).toHaveLength(1); + const event = selected[0]; + if (event === undefined) throw new Error("Complete event was lost"); + const listeners = graph.nodes.filter( + (node) => + node.kind === "listener" && node.properties.event_name === name, + ); + expect(new Set(listeners.map((node) => node.properties.method))).toEqual( + new Set(["on", "off"]), + ); + const listenerIds = new Set(listeners.map((node) => node.node_id)); + expect( + graph.relations + .filter( + (relation) => + relation.source_node_id === event.node_id && + listenerIds.has(relation.target_node_id), + ) + .map((relation) => relation.relation), + ).toEqual( + expect.arrayContaining(["registers-listener", "removes-listener"]), + ); + const lookalikeEvent = events.find( + (node) => node.properties.event_name === lookalike, + ); + expect(lookalikeEvent).toBeDefined(); + expect(lookalikeEvent?.node_id).not.toBe(event.node_id); + const shortEvents = events.filter( + (node) => node.properties.event_name === "ok", + ); + expect(shortEvents).toHaveLength(2); + expect( + new Set(shortEvents.map((node) => node.properties.emitter_key)).size, + ).toBe(2); + expect( + shortEvents.every( + (node) => + node.label === "ok" && node.identity.role_key.endsWith("\0ok"), + ), + ).toBe(true); + const dynamic = events.find( + (node) => node.properties.event_name === null, + ); + expect(dynamic?.label).toBeNull(); + expect( + graph.unknowns.some( + (unknown) => + unknown.node_id === dynamic?.node_id && unknown.family === "event", + ), + ).toBe(true); + const traceResponse = await client.callTool({ + name: "trace_javascript_semantics", + arguments: { + application: { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }, + query: { + seed: { kind: "event", name }, + direction: "forward-influence", + }, + }, + }); + expect(traceResponse.isError).not.toBe(true); + const traceEvidence = toolContract( + "trace_javascript_semantics", + ).outputSchema.parse(traceResponse.structuredContent); + const trace = javaScriptSemanticTraceResultSchema.parse( + traceEvidence.normalized_result, + ); + expect(new Set(trace.seed_node_ids)).toEqual( + new Set([event.node_id, ...listenerIds]), + ); + expect( + trace.nodes + .filter((node) => listenerIds.has(node.node_id)) + .map((node) => node.properties.event_name), + ).toEqual(listeners.map(() => name)); + await client.ping(); + } finally { + await client.close(); + await transport.close(); + } + }, + 120_000, +);