mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user