mirror of
https://github.com/morluto/rea.git
synced 2026-10-23 14:22:30 -05:00
* perf(javascript): reuse the source-analysis worker within a process Each analyze-javascript-application call started a new Node worker. That startup, not fixture IO or the shared CLI session, dominated the slow loader-review and factory-completion files. Park one healthy worker on the artifact resource scope and on the CLI process. Stop it when the scope, session, or one-shot process ends. Do not reuse after failure, cancellation, or a different heap or timeout. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cli): close the JavaScript worker when dispatch throws Minimal dispatcher runtimes now export a no-op closeCliJavaScriptResources, and CLI dispatch plus output rendering always await that cleanup. A dispatch failure and a cleanup failure are both reported. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(artifacts): report close-hook failures with retained cleanup When retained cleanup and the scope close hook both fail, the close error keeps both causes and both resource labels. The pending owners and the hook stay registered so the next close retries each path. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): stop a replaced analysis worker before the next one starts A heap or timeout change now closes the parked worker before constructing its replacement. The scope retains that process only when the close fails. Co-authored-by: morluto <morluto@users.noreply.github.com> * style: format worker-reuse close and launch helpers Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cli): report a failed JavaScript worker shutdown before exit Share one in-flight close and drop the CLI scope only after it succeeds, so a later caller retries a worker that is still owned. The one-shot exit callback writes that failure and exits non-zero, while a closed stdout pipe still exits 141. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): keep a worker parked during an overlapping limit change Claim the scope slot only when it is still empty after the replaced worker closes. A concurrent analysis that parked another healthy worker keeps that process, and the extra worker is closed through its own port. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(artifacts): run later close hooks when an earlier hook fails Run every registered close hook on each close. One failure is returned unchanged, and several failures are aggregated so a later hook is not skipped. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(javascript): fail a limit change when the previous worker stays alive A failed close used to be retained and then ignored, so MCP could return a successful analysis while the old process and its replacement were both owned. The request now fails before the replacement is constructed, and the still-live worker stays owned for a later close. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { afterAll, expect, it } from "vitest";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
|
|
import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js";
|
|
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { closeCliJavaScriptResources, createCli } from "../../src/cli.js";
|
|
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
|
|
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
|
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
|
|
import { toolContract } from "../../src/contracts/toolContracts.js";
|
|
import { javascriptApplicationAnalysisResultSchema } from "../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
|
import { javaScriptSemanticTraceResultSchema } from "../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
|
|
import { resolveJavaScriptSemanticEvidence } from "../../src/domain/javascript/javascriptSemanticGraph.js";
|
|
|
|
const SEMANTIC_GRAPH_ID = `jsrg_${"0".repeat(64)}`;
|
|
|
|
// In-process serve keeps the CLI worker for this file. Stop it before the
|
|
// worker process exits so the private runtime root is not left behind.
|
|
afterAll(closeCliJavaScriptResources);
|
|
|
|
it("reports invalid nested semantic graph Evidence as caller input through CLI and SDK MCP", async () => {
|
|
const root = await createTestTempDirectory("rea-semantic-trace-input-");
|
|
await mkdir(join(root, "src"));
|
|
await writeFile(
|
|
join(root, "src", "main.js"),
|
|
"export function parse(value) { return JSON.parse(value); }\n",
|
|
);
|
|
|
|
const cli = createCli({});
|
|
const analyzed = await serveCli(cli, [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--json",
|
|
]);
|
|
expect(analyzed.exitCode).toBe(0);
|
|
const sourceEvidence = parseEvidence(JSON.parse(analyzed.stdout));
|
|
const normalizedResult = structuredClone(sourceEvidence.normalized_result);
|
|
if (!isJsonObject(normalizedResult))
|
|
throw new Error("CLI analysis result must be an object");
|
|
const semanticGraph = normalizedResult.semantic_graph;
|
|
if (!isJsonObject(semanticGraph))
|
|
throw new Error("CLI analysis must include a semantic graph");
|
|
const firstNode = semanticGraph.nodes;
|
|
if (!Array.isArray(firstNode) || !isJsonObject(firstNode[0]))
|
|
throw new Error("CLI semantic graph must include a node");
|
|
const seedNodeId = firstNode[0].node_id;
|
|
if (typeof seedNodeId !== "string")
|
|
throw new Error("CLI semantic graph node must have an identifier");
|
|
semanticGraph.graph_id = SEMANTIC_GRAPH_ID;
|
|
|
|
const subject = sourceEvidence.subject;
|
|
if (subject === null) throw new Error("CLI Evidence must include a subject");
|
|
const malformedEvidence = createEvidence(
|
|
{
|
|
path: subject.local_path,
|
|
sha256: subject.digest.sha256,
|
|
format: subject.format,
|
|
...(subject.architecture === null
|
|
? {}
|
|
: { architecture: subject.architecture }),
|
|
},
|
|
sourceEvidence.provider,
|
|
{
|
|
predicateType: sourceEvidence.predicate_type,
|
|
operation: sourceEvidence.operation,
|
|
parameters: sourceEvidence.parameters,
|
|
result: normalizedResult,
|
|
confidence: sourceEvidence.confidence,
|
|
authority: sourceEvidence.authority,
|
|
},
|
|
);
|
|
expect(malformedEvidence.evidence_id).not.toBe(sourceEvidence.evidence_id);
|
|
|
|
const arguments_ = {
|
|
application: malformedEvidence,
|
|
query: {
|
|
seed: { kind: "semantic-node", node_id: seedNodeId },
|
|
direction: "backward-provenance",
|
|
},
|
|
};
|
|
const cliTrace = await serveCli(cli, [
|
|
"trace-javascript-semantics",
|
|
JSON.stringify(arguments_),
|
|
"--json",
|
|
]);
|
|
const cliEnvelope = analysisCliErrorEnvelopeSchema.parse(
|
|
JSON.parse(cliTrace.stdout),
|
|
);
|
|
const { error: _label, ...cliError } = cliEnvelope;
|
|
expect(cliError).toMatchObject({
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: [
|
|
"application",
|
|
"normalized_result",
|
|
"semantic_graph",
|
|
"graph_id",
|
|
],
|
|
reason: "invalid_value",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const harness = await createApplicationMcpHarness();
|
|
try {
|
|
await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
sourceEvidence,
|
|
arguments_.query,
|
|
);
|
|
|
|
const mcpTrace = await harness.client.callTool({
|
|
name: "trace_javascript_semantics",
|
|
arguments: arguments_,
|
|
});
|
|
const { error: mcpError } = parseMcpToolError(mcpTrace);
|
|
expect(mcpError).toEqual(cliError);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("excludes local exports from writes-only traces through CLI and MCP", async () => {
|
|
const root = await createTestTempDirectory("rea-semantic-export-trace-");
|
|
await mkdir(join(root, "src"));
|
|
await writeFile(
|
|
join(root, "src", "main.js"),
|
|
[
|
|
"const answer = 42;",
|
|
"export { answer, answer as result };",
|
|
"let mutable = 0;",
|
|
"mutable = 1;",
|
|
"export { mutable };",
|
|
].join("\n"),
|
|
);
|
|
|
|
const cli = createCli({});
|
|
const analyzed = await serveCli(cli, [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--json",
|
|
]);
|
|
expect(analyzed.exitCode).toBe(0);
|
|
const application = parseEvidence(JSON.parse(analyzed.stdout));
|
|
const analysisResult = javascriptApplicationAnalysisResultSchema.parse(
|
|
application.normalized_result,
|
|
);
|
|
const bindingId = (label: string): string => {
|
|
const binding = analysisResult.semantic_graph.nodes.find(
|
|
(node) => node.kind === "binding" && node.label === label,
|
|
);
|
|
if (binding === undefined)
|
|
throw new Error(`CLI semantic graph must include the ${label} binding`);
|
|
return binding.node_id;
|
|
};
|
|
|
|
const harness = await createApplicationMcpHarness();
|
|
try {
|
|
const exportTrace = await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
application,
|
|
{
|
|
seed: { kind: "semantic-node", node_id: bindingId("answer") },
|
|
direction: "backward-provenance",
|
|
allowed_relations: ["writes"],
|
|
},
|
|
);
|
|
expect(exportTrace.summary).toMatchObject({
|
|
total_seed_matches: 1,
|
|
traversed_relations: 0,
|
|
});
|
|
expect(exportTrace.relations).toEqual([]);
|
|
|
|
const assignmentTrace = await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
application,
|
|
{
|
|
seed: { kind: "semantic-node", node_id: bindingId("mutable") },
|
|
direction: "backward-provenance",
|
|
allowed_relations: ["writes"],
|
|
},
|
|
);
|
|
expect(assignmentTrace.summary).toMatchObject({
|
|
total_seed_matches: 1,
|
|
traversed_relations: 1,
|
|
});
|
|
expect(assignmentTrace.relations).toHaveLength(1);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it("traces exact empty property names through CLI and MCP", async () => {
|
|
const root = await createTestTempDirectory("rea-semantic-empty-property-");
|
|
await writeFile(
|
|
join(root, "main.js"),
|
|
"const routes = {'': 'HOME', x: 'NAMED'}; const root = routes['']; const named = routes.x;\n",
|
|
);
|
|
const cli = createCli({});
|
|
const analyzed = await serveCli(cli, [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--json",
|
|
]);
|
|
expect(analyzed.exitCode).toBe(0);
|
|
const application = parseEvidence(JSON.parse(analyzed.stdout));
|
|
const { semantic_graph: graph } =
|
|
javascriptApplicationAnalysisResultSchema.parse(
|
|
application.normalized_result,
|
|
);
|
|
const harness = await createApplicationMcpHarness();
|
|
try {
|
|
for (const name of ["", "x"]) {
|
|
const slots = graph.nodes.filter(
|
|
(node) =>
|
|
node.kind === "property-slot" && node.properties.name === name,
|
|
);
|
|
expect(slots).toHaveLength(1);
|
|
const slot = slots[0];
|
|
if (slot === undefined)
|
|
throw new Error("Expected retained property slot");
|
|
const exact = await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
application,
|
|
{
|
|
seed: { kind: "semantic-node", node_id: slot.node_id },
|
|
direction: "forward-influence",
|
|
},
|
|
);
|
|
const selected = await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
application,
|
|
{
|
|
seed: { kind: "property", name },
|
|
direction: "forward-influence",
|
|
},
|
|
);
|
|
expect(selected.seed_node_ids).toEqual([slot.node_id]);
|
|
expect(selected.nodes).toEqual(exact.nodes);
|
|
expect(selected.relations).toEqual(exact.relations);
|
|
expect(selected.nodes).toHaveLength(2);
|
|
expect(selected.relations).toHaveLength(1);
|
|
expect(selected.coverage.status).toBe("partial");
|
|
}
|
|
const absent = await assertSuccessfulTraceParity(
|
|
cli,
|
|
harness,
|
|
application,
|
|
{
|
|
seed: { kind: "property", name: "missing" },
|
|
direction: "forward-influence",
|
|
},
|
|
);
|
|
expect(absent.seed_node_ids).toEqual([]);
|
|
expect(absent.nodes).toEqual([]);
|
|
expect(absent.relations).toEqual([]);
|
|
expect(absent.coverage.status).toBe("partial");
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
const assertSuccessfulTraceParity = async (
|
|
cli: ReturnType<typeof createCli>,
|
|
harness: Awaited<ReturnType<typeof createApplicationMcpHarness>>,
|
|
application: ReturnType<typeof parseEvidence>,
|
|
query: {
|
|
readonly seed: unknown;
|
|
readonly direction: string;
|
|
readonly allowed_relations?: readonly string[];
|
|
},
|
|
): Promise<ReturnType<typeof javaScriptSemanticTraceResultSchema.parse>> => {
|
|
const arguments_ = { application, query };
|
|
const cliResponse = await serveCli(cli, [
|
|
"trace-javascript-semantics",
|
|
JSON.stringify(arguments_),
|
|
"--json",
|
|
]);
|
|
expect(cliResponse.exitCode, cliResponse.stdout).toBe(0);
|
|
expect(JSON.parse(cliResponse.stdout), cliResponse.stdout).not.toHaveProperty(
|
|
"error",
|
|
);
|
|
const cliEvidence = parseEvidence(JSON.parse(cliResponse.stdout));
|
|
const cliResult = javaScriptSemanticTraceResultSchema.parse(
|
|
cliEvidence.normalized_result,
|
|
);
|
|
assertTraceContextsResolve(cliResult);
|
|
|
|
const mcpResponse = await harness.client.callTool({
|
|
name: "trace_javascript_semantics",
|
|
arguments: arguments_,
|
|
});
|
|
expect(mcpResponse.isError).not.toBe(true);
|
|
const { tools } = await harness.client.listTools();
|
|
const advertised = tools.find(
|
|
({ name }) => name === "trace_javascript_semantics",
|
|
);
|
|
if (advertised?.outputSchema === undefined)
|
|
throw new Error("Semantic trace SDK output schema is missing");
|
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
|
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
|
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
|
expect(ajv.validate(outputSchema, mcpResponse.structuredContent)).toBe(true);
|
|
const mcpEvidence = toolContract(
|
|
"trace_javascript_semantics",
|
|
).outputSchema.parse(mcpResponse.structuredContent);
|
|
expect(mcpEvidence.normalized_result).toEqual(cliResult);
|
|
expect(mcpEvidence).toEqual(cliEvidence);
|
|
return cliResult;
|
|
};
|
|
|
|
const assertTraceContextsResolve = (
|
|
result: ReturnType<typeof javaScriptSemanticTraceResultSchema.parse>,
|
|
): void => {
|
|
const contexts = new Map(
|
|
result.evidence_contexts.map((context) => [context.context_id, context]),
|
|
);
|
|
const references = [
|
|
...result.nodes.map(({ evidence }) => evidence),
|
|
...result.relations.map(({ evidence }) => evidence),
|
|
...result.unknowns.map(({ evidence }) => evidence),
|
|
];
|
|
const referencedIds = new Set<string>();
|
|
for (const reference of references) {
|
|
const context = contexts.get(reference.context_id);
|
|
expect(context, reference.context_id).toBeDefined();
|
|
if (context === undefined) continue;
|
|
referencedIds.add(reference.context_id);
|
|
expect(
|
|
resolveJavaScriptSemanticEvidence(
|
|
{ evidence_contexts: result.evidence_contexts },
|
|
reference,
|
|
),
|
|
).toMatchObject({
|
|
...Object.fromEntries(
|
|
Object.entries(context).filter(([key]) => key !== "context_id"),
|
|
),
|
|
location: reference.location,
|
|
});
|
|
}
|
|
expect([...referencedIds].toSorted()).toEqual(
|
|
[...contexts.keys()].toSorted(),
|
|
);
|
|
};
|
|
|
|
const serveCli = async (
|
|
cli: ReturnType<typeof createCli>,
|
|
arguments_: readonly string[],
|
|
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
|
|
let stdout = "";
|
|
let exitCode = 0;
|
|
await cli.serve([...arguments_], {
|
|
env: {},
|
|
exit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
});
|
|
return { stdout, exitCode };
|
|
};
|
|
|
|
const isJsonObject = (value: unknown): value is Record<string, JsonValue> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|