* fix(javascript): apply package type scope to application sources Application analysis parsed .js sources without their package scope, so an unbound require in a type-module package became a resolved CommonJS dependency. The parent now observes each source's nearest same-container package.json, stopping at node_modules, and the worker parses with that package type as reference imports already do. Fixes #7323 * fix(javascript): keep package type unknown behind unavailable manifests The package-type lookup indexed only readable files, so a nearer package.json whose bytes were unavailable was skipped and an ancestor's "type" applied to sources it does not own. Index unavailable manifests by container and directory too, and stop the walk with "unknown" when one is the nearest owner, as the Next app-context lookup already does. * fix(javascript): keep Electron preloads outside module package scope Electron's preload loader ignores a package's "type": "module" and gives a .js preload `require`; ESM preloads need the .mjs suffix. Applying the package type to every .js source marked such a preload as an ES module, so its `require("electron")` edges and IPC pairing disappeared. A source that requires an Electron module now keeps the context REA inferred before package scope was applied. Node-loaded sources in a module scope still drop unbound `require`. * fix(javascript): admit Electron script loaders only on renderer evidence The previous commit dropped a module package type whenever a source required an Electron module. A Node-loaded ES module that requires Electron, or one whose Electron call goes through a local `require`, then regained the false require edges the package scope removes. The parser applies the package type unconditionally again. The worker re-reads a package-module script that requires an Electron module once outside the package scope. The source keeps that context only when the static pass there directly observes a contextBridge exposure or a renderer IPC operation, which only Electron's renderer-side loaders provide. * fix(javascript): check Electron renderer evidence without a second static passb54845a6bran the full static pass on an unscoped parse of every package-module script that requires Electron, then discarded it when no renderer operation turned up, so such main-process sources paid for two full passes. A candidate must now also name contextBridge or ipcRenderer, which any renderer evidence needs. The evidence check runs only the static pass's binding and Electron identity preamble with its exposure and IPC descriptors. The full static pass runs once, on the chosen parse. * fix(javascript): confirm Electron renderer evidence with the static pass The focused evidence check from4a86bca9balso counted renderer calls in bundler factory defaults that the bundler convention never runs, which the static pass skips. Such a default switched a type-module file to script context, so its unrelated unbound requires became imports again. The focused check now only screens. It follows the static pass's own traversal, so a negative result still rules evidence out. A positive result is confirmed by the static pass's direct findings on the unscoped parse, and those findings become the file's facts when they hold. * fix(javascript): confirm renderer evidence before wrapper substitutionf71236400confirmed a screened source from the static pass's final contextBridge and IPC findings, without wrapper substitutions. The wrapper pass replaces a directly observed exposure inside a helper with a substituted finding, so such a preload lost its imports again. Before its wrapper pass, the static pass now records whether its source traversal directly observed a contextBridge exposure or a renderer IPC operation, and observedElectronRendererCall exposes that. The traversal still skips bundler factory defaults that never run, and the reported findings are unchanged. * fix(javascript): resolve declared preload loaders before source analysis * fix(javascript): leave preload discovery failures to source analysis A source that failed during preload discovery threw out of the whole application analysis, so one oversized source or a per-source timeout in a module package returned no Evidence. Skip a source that exceeds its worker limits and end discovery on any other failure except cancellation; source analysis then meets the same source, records its failure and keeps partial Evidence as it does without discovery. * fix(javascript): report preload discovery gaps in Evidence limitations When preload discovery skipped a source or stopped early, source analysis could still read that source, so the result looked complete while a .js preload declared there kept its package scope. Return each discovery gap with the discovered paths and add it to the analysis limitations, which both complete and partial Evidence carry.
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
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, type SharedMcpCall } 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(
|
|
"names the server deadline control in CLI timeout recovery",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-js-cli-timeout-recovery-");
|
|
await writeFile(join(root, "main.js"), "export const observed = 7;\n");
|
|
const response = await cli.run({
|
|
arguments: [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--max-heap-mb",
|
|
"128",
|
|
"--analysis-timeout-ms",
|
|
"1",
|
|
"--format",
|
|
"json",
|
|
],
|
|
environment: { NODE_OPTIONS: "--max-old-space-size=512" },
|
|
});
|
|
expect(response.exitCode).not.toBe(0);
|
|
expect(response.json).toMatchObject({
|
|
error: "JavaScript application analysis failed",
|
|
code: "provider_timeout",
|
|
message: expect.stringContaining("analysis_timeout_ms"),
|
|
remediation: {
|
|
action: expect.stringContaining(
|
|
"rea analyze-javascript-application <input_path> --analysis-timeout-ms",
|
|
),
|
|
},
|
|
recovery: { kind: "inspect_timeout_state", timeout_ms: 1 },
|
|
});
|
|
},
|
|
);
|
|
|
|
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 { call } = await connectLocalToolsMcp({
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
});
|
|
const expected = new Map<string, unknown>();
|
|
for (const { controls } of cases) {
|
|
const response = await call("analyze_javascript_application", {
|
|
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(call);
|
|
for (const [evidence_id, parameters] of expected)
|
|
expect(
|
|
retained.records.find((record) => record.evidence_id === evidence_id)
|
|
?.parameters,
|
|
).toEqual(parameters);
|
|
const exported = await call("export_evidence_bundle", { path });
|
|
expect(exported.isError).not.toBe(true);
|
|
expect(parseEvidenceBundle(JSON.parse(await readFile(path, "utf8")))).toEqual(
|
|
retained,
|
|
);
|
|
const closed = await call("close_binary", {});
|
|
expect(closed.isError).not.toBe(true);
|
|
expect(await getBundle(call)).toEqual(retained);
|
|
const reset = await call("reset_investigation", {});
|
|
expect(reset.isError).not.toBe(true);
|
|
expect((await getBundle(call)).records).toEqual([]);
|
|
const imported = await call("import_evidence_bundle", { path });
|
|
expect(imported.isError, JSON.stringify(imported.content)).not.toBe(true);
|
|
expect(await getBundle(call)).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, call } = await connectLocalToolsMcp({
|
|
NODE_OPTIONS: "--max-old-space-size=512",
|
|
});
|
|
const response = await call("analyze_javascript_application", {
|
|
input_path: root,
|
|
max_heap_mb: 128,
|
|
analysis_timeout_ms: 1,
|
|
source_paths: sourcePaths,
|
|
});
|
|
expect(parseMcpToolError(response)).toMatchObject({
|
|
error: {
|
|
code: "provider_timeout",
|
|
message: expect.stringContaining("server/provider deadline"),
|
|
remediation: {
|
|
action: expect.stringContaining("MCP client"),
|
|
},
|
|
recovery: {
|
|
kind: "inspect_timeout_state",
|
|
operation: "analyze_javascript_application",
|
|
timeout_ms: 1,
|
|
},
|
|
},
|
|
});
|
|
const bundle = await getBundle(call);
|
|
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 call("analyze_javascript_application", {
|
|
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,
|
|
);
|
|
|
|
cliTest(
|
|
"keeps partial Evidence when a module package source times out",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-js-limit-module-timeout-");
|
|
await Promise.all([
|
|
writeFile(join(root, "package.json"), '{"type":"module"}'),
|
|
writeFile(join(root, "a.js"), "export const observed = 7;\n"),
|
|
]);
|
|
// A module package with .js sources runs preload discovery first, which
|
|
// must leave the timeout to the source pass that retains partial facts.
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"analyze-javascript-application",
|
|
root,
|
|
"--analysis-timeout-ms",
|
|
"1",
|
|
"--format",
|
|
"json",
|
|
],
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
const failure = z
|
|
.object({
|
|
code: z.literal("provider_timeout"),
|
|
details: z.object({ partial_observation: z.unknown() }),
|
|
})
|
|
.parse(result.json);
|
|
const partial = parseEvidence(failure.details.partial_observation);
|
|
expect(partial.operation).toBe("analyze_javascript_application");
|
|
expect(partial.limitations).toContainEqual(
|
|
expect.stringMatching(/^Preload discovery could not read a\.js \(/u),
|
|
);
|
|
},
|
|
);
|
|
|
|
async function getBundle(call: SharedMcpCall) {
|
|
const response = await call("get_evidence_bundle", { 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);
|
|
}
|