Files
rea/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts
Kaoru0822andClaude Opus 5.5 4a01211f11 fix(javascript): keep static facts after semantic results use the parent budget (#8648)
Static checkpoints were admitted against the same budget as semantic and
application results, half of the parent's free heap. Once those charges used
it, the next checkpoint stopped the analysis and every remaining source lost
its static facts, with most of the parent heap still free.

Semantic and application results still stop at that budget. Static checkpoints
may now continue to three quarters of the free heap while the live heap also
has room for another copy of what it holds. Without that room, admission falls
back to the result budget. The live condition is needed because the limit V8
reports counts young-generation capacity that retained objects cannot use:
without it, the parent aborted on heap exhaustion at small heap limits.

Reserved relationships are given up only when the larger share runs short. A
stop still fails with a typed error, which now reports the limit that applied,
what was available, the live heap in use and what the checkpoint needed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:16:16 -08:00

244 lines
9.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const assertRetainedMemoryFailure = (value: unknown, path: string): void => {
const parsed = z
.object({
code: z.literal("resource_constraint"),
message: z.string(),
details: z.object({
operation: z.literal("analyze_javascript_application"),
resource: z.literal("memory"),
reported_limits: z.record(z.string(), z.unknown()),
}),
remediation: z.object({ action: z.string() }),
})
.safeParse(value);
if (!parsed.success)
throw new Error(
`Expected a typed memory constraint for ${path}; got ${JSON.stringify(value)}`,
{ cause: parsed.error },
);
const failure = parsed.data;
const limits = failure.details.reported_limits;
const graphAdmission = z
.object({
parent_available_result_budget_bytes: z.number(),
module_retention_estimate_bytes: z.number(),
graph_retention_estimate_bytes: z.number(),
})
.safeParse(limits);
if (graphAdmission.success) {
expect(
graphAdmission.data.module_retention_estimate_bytes +
graphAdmission.data.graph_retention_estimate_bytes,
).toBeGreaterThan(graphAdmission.data.parent_available_result_budget_bytes);
expect(failure.message).toContain(
`Semantic graph for ${path} exceeds the parent's remaining result budget`,
);
expect(failure.remediation.action).toContain("parent result budget");
expect(failure.remediation.action).toContain(
"retained static and module facts",
);
return;
}
};
cliTest.skipIf(process.versions.bun !== undefined).for([
{ laterSources: 1, heapMb: 640, methodsCount: 400, retainGraph: true },
{ laterSources: 10, heapMb: 640, methodsCount: 400, retainGraph: true },
// Exercise retained evidence and later checkpoints under real heap pressure.
// The controlled worker test proves graph-only admission independently of GC.
{ laterSources: 1, heapMb: 448, methodsCount: 600, retainGraph: false },
])(
"preserves evidence with $laterSources later checkpoints and a $heapMb MiB parent heap",
{ timeout: 90_000 },
async ({ laterSources, heapMb, methodsCount, retainGraph }, { cli }) => {
const root = await createTestTempDirectory("rea-js-checkpoint-reserve-");
const source = Array.from(
{ length: methodsCount },
(_, index) =>
`export function method_${String(index)}(input) { const value = input + ${String(index)}; const pair = { value }; return pair.value; }`,
).join("\n");
const files: readonly (readonly [string, string])[] = [
["a-large.mjs", source],
...Array.from(
{ length: laterSources },
(_, index) =>
[
`z-tiny-${String(index)}.mjs`,
`export const tiny${String(index)} = 1;\n`,
] as const,
),
];
for (const [name, text] of files) await writeFile(join(root, name), text);
const response = await cli.run({
arguments: ["analyze", root, "--format", "json"],
environment: {
NODE_OPTIONS: `--max-old-space-size=${String(heapMb)} --max-semi-space-size=8`,
},
timeoutMs: 60_000,
});
expect(response.exitCode).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
expect(result.statistics.parsed_javascript_files).toBe(laterSources + 1);
expect(result.statistics.parse_failures).toBe(0);
const failures = result.graph.nodes.flatMap((node) =>
node.observations.flatMap(({ properties }) =>
properties.analysis_failure === undefined
? []
: [
{
kind: node.kind,
identity: node.identity,
path: properties.path,
failure: properties.analysis_failure,
},
],
),
);
const methods = result.semantic_graph.nodes.filter(
({ kind, identity }) =>
kind === "function" && identity.module_path === "a-large.mjs",
);
if (retainGraph)
expect(methods.map(({ label }) => label).sort()).toEqual(
Array.from(
{ length: methodsCount },
(_, index) => `method_${String(index)}`,
).sort(),
);
else {
expect(methods).toHaveLength(0);
expect([
result.graph.coverage.status,
result.semantic_graph.coverage.status,
]).toContain("partial");
}
expect(
result.semantic_graph.nodes.some(
({ identity, label }) =>
identity.module_path === `z-tiny-${String(laterSources - 1)}.mjs` &&
label === `tiny${String(laterSources - 1)}`,
),
).toBe(true);
for (const [name, text] of files) {
const digest = createHash("sha256").update(text).digest("hex");
const node = result.graph.nodes.find(
({ kind, identity }) =>
kind === "javascript-asset" &&
identity.strategy === "content-digest" &&
identity.sha256 === digest,
);
expect(node?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
path: name,
parse_status: "complete",
}),
}),
]),
);
if (retainGraph) expect(failures).toEqual([]);
expect(await readFile(join(root, name), "utf8")).toBe(text);
if (!retainGraph && name === "a-large.mjs") {
const failureRecord = failures.find(
({ kind, identity, path }) =>
kind === "unknown" &&
path === name &&
identity.strategy === "artifact-local-key" &&
identity.artifact_sha256 === digest &&
identity.namespace === "static-parse-scope" &&
identity.key === `analyze-javascript-source:${name}`,
);
if (failureRecord === undefined)
throw new Error(`Missing analysis failure for ${name}`);
assertRetainedMemoryFailure(failureRecord.failure, name);
}
}
expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu);
},
);
cliTest.skipIf(process.versions.bun !== undefined)(
"keeps every source's static facts after semantic results use the parent result budget",
{ timeout: 90_000 },
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-static-capacity-");
// Plain functions produce semantic graphs large enough to use the result
// budget of this heap. The later sources' storage findings then produce
// static checkpoints that exceed its reserve for the next checkpoint.
const vendors = Array.from({ length: 12 }, (_, vendor) => [
`a-vendor-${String(vendor)}.mjs`,
Array.from(
{ length: 100 },
(_, index) =>
`export function v${String(vendor)}_${String(index)}(input) { const value = input + ${String(index)}; const pair = { value }; return pair.value; }`,
).join("\n"),
]);
const callsPerSource = 12_000;
const sources = Array.from({ length: 5 }, (_, source) => [
`z-source-${String(source)}.mjs`,
Array.from(
{ length: callsPerSource },
(_, index) =>
`localStorage.getItem("k${String(source)}_${String(index)}");`,
).join("\n"),
]);
for (const [name, text] of [...vendors, ...sources])
await writeFile(join(root, name!), text!);
const response = await cli.run({
arguments: ["analyze", root, "--format", "json"],
environment: {
NODE_OPTIONS: "--max-old-space-size=640 --max-semi-space-size=8",
},
timeoutMs: 75_000,
});
expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu);
expect(
response.exitCode,
JSON.stringify(response.json)?.slice(0, 2000),
).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(response.json).normalized_result,
);
expect(result.statistics).toMatchObject({
parsed_javascript_files: vendors.length + sources.length,
skipped_javascript_files: 0,
parse_failures: 0,
});
// The omission is reported where a reader finds the retained facts.
for (const [name, text] of sources) {
const digest = createHash("sha256").update(text!).digest("hex");
const node = result.graph.nodes.find(
({ kind, identity }) =>
kind === "javascript-asset" &&
identity.strategy === "content-digest" &&
identity.sha256 === digest,
);
const observation = node?.observations.find(
({ properties }) =>
properties.application_projection_failure !== undefined,
);
expect(observation?.label, name).toBe(name);
expect(
z
.object({ storage: z.array(z.unknown()) })
.parse(observation?.properties.static_analysis).storage,
).toHaveLength(callsPerSource);
}
expect(result.graph.coverage.status).toBe("partial");
},
);