fix(javascript): bound semantic node projection for resource safety (#966)

* fix(javascript): bound semantic node projection for resource safety

analyze_javascript_application exhausted the heap or failed serialization on
ordinary inputs. A small extracted tree crashed outright, and a larger
application tree failed at both the default heap and an 8 GB heap.

Cause: the semantic projection emits one node per AST expression, call site,
binding and property slot, with no ceiling. A five-file tree already yields on
the order of 170,000 nodes, and the whole result is serialized after the object
graph is built and validated. Result ceilings were deliberately removed upstream,
so nothing bounded this growth.

Apply two resource-safety ceilings and report them through the graph's own
coverage contract rather than dropping data silently:

- SEMANTIC_GRAPH_NODE_CEILING bounds retained nodes per tree (100,000, roughly
  300 MB serialized, below V8's 512 MB string limit).
- SEMANTIC_GRAPH_FILE_NODE_CEILING bounds each source file (20,000) so one
  bundled library cannot starve the application's own modules.

Exhausting either marks coverage truncated with a partial status and records the
ceiling in coverage.limits. A small application stays complete and untruncated.

Verified: a previously-crashing application tree now returns Evidence in under a
minute, while a small tree stays complete. Existing tests and check:fast pass.

* fix(javascript): report semantic graph truncation accurately

---------

Co-authored-by: REA Contributor <contributor@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 01:22:10 +08:00
1 parent b4fc0ecaa1
commit 96a46ada0a
4 files changed
+228 -8

No files matched your search

@@ -1,6 +1,15 @@
import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import {
buildJavaScriptSemanticGraph,
SEMANTIC_GRAPH_FILE_NODE_CEILING,
SEMANTIC_GRAPH_NODE_CEILING,
} from "./JavaScriptSemanticGraphBuilder.js";
import {
addSemanticGraphNode,
constructSemanticGraphNode,
createSemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
@@ -287,6 +296,31 @@ const graphFor = (source: string) => {
});
};
const semanticNodeFor = (roleKey: string) => {
const file: JavaScriptArtifactFile = {
path: "budget.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: 0,
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
};
const state = createSemanticGraphProjectionState({ nodes: [] });
return constructSemanticGraphNode(
file,
{
kind: "expression",
roleKey,
location: null,
label: roleKey,
functionNodeId: null,
},
state,
);
};
it.each([
"const value = consume(Promise.resolve(1));",
"async function run() { await consume(Promise.resolve(1)); }",
@@ -428,3 +462,75 @@ it.each([
expect(query.relations).toContainEqual(relation);
expect(query.nodes.map(({ node_id }) => node_id)).toContain(promise?.node_id);
});
it("bounds semantic node projection and reports the ceiling in coverage", () => {
// A bundled vendor library can emit tens of thousands of semantic nodes on its
// own (measured: 135,286 from one minified editor bundle). Without a ceiling the
// projection exhausts the heap and the result cannot be serialized. The bound
// must be reported through coverage rather than dropping data silently.
const statements = Array.from(
{ length: 4_000 },
(_, index) =>
`const value${index} = { a: ${index}, b: [${index}, ${index + 1}] };`,
).join("\n");
const graph = graphFor(statements);
expect(graph.nodes.length).toBeLessThanOrEqual(SEMANTIC_GRAPH_NODE_CEILING);
expect(graph.coverage.truncated).toBe(true);
expect(graph.coverage.status).toBe("partial");
expect(graph.coverage.omitted_nodes).toBeNull();
expect(graph.coverage.omitted_relations).toBeNull();
expect(
graph.coverage.families.every(
({ omitted_relations }) => omitted_relations === null,
),
).toBe(true);
expect(
graph.coverage.limits.some(
({ name }) => name === "semantic_graph_node_ceiling",
),
).toBe(true);
});
it("leaves a small application complete and untruncated", () => {
const graph = graphFor("const answer = 40 + 2;");
expect(graph.coverage.truncated).toBe(false);
expect(graph.coverage.omitted_nodes).toBe(0);
expect(graph.coverage.omitted_relations).toBe(0);
expect(graph.coverage.limits).toEqual([]);
});
it("does not mark a file truncated when it exactly fills its budget", () => {
// Each unbound reference projects one expression, plus the module root.
const graph = graphFor(
"external;\n".repeat(SEMANTIC_GRAPH_FILE_NODE_CEILING - 1),
);
expect(graph.nodes).toHaveLength(SEMANTIC_GRAPH_FILE_NODE_CEILING);
expect(graph.coverage).toMatchObject({
truncated: false,
omitted_nodes: 0,
omitted_relations: 0,
limits: [],
});
});
it("only records node loss when the budget rejects a new identity", () => {
// The truncation signal must record blocked node creation, not a zero
// remaining budget: a file using exactly its share drops nothing.
const state = createSemanticGraphProjectionState({ nodes: [] });
state.fileNodeBudget = 2;
state.fileNodesDropped = false;
const first = addSemanticGraphNode(state, semanticNodeFor("first"));
const second = addSemanticGraphNode(state, semanticNodeFor("second"));
expect(first).not.toBeNull();
expect(second).not.toBeNull();
expect(state.fileNodeBudget).toBe(0);
expect(state.fileNodesDropped).toBe(false);
expect(addSemanticGraphNode(state, semanticNodeFor("first"))).toBe(first);
expect(state.fileNodesDropped).toBe(false);
const blocked = addSemanticGraphNode(state, semanticNodeFor("third"));
expect(blocked).toBeNull();
expect(state.fileNodesDropped).toBe(true);
});