fix(javascript): trace wrapped Electron IPC paths in large bundles (#1874)

Co-authored-by: David <17119984+fengdwx@users.noreply.github.com>
This commit is contained in:
DavidandDavid authored and GitHub committed 2026-10-12 09:28:59 +08:00
1 parent d93fbda390
commit 87b9793c53
46 files changed
+3213 -355

No files matched your search

@@ -221,4 +221,4 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => {
};
const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string =>
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}`;
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`;
@@ -2,6 +2,7 @@ import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphCo
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js";
import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
/** Add every static Electron process and security-boundary fact to the JAG. */
export const addElectronBoundaries = (
@@ -10,4 +11,5 @@ export const addElectronBoundaries = (
addElectronWindowBoundaries(context);
addElectronIpcBoundaries(context);
addElectronNativeBoundaries(context);
addElectronCallPaths(context);
};
@@ -0,0 +1,179 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
type ElectronIpcRecord,
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronInferenceEdge,
electronFindingSourceNode,
electronRangeKey,
} from "./ElectronBoundaryGraphContext.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js";
interface ProjectedPath {
readonly file: JavaScriptArtifactFile;
readonly path: ElectronCallPath;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly nodes: readonly ApplicationNode[];
}
/** Retain context-specific wrapper paths, then join only unique IPC records. */
export const addElectronCallPaths = (
context: JavaScriptArtifactGraphContext,
): void => {
const projected: ProjectedPath[] = [];
for (const { file, javascript } of context.analysis.files) {
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const path of javascript.electron.call_paths ?? []) {
const pathId = sha256Text(JSON.stringify(path));
const nodes = path.steps.map((step, index) =>
context.accumulator.addNode({
kind: "javascript-expression",
identity: artifactLocalIdentity(
file.sha256,
"electron-call-path-step",
`${pathId}:${String(index)}`,
),
observations: [
{
label: step.label,
properties: {
path_id: pathId,
entry: path.entry,
entry_kind: path.entry_kind,
channel: path.channel,
terminal: path.terminal,
step_kind: step.kind,
step_index: index,
module_key: step.module_key,
module_runtime: step.module_runtime,
conditions: path.conditions,
resolution: "candidate",
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: step.location,
operation: "substitute-electron-wrapper",
coverage,
limitations: [
"Lexical wrapper substitution is a static candidate; guards, runtime overrides and execution remain unverified.",
],
}),
},
],
}),
);
const first = nodes[0],
entry = path.steps[0];
const owner = entry
? electronFindingSourceNode(
context,
file,
entry.module_key,
entry.module_runtime ?? undefined,
)
: undefined;
if (first && entry && owner)
addElectronInferenceEdge(context, {
source: owner,
target: first,
file,
range: entry.location,
coverage,
relation: "contains",
operation: "locate-electron-wrapper-path",
properties: {
path_id: pathId,
entry: path.entry,
resolution: "candidate",
},
});
for (let i = 1; i < nodes.length; i += 1) {
const source = nodes[i - 1],
target = nodes[i],
previous = path.steps[i - 1],
step = path.steps[i];
if (!source || !target || !previous || !step) continue;
addElectronInferenceEdge(context, {
source,
target,
file,
range: step.location,
coverage,
relation: ["exposure", "factory", "ipc"].includes(previous.kind)
? "maps_to"
: "calls",
operation: "trace-electron-wrapper-path",
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
resolution: "candidate",
conditions: path.conditions,
},
});
}
projected.push({ file, path, coverage, nodes });
}
}
for (const { transmission, handler } of unambiguousElectronIpcPairings(
collectElectronIpcRecords(context.analysis),
)) {
const sources = projected.filter(
(p) => p.path.entry_kind === "bridge-member" && matches(p, transmission),
);
const targets = projected.filter(
(p) => p.path.entry_kind === "ipc-handler" && matches(p, handler),
);
for (const from of sources)
for (const to of targets) {
const source = from.nodes.at(-1),
target = to.nodes[0];
if (!source || !target) continue;
addElectronInferenceEdge(context, {
source,
target,
file: from.file,
range: transmission.finding.location,
coverage: from.coverage,
relation:
transmission.finding.mode === "invoke" ? "invokes" : "sends",
operation: "join-electron-wrapper-paths",
properties: {
channel: transmission.finding.channel,
pairing_basis: "unique-exact-literal-channel",
resolution: "candidate",
handler_path: to.file.path,
conditions: to.path.conditions,
},
limitations: [
"The unique static channel match does not establish runtime registration, sender acceptance, or dispatch.",
],
});
}
}
};
const matches = (
projected: ProjectedPath,
record: ElectronIpcRecord,
): boolean =>
projected.file.path === record.file.path &&
projected.path.channel === record.finding.channel &&
projected.path.steps.some(
(step) =>
electronRangeKey(step.location) ===
electronRangeKey(record.finding.location),
);
@@ -27,6 +27,7 @@ export interface ElectronGraphEdgeInput {
| "exposes"
| "sends"
| "invokes"
| "calls"
| "handles";
readonly operation: string;
readonly properties: Readonly<Record<string, unknown>>;
@@ -7,7 +7,10 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
@@ -120,11 +123,18 @@ const addChannelNode = (
: `literal:${finding.channel}`;
return context.accumulator.addNode({
kind: "ipc-channel",
identity: electronObservationIdentity(
context,
"electron-ipc-channel",
identityKey,
),
identity:
finding.resolution_basis === "wrapper-substitution"
? artifactLocalIdentity(
file.sha256,
"electron-inferred-ipc-channel",
identityKey,
)
: electronObservationIdentity(
context,
"electron-ipc-channel",
identityKey,
),
observations: [
{
label: finding.channel ?? finding.channel_expression,
@@ -136,18 +146,24 @@ const addChannelNode = (
channel_expression: finding.channel_expression,
resolution: finding.channel === null ? "dynamic" : "literal",
},
evidence: astObservationEvidence({
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations:
finding.channel === null
finding.resolution_basis === "wrapper-substitution"
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
"The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.",
]
: [],
: finding.channel === null
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]
: [],
}),
},
],
@@ -177,7 +193,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
handler_kind: finding.handler_kind,
handler_location: finding.handler_location,
},
evidence: astObservationEvidence({
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: handlerRange,
@@ -13,7 +13,10 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronAstEdge,
addElectronInferenceEdge,
@@ -190,8 +193,11 @@ const addContextBridge = (
api_status: value.api_status,
members: value.members,
unknown_members: value.unknown_members,
resolution_basis: value.resolution_basis ?? "direct-syntax",
},
evidence: astObservationEvidence({
evidence: (value.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: value.location,
@@ -91,6 +91,12 @@ const parameters = (
): EvidenceObservation["parameters"] => ({
format: input.format,
integrity_policy: input.integrity_policy,
...(input.max_semantic_nodes === undefined
? {}
: { max_semantic_nodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
@@ -66,6 +66,12 @@ export const analyzeJavaScriptApplicationValidated = async (
input_path: input.input_path,
format: input.format,
integrity_policy: input.integrity_policy,
...(input.max_semantic_nodes === undefined
? {}
: { max_semantic_nodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
@@ -139,7 +139,14 @@ export const reconstructJavaScriptArtifact = async (
"parse_javascript_sources",
`Parsing and projecting ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application source files`,
);
const semanticProjection = createJavaScriptSemanticGraphProjection();
const semanticProjection = createJavaScriptSemanticGraphProjection({
...(input.max_semantic_nodes === undefined
? {}
: { totalNodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const worker = createJavaScriptSourceAnalysis(
{
heapMb:
@@ -6,6 +6,8 @@ import { expect, it } from "vitest";
import {
SEMANTIC_GRAPH_FILE_NODE_CEILING,
SEMANTIC_GRAPH_NODE_CEILING,
createJavaScriptSemanticGraphProjection,
projectJavaScriptSemanticFileSteps,
} from "./JavaScriptSemanticGraphBuilder.js";
import {
addSemanticGraphNode,
@@ -905,3 +907,48 @@ it.each(["{}", "unknownRoot", "{ a: null }"])(
expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]);
},
);
it("projects and adopts more than the default per-file capacity when explicitly offered", () => {
const text = "external;\n".repeat(20_001);
const file: JavaScriptArtifactFile = {
path: "large.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: Buffer.byteLength(text),
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: text },
};
const steps = projectJavaScriptSemanticFileSteps(
file,
analyzeJavaScriptSemantics(text),
20_002,
);
let result = steps.next();
while (!result.done) result = steps.next();
expect(result.value.nodes).toHaveLength(20_002);
expect(result.value.truncated).toBe(false);
const projection = createJavaScriptSemanticGraphProjection({
totalNodes: 20_010,
fileNodes: 20_002,
});
projection.adoptFileProjection(result.value);
expect(projection.remainingFileNodeBudget()).toBe(8);
const graph = projection.finish(SHA256, {
graph_id: `jag_${"b".repeat(64)}`,
nodes: [],
});
expect(graph.coverage).toMatchObject({
truncated: false,
omitted_nodes: 0,
omitted_relations: 0,
});
const bounded = createJavaScriptSemanticGraphProjection({
totalNodes: 20_001,
fileNodes: 20_002,
});
expect(() => bounded.adoptFileProjection(result.value)).toThrow(
"exceed the offered tree capacity",
);
});
@@ -41,7 +41,11 @@ import {
type ApplicationCoverage,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { javaScriptAnalysisCoverageLimits } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
JAVASCRIPT_SEMANTIC_NODE_MAXIMUM,
javaScriptAnalysisCoverageLimits,
} from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js";
import {
projectSemanticClosureCaptures,
@@ -65,6 +69,8 @@ import {
type SemanticGraphProjectionState as BuilderState,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import { projectElectronSemanticCallPaths } from "./JavaScriptSemanticGraphElectronProjection.js";
interface BuilderInput {
readonly rootArtifactSha256: string;
@@ -105,13 +111,13 @@ interface FileContext extends SemanticFlowProjectionContext {
* (`truncated`, `omitted_nodes`, `omitted_relations`, `limits`) rather than
* silently dropping data.
*
* Sizing: a retained semantic node costs roughly 3 KB once serialized, and V8
* additionally caps a single string at 512 MB. The result is serialized after the
* object graph is built and validated, so both phases must fit together. A single
* minified vendor bundle can account for most of a tree's nodes, so a whole-tree
* ceiling without a per-file ceiling does not bound the work. Both are applied.
* Defaults bound work even when ample heap is available. Callers may raise the
* capacities explicitly; isolated worker heap, bounded record transport and
* parent result admission independently constrain actual retained memory. The
* result uses incremental serialization rather than a single JSON string.
*/
export const SEMANTIC_GRAPH_NODE_CEILING = 100_000;
export const SEMANTIC_GRAPH_NODE_CEILING =
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodes;
/**
* Per-file share of the semantic node budget.
@@ -119,7 +125,8 @@ export const SEMANTIC_GRAPH_NODE_CEILING = 100_000;
* Bound each source file independently so one large bundled library cannot
* consume the whole tree's budget and starve the application's own modules.
*/
export const SEMANTIC_GRAPH_FILE_NODE_CEILING = 20_000;
export const SEMANTIC_GRAPH_FILE_NODE_CEILING =
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile;
export const buildJavaScriptSemanticGraph = ({
rootArtifactSha256,
@@ -129,7 +136,11 @@ export const buildJavaScriptSemanticGraph = ({
const projection = createJavaScriptSemanticGraphProjection();
for (const analyzed of analysis.files) {
if (analyzed.semantic !== null)
projection.projectFile(analyzed.file, analyzed.semantic.ir);
projection.projectFile(
analyzed.file,
analyzed.semantic.ir,
analyzed.javascript?.electron.call_paths,
);
else if (
!analyzed.file.text.included &&
analyzed.file.text.reason === "outside-analysis-scope"
@@ -162,11 +173,13 @@ export interface JavaScriptSemanticGraphProjection {
readonly projectFile: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths?: readonly ElectronCallPath[],
) => void;
/** Project one file in steps between its projection passes. */
readonly projectFileSteps: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths?: readonly ElectronCallPath[],
) => Generator<void, void>;
readonly finish: (
rootArtifactSha256: string,
@@ -179,234 +192,241 @@ export interface JavaScriptSemanticGraphProjection {
}
/** Accumulate semantic nodes while each source IR is still file-local. */
export const createJavaScriptSemanticGraphProjection =
(): JavaScriptSemanticGraphProjection => {
const state = emptyState();
const fingerprints: JavaScriptSemanticFingerprint[] = [];
let truncatedFiles = 0;
let unavailableFiles = 0;
let resourceTruncatedFiles = 0;
const executionLimits = new Map<
string,
ApplicationCoverage["limits"][number]
>();
const projectSourceSteps = function* (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
): Generator<void, void> {
if (state.nodes.size >= SEMANTIC_GRAPH_NODE_CEILING) {
truncatedFiles += 1;
return;
}
const remainingTreeBudget =
SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size;
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
remainingTreeBudget,
);
state.fileNodesDropped = false;
fingerprints.push(...(yield* projectFileSteps(file, ir, state)));
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
};
const finishWith = <Value>(
factory: (input: unknown) => Value,
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
): Value => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const relations = [...state.relations.values()];
const unknowns = [...state.unknowns.values()];
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
const retainedByFamily = new Map<Family, number>();
const unknownIdsByFamily = new Map<Family, string[]>();
for (const relation of relations) {
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
}
for (const unknown of unknowns) {
const identifiers = unknownIdsByFamily.get(unknown.family);
if (identifiers === undefined)
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
else identifiers.push(unknown.unknown_id);
}
const graph = factory({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
evidence_contexts: state.evidenceContexts.contexts,
nodes: [...state.nodes.values()],
relations,
// The input is transferred to the factory; `fingerprints` is cleared below.
fingerprints: [...fingerprints],
unknowns,
coverage: {
status:
truncatedFiles > 0 || unavailableFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0 || resourceTruncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
export const createJavaScriptSemanticGraphProjection = (
limits: { readonly totalNodes?: number; readonly fileNodes?: number } = {},
): JavaScriptSemanticGraphProjection => {
const nodeCeiling = limits.totalNodes ?? SEMANTIC_GRAPH_NODE_CEILING;
const fileNodeCeiling = limits.fileNodes ?? SEMANTIC_GRAPH_FILE_NODE_CEILING;
for (const value of [nodeCeiling, fileNodeCeiling])
if (
!Number.isSafeInteger(value) ||
value < 1 ||
value > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM
)
throw new RangeError("Semantic graph requires valid node capacities");
const state = emptyState();
const fingerprints: JavaScriptSemanticFingerprint[] = [];
let truncatedFiles = 0;
let unavailableFiles = 0;
let resourceTruncatedFiles = 0;
const executionLimits = new Map<
string,
ApplicationCoverage["limits"][number]
>();
const projectSourceSteps = function* (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths: readonly ElectronCallPath[] = [],
): Generator<void, void> {
if (state.nodes.size >= nodeCeiling) {
truncatedFiles += 1;
return;
}
const remainingTreeBudget = nodeCeiling - state.nodes.size;
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
state.fileNodesDropped = false;
fingerprints.push(...(yield* projectFileSteps(file, ir, state, paths)));
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
};
const finishWith = <Value>(
factory: (input: unknown) => Value,
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
): Value => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const relations = [...state.relations.values()];
const unknowns = [...state.unknowns.values()];
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
const retainedByFamily = new Map<Family, number>();
const unknownIdsByFamily = new Map<Family, string[]>();
for (const relation of relations) {
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
}
for (const unknown of unknowns) {
const identifiers = unknownIdsByFamily.get(unknown.family);
if (identifiers === undefined)
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
else identifiers.push(unknown.unknown_id);
}
const graph = factory({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
evidence_contexts: state.evidenceContexts.contexts,
nodes: [...state.nodes.values()],
relations,
// The input is transferred to the factory; `fingerprints` is cleared below.
fingerprints: [...fingerprints],
unknowns,
coverage: {
status:
truncatedFiles > 0 || unavailableFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0 || resourceTruncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
limits: [
...executionLimits.values(),
...(truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: nodeCeiling,
unit: "items" as const,
},
{
name: "semantic_graph_file_node_ceiling",
value: fileNodeCeiling,
unit: "items" as const,
},
]
: []),
],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family),
retained_relations: retainedByFamily.get(family) ?? 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
limits: [
...executionLimits.values(),
...(truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: SEMANTIC_GRAPH_NODE_CEILING,
unit: "items" as const,
},
]
: []),
],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family),
retained_relations: retainedByFamily.get(family) ?? 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
unknown_ids: unknownIdsByFamily.get(family) ?? [],
})),
},
limitations: [
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
],
});
state.nodes.clear();
state.relations.clear();
state.unknowns.clear();
state.roots.clear();
state.evidenceContexts.clear();
fingerprints.length = 0;
return graph;
};
return {
remainingFileNodeBudget: () =>
Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
Math.max(0, SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size),
),
adoptFileProjection: (projection) => {
const newNodes = projection.nodes.filter(
({ node_id }) => !state.nodes.has(node_id),
);
if (
newNodes.length >
Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size,
)
)
throw new RangeError(
"Transferred semantic nodes exceed the offered tree capacity",
);
for (const context of projection.evidenceContexts)
state.evidenceContexts.adopt(context);
for (const node of newNodes) state.nodes.set(node.node_id, node);
for (const relation of projection.relations)
state.relations.set(relation.relation_id, relation);
for (const unknown of projection.unknowns)
state.unknowns.set(unknown.unknown_id, unknown);
for (const root of projection.roots) state.roots.add(root);
fingerprints.push(...projection.fingerprints);
if (projection.truncated) truncatedFiles += 1;
unknown_ids: unknownIdsByFamily.get(family) ?? [],
})),
},
recordUnavailableFile: (file, reason, limits, unknownReason) => {
const retainedLimits = javaScriptAnalysisCoverageLimits(limits);
for (const limit of retainedLimits) {
executionLimits.set(
JSON.stringify([limit.name, limit.value, limit.unit]),
limit,
);
}
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
Math.max(0, SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
state: "unknown" as const,
confidence: "unknown" as const,
coverage: partialApplicationCoverage(retainedLimits, null),
limitations: [reason],
};
for (const family of JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) {
const unknown = createJavaScriptSemanticGraphUnknown(
{
node_id: module?.node_id ?? null,
family,
relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family,
),
reason: unknownReason,
detail: reason,
candidate_node_ids: [],
evidence,
},
state.evidenceContexts,
);
state.unknowns.set(unknown.unknown_id, unknown);
}
unavailableFiles += 1;
if (unknownReason === "resource-limit" && retainedLimits.length > 0)
resourceTruncatedFiles += 1;
},
projectFile: (file, ir) => {
const steps = projectSourceSteps(file, ir);
while (steps.next().done !== true);
},
projectFileSteps: projectSourceSteps,
finish: (rootArtifactSha256, applicationGraph) =>
finishWith(
createJavaScriptSemanticGraph,
rootArtifactSha256,
applicationGraph,
),
finishImmutableSteps: (rootArtifactSha256, applicationGraph) =>
finishWith(
sealTransferredJavaScriptSemanticGraphSteps,
rootArtifactSha256,
applicationGraph,
),
};
limitations: [
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
],
});
state.nodes.clear();
state.relations.clear();
state.unknowns.clear();
state.roots.clear();
state.evidenceContexts.clear();
fingerprints.length = 0;
return graph;
};
return {
remainingFileNodeBudget: () =>
Math.min(fileNodeCeiling, Math.max(0, nodeCeiling - state.nodes.size)),
adoptFileProjection: (projection) => {
const newNodes = projection.nodes.filter(
({ node_id }) => !state.nodes.has(node_id),
);
if (
newNodes.length >
Math.min(fileNodeCeiling, nodeCeiling - state.nodes.size)
)
throw new RangeError(
"Transferred semantic nodes exceed the offered tree capacity",
);
for (const context of projection.evidenceContexts)
state.evidenceContexts.adopt(context);
for (const node of newNodes) state.nodes.set(node.node_id, node);
for (const relation of projection.relations)
state.relations.set(relation.relation_id, relation);
for (const unknown of projection.unknowns)
state.unknowns.set(unknown.unknown_id, unknown);
for (const root of projection.roots) state.roots.add(root);
fingerprints.push(...projection.fingerprints);
if (projection.truncated) truncatedFiles += 1;
},
recordUnavailableFile: (file, reason, limits, unknownReason) => {
const retainedLimits = javaScriptAnalysisCoverageLimits(limits);
for (const limit of retainedLimits) {
executionLimits.set(
JSON.stringify([limit.name, limit.value, limit.unit]),
limit,
);
}
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
state: "unknown" as const,
confidence: "unknown" as const,
coverage: partialApplicationCoverage(retainedLimits, null),
limitations: [reason],
};
for (const family of JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) {
const unknown = createJavaScriptSemanticGraphUnknown(
{
node_id: module?.node_id ?? null,
family,
relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family,
),
reason: unknownReason,
detail: reason,
candidate_node_ids: [],
evidence,
},
state.evidenceContexts,
);
state.unknowns.set(unknown.unknown_id, unknown);
}
unavailableFiles += 1;
if (unknownReason === "resource-limit" && retainedLimits.length > 0)
resourceTruncatedFiles += 1;
},
projectFile: (file, ir, paths) => {
const steps = projectSourceSteps(file, ir, paths);
while (steps.next().done !== true);
},
projectFileSteps: projectSourceSteps,
finish: (rootArtifactSha256, applicationGraph) =>
finishWith(
createJavaScriptSemanticGraph,
rootArtifactSha256,
applicationGraph,
),
finishImmutableSteps: (rootArtifactSha256, applicationGraph) =>
finishWith(
sealTransferredJavaScriptSemanticGraphSteps,
rootArtifactSha256,
applicationGraph,
),
};
};
/** Project one source with the parent's remaining node capacity, retaining no full IR. */
export function* projectJavaScriptSemanticFileSteps(
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
nodeBudget: number,
paths: readonly ElectronCallPath[] = [],
): Generator<void, JavaScriptSemanticFileProjection> {
if (
!Number.isSafeInteger(nodeBudget) ||
nodeBudget < 0 ||
nodeBudget > SEMANTIC_GRAPH_FILE_NODE_CEILING
nodeBudget > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM
)
throw new RangeError(
"File semantic projection requires a valid offered node capacity",
@@ -414,7 +434,7 @@ export function* projectJavaScriptSemanticFileSteps(
const state = emptyState();
state.fileNodeBudget = nodeBudget;
state.fileNodesDropped = false;
const fingerprints = yield* projectFileSteps(file, ir, state);
const fingerprints = yield* projectFileSteps(file, ir, state, paths);
return {
roots: [...state.roots],
evidenceContexts: state.evidenceContexts.contexts,
@@ -430,6 +450,7 @@ function* projectFileSteps(
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
state: BuilderState,
paths: readonly ElectronCallPath[] = [],
): Generator<void, JavaScriptSemanticFingerprint[]> {
yield;
const moduleNode = retainNode(state, file, {
@@ -441,6 +462,7 @@ function* projectFileSteps(
});
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
yield* projectElectronSemanticCallPaths(file, paths, state);
// Node creation keeps source order, which the per-file node budget relies
// on; yields between nodes do not change it.
const callableNodes = new Map<string, JavaScriptSemanticGraphNode>();
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { projectJavaScriptSemanticFileSteps } from "./JavaScriptSemanticGraphBuilder.js";
const source = `${Array.from({ length: 40 }, (_, i) => `function library${String(i)}(){return ${String(i)}}`).join("\n")}
const e=require("electron");
function register(channel,callback){e.ipcMain.addListener(channel,event=>allowed(event)&&callback(event))}
register("test:copy",event=>event.sender.copy());`;
const file: JavaScriptArtifactFile = {
path: "main.js",
sha256: "a".repeat(64),
container_sha256: "b".repeat(64),
bytes: source.length,
inventory_artifact_id: `art_${"a".repeat(64)}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: source },
};
const facts = analyzeJavaScriptStaticSource(source);
const ir = analyzeJavaScriptSemantics(source);
const project = (budget: number) => {
const steps = projectJavaScriptSemanticFileSteps(
file,
ir,
budget,
facts.electron.call_paths,
);
let result = steps.next();
while (!result.done) result = steps.next();
return result.value;
};
it("retains Electron calls before library definitions consume the node budget", () => {
const result = project(20);
expect(result.nodes).toHaveLength(20);
expect(result.truncated).toBe(true);
const terminal = result.nodes.find(
(node) => node.label === "ipc-event.sender.copy",
);
expect(terminal).toBeDefined();
expect(
result.relations.some(
(r) =>
r.target_node_id === terminal?.node_id && r.resolution === "candidate",
),
).toBe(true);
expect(
result.nodes.some(
(node) =>
node.properties.entry === "test:copy" &&
Array.isArray(node.properties.conditions) &&
node.properties.conditions.length === 1,
),
).toBe(true);
});
it("reports truncation and keeps referential integrity even within a boundary path", () => {
const result = project(3);
expect(result.nodes).toHaveLength(3);
expect(result.truncated).toBe(true);
expect(
result.nodes.some((node) => node.label === "ipc-event.sender.copy"),
).toBe(false);
const retained = new Set(result.nodes.map((node) => node.node_id));
expect(
result.relations.every(
(r) => retained.has(r.source_node_id) && retained.has(r.target_node_id),
),
).toBe(true);
});
@@ -0,0 +1,73 @@
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import {
addSemanticGraphRelation,
retainSemanticGraphNode,
type SemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
/** Give discovered process-boundary paths budget before bulk library definitions. */
export function* projectElectronSemanticCallPaths(
file: JavaScriptArtifactFile,
paths: readonly ElectronCallPath[],
state: SemanticGraphProjectionState,
): Generator<void, void> {
for (const path of paths) {
yield;
const pathId = sha256Text(JSON.stringify(path));
let previous: ReturnType<typeof retainSemanticGraphNode> = null;
let previousKind: ElectronCallPath["steps"][number]["kind"] | undefined;
for (const [index, step] of path.steps.entries()) {
const node = retainSemanticGraphNode(state, file, {
kind:
step.kind === "function"
? "function"
: step.kind === "exposure"
? "boundary"
: "call-site",
roleKey: `electron-path:${pathId}:${String(index)}`,
location: step.location,
label: step.label,
functionNodeId: null,
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
terminal: path.terminal,
step_kind: step.kind,
resolution: "candidate",
conditions: path.conditions.map((c) => ({
expression: c.expression,
location: {
start: { ...c.location.start },
end: { ...c.location.end },
},
})),
},
});
if (index === 0 && node !== null) state.roots.add(node.node_id);
addSemanticGraphRelation(state, {
source: previous,
target: node,
relation:
previousKind === "ipc"
? "registers-listener"
: previousKind === "exposure" || previousKind === "factory"
? "aliases"
: "calls",
resolution: "candidate",
evidence: inferredSemanticEvidenceAt(file, step.location),
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
basis: "lexical-wrapper-substitution",
},
});
previous = node;
previousKind = step.kind;
}
}
}