Record the admitted selected path separately from canonical artifact identity in successful and partial Evidence. Anchor relative inputs consistently across both CLI routes using the existing caller-path owner. Adapt shared client verifier assertions and boundary coverage from PR #5261, and the dedicated-route anchoring correction proposed in PR #5265. Historical parsing remains independent of live paths, including legacy records without the parameter. Validation: 118 focused tests in eight files, static and docs checks, discovery, and installed Gemini CLI 0.29.5 loopback verification of 207 schemas/241 examples passed. Synthetic static-analysis fixtures; no live Google API or native target execution. Fixes #5258 Co-authored-by: kwakhyun <khyun2348@naver.com> Co-authored-by: vishal-tambi <tambivishal3@gmail.com>
296 lines
9.9 KiB
TypeScript
296 lines
9.9 KiB
TypeScript
import type { Client } from "@modelcontextprotocol/client";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
|
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
|
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
const cases = [
|
|
{
|
|
name: "both limits",
|
|
args: ["--max-heap-mb", "128", "--analysis-timeout-ms", "30000"],
|
|
controls: { max_heap_mb: 128, analysis_timeout_ms: 30000 },
|
|
},
|
|
{
|
|
name: "heap limit alone",
|
|
args: ["--max-heap-mb", "256"],
|
|
controls: { max_heap_mb: 256 },
|
|
},
|
|
{
|
|
name: "timeout alone",
|
|
args: ["--analysis-timeout-ms", "40000"],
|
|
controls: { analysis_timeout_ms: 40000 },
|
|
},
|
|
{
|
|
name: "omitted limits",
|
|
args: [],
|
|
controls: {},
|
|
},
|
|
];
|
|
|
|
cliTest.for(cases)(
|
|
"preserves $name in CLI Evidence without adding ambient defaults",
|
|
async ({ args, controls }, { cli }) => {
|
|
const root = await createTestTempDirectory("rea-js-limit-provenance-");
|
|
await writeFile(join(root, "main.js"), "export const observed = 7;\n");
|
|
const response = await cli.run({
|
|
arguments: [
|
|
"analyze-javascript-application",
|
|
root,
|
|
...args,
|
|
"--format",
|
|
"json",
|
|
],
|
|
environment: { NODE_OPTIONS: "--max-old-space-size=512" },
|
|
});
|
|
expect(response.exitCode, response.stdout + response.stderr).toBe(0);
|
|
const evidence = parseEvidence(response.json);
|
|
expect(evidence.parameters).toEqual({
|
|
input_path: root,
|
|
format: "auto",
|
|
integrity_policy: "fail",
|
|
...controls,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves MCP analysis limits in retained Evidence and an exported bundle round trip", async () => {
|
|
const root = await createTestTempDirectory("rea-js-limit-mcp-");
|
|
await writeFile(join(root, "main.js"), "export const observed = 7;\n");
|
|
const bundleRoot = await createTestTempDirectory("rea-js-limit-bundle-");
|
|
const path = join(bundleRoot, "evidence.json");
|
|
const { client } = await connectLocalToolsMcp({
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
});
|
|
const expected = new Map<string, unknown>();
|
|
for (const { controls } of cases) {
|
|
const response = await client.callTool({
|
|
name: "analyze_javascript_application",
|
|
arguments: { input_path: root, ...controls, detail: "summary" },
|
|
});
|
|
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
|
const { parent_evidence_id } = z
|
|
.object({
|
|
normalized_result: z.object({ parent_evidence_id: z.string() }),
|
|
})
|
|
.parse(response.structuredContent).normalized_result;
|
|
expected.set(parent_evidence_id, {
|
|
input_path: root,
|
|
format: "auto",
|
|
integrity_policy: "fail",
|
|
...controls,
|
|
});
|
|
}
|
|
expect(expected.size).toBe(cases.length);
|
|
const retained = await getBundle(client);
|
|
for (const [evidence_id, parameters] of expected)
|
|
expect(
|
|
retained.records.find((record) => record.evidence_id === evidence_id)
|
|
?.parameters,
|
|
).toEqual(parameters);
|
|
const exported = await client.callTool({
|
|
name: "export_evidence_bundle",
|
|
arguments: { path },
|
|
});
|
|
expect(exported.isError).not.toBe(true);
|
|
expect(parseEvidenceBundle(JSON.parse(await readFile(path, "utf8")))).toEqual(
|
|
retained,
|
|
);
|
|
const closed = await client.callTool({
|
|
name: "close_binary",
|
|
arguments: {},
|
|
});
|
|
expect(closed.isError).not.toBe(true);
|
|
expect((await getBundle(client)).records).toEqual([]);
|
|
const imported = await client.callTool({
|
|
name: "import_evidence_bundle",
|
|
arguments: { path },
|
|
});
|
|
expect(imported.isError, JSON.stringify(imported.content)).not.toBe(true);
|
|
expect(await getBundle(client)).toEqual(retained);
|
|
}, 60_000);
|
|
|
|
cliTest(
|
|
"retains caller limits and applicable file scopes after an actual analysis timeout",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-js-limit-timeout-");
|
|
const sourcePaths = [
|
|
"a.js",
|
|
"a.js.map",
|
|
"data.json",
|
|
"index.html",
|
|
"package.json",
|
|
"z.node",
|
|
];
|
|
await Promise.all(
|
|
(
|
|
[
|
|
["a.js", "export const observed = 7;\n"],
|
|
[
|
|
"a.js.map",
|
|
JSON.stringify({
|
|
version: 3,
|
|
sources: [],
|
|
names: [],
|
|
mappings: "",
|
|
}),
|
|
],
|
|
["data.json", '{"observed":7}'],
|
|
["index.html", '<script src="./a.js"></script>'],
|
|
["package.json", '{"name":"file-scope-control","main":"a.js"}'],
|
|
["z-excluded.js", "export const excluded = true;\n"],
|
|
["z.node", Buffer.from([0, 1, 2, 3])],
|
|
["zz-excluded.node", Buffer.from([3, 2, 1, 0])],
|
|
] as const
|
|
).map(([path, content]) => writeFile(join(root, path), content)),
|
|
);
|
|
const { client } = await connectLocalToolsMcp({
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
});
|
|
const response = await client.callTool({
|
|
name: "analyze_javascript_application",
|
|
arguments: {
|
|
input_path: root,
|
|
max_heap_mb: 128,
|
|
analysis_timeout_ms: 1,
|
|
source_paths: sourcePaths,
|
|
},
|
|
});
|
|
expect(parseMcpToolError(response)).toMatchObject({
|
|
error: { code: "provider_timeout" },
|
|
});
|
|
const bundle = await getBundle(client);
|
|
const partial = bundle.records.find(
|
|
(record) => record.operation === "analyze_javascript_application",
|
|
);
|
|
expect(partial?.parameters).toEqual({
|
|
input_path: root,
|
|
format: "auto",
|
|
integrity_policy: "fail",
|
|
max_heap_mb: 128,
|
|
analysis_timeout_ms: 1,
|
|
source_paths: sourcePaths,
|
|
});
|
|
if (partial === undefined)
|
|
throw new Error("Timeout Evidence was not retained");
|
|
const cliArguments = [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--max-heap-mb",
|
|
"128",
|
|
...sourcePaths.flatMap((path) => ["--source-path", path]),
|
|
"--format",
|
|
"json",
|
|
];
|
|
const cliTimeout = await cli.run({
|
|
arguments: [...cliArguments, "--analysis-timeout-ms", "1"],
|
|
});
|
|
expect(cliTimeout.exitCode).toBe(1);
|
|
const cliFailure = z
|
|
.object({
|
|
code: z.literal("provider_timeout"),
|
|
details: z.object({ partial_observation: z.unknown() }),
|
|
})
|
|
.parse(cliTimeout.json);
|
|
const cliPartial = parseEvidence(cliFailure.details.partial_observation);
|
|
const control = await client.callTool({
|
|
name: "analyze_javascript_application",
|
|
arguments: {
|
|
input_path: root,
|
|
max_heap_mb: 128,
|
|
source_paths: sourcePaths,
|
|
},
|
|
});
|
|
expect(control.isError).not.toBe(true);
|
|
const controlEvidence = parseEvidence(control.structuredContent);
|
|
const cliControl = await cli.run({ arguments: cliArguments });
|
|
expect(cliControl.exitCode, cliControl.stderr).toBe(0);
|
|
const cliControlEvidence = parseEvidence(cliControl.json);
|
|
expect(cliControlEvidence.evidence_id).toBe(controlEvidence.evidence_id);
|
|
// Observe both adapters and successful controls before testing applicability.
|
|
for (const [evidence, interrupted] of [
|
|
[partial, true],
|
|
[cliPartial, true],
|
|
[controlEvidence, false],
|
|
[cliControlEvidence, false],
|
|
] as const)
|
|
expectFileAnalysisApplicability(evidence, interrupted);
|
|
await client.ping();
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
async function getBundle(client: Client) {
|
|
const response = await client.callTool({
|
|
name: "get_evidence_bundle",
|
|
arguments: { detail: "complete" },
|
|
});
|
|
expect(response.isError).not.toBe(true);
|
|
return parseEvidenceBundle(
|
|
z.object({ result: z.unknown() }).parse(response.structuredContent).result,
|
|
);
|
|
}
|
|
|
|
function expectFileAnalysisApplicability(
|
|
evidence: Evidence,
|
|
interrupted: boolean,
|
|
) {
|
|
const result = javascriptApplicationAnalysisResultSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
const scopeObservations = result.graph.nodes
|
|
.filter(({ kind }) => kind === "unknown")
|
|
.flatMap(({ observations }) => observations);
|
|
const scopes = (path: string) =>
|
|
scopeObservations.filter(({ properties }) => properties.path === path);
|
|
expect(result.graph.nodes.some(({ kind }) => kind === "native-addon")).toBe(
|
|
true,
|
|
);
|
|
for (const path of ["z.node", "zz-excluded.node"])
|
|
expect(scopes(path), path).toEqual([]);
|
|
const excluded = scopes("z-excluded.js");
|
|
expect(excluded).toHaveLength(1);
|
|
expect(excluded[0]).toMatchObject({
|
|
properties: { operation: "select-javascript-sources" },
|
|
evidence: {
|
|
authority: "ast-static-analysis",
|
|
state: "unavailable",
|
|
confidence: "unknown",
|
|
},
|
|
});
|
|
expect(excluded[0]?.properties).not.toHaveProperty("analysis_failure");
|
|
for (const [path, operation] of [
|
|
["a.js", "analyze-javascript-source"],
|
|
["a.js.map", "parse-local-source-map"],
|
|
["data.json", "parse-json-module"],
|
|
["index.html", "parse-html-script-references"],
|
|
["package.json", "parse-package-json"],
|
|
] as const) {
|
|
if (!interrupted) expect(scopes(path), path).toEqual([]);
|
|
else {
|
|
expect(scopes(path), path).toHaveLength(1);
|
|
expect(scopes(path)[0]).toMatchObject({
|
|
properties: {
|
|
operation,
|
|
analysis_status: path === "a.js" ? "interrupted" : "not-started",
|
|
},
|
|
evidence: {
|
|
authority: path === "a.js" ? "ast-static-analysis" : "artifact-bytes",
|
|
state: "unavailable",
|
|
confidence: "unknown",
|
|
coverage: { status: "partial" },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (interrupted) expect(result.graph.coverage.status).toBe("partial");
|
|
else expect(result.statistics.parsed_javascript_files).toBe(1);
|
|
}
|