Files
reverse-engineering-anything/src/application/JavaScriptSemanticGraphBuilder.ts
T

468 lines
16 KiB
TypeScript

import {
createJavaScriptSemanticGraph,
type JavaScriptSemanticGraph,
type JavaScriptSemanticGraphNode,
} from "../domain/javascriptSemanticGraph.js";
import {
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
} from "../domain/javascriptSemanticGraphSchemas.js";
import type {
JavaScriptSemanticCallArgument,
JavaScriptSemanticIr,
} from "../domain/javascriptSemanticIr.js";
import { sourceRangesEqual as rangesEqual } from "../domain/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
projectSemanticEvents,
projectSemanticTimers,
} from "./JavaScriptSemanticGraphAsyncProjection.js";
import { projectSemanticChildProcesses } from "./JavaScriptSemanticGraphChildProcessProjection.js";
import {
projectSemanticBoundaries,
projectSemanticConfiguration,
projectSemanticRequests,
} from "./JavaScriptSemanticGraphDataProjection.js";
import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js";
import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js";
import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js";
import {
projectSemanticClosureCaptures,
projectSemanticFrontiers,
projectSemanticPromises,
projectSemanticReturnValues,
semanticRecoveryLimits,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
import {
owningSemanticCallableNode,
semanticFamilyStatus,
semanticNodesWithinRange,
} from "./JavaScriptSemanticGraphProjection.js";
import {
addSemanticFallbackRoot as addFallbackRoot,
addSemanticGraphNode as addNode,
addSemanticGraphRelation as addRelation,
constructSemanticGraphNode as semanticNode,
createSemanticGraphProjectionState as emptyState,
type SemanticGraphProjectionState as BuilderState,
} from "./JavaScriptSemanticGraphConstruction.js";
interface BuilderInput {
readonly rootArtifactSha256: string;
readonly applicationGraph: Pick<
JavaScriptApplicationGraph,
"graph_id" | "nodes"
>;
readonly analysis: JavaScriptArtifactAnalysis;
}
interface FileContext extends SemanticFlowProjectionContext {
readonly file: JavaScriptArtifactFile;
readonly ir: JavaScriptSemanticIr;
readonly state: BuilderState;
readonly moduleNode: JavaScriptSemanticGraphNode;
readonly bindingNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly argumentNodes: Map<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: JavaScriptSemanticGraphNode[];
readonly callResolutions: ReadonlyMap<string, "candidate" | "resolved">;
}
/** Project bounded per-file semantic IR into an artifact-bound companion graph. */
export const buildJavaScriptSemanticGraph = ({
rootArtifactSha256,
applicationGraph,
analysis,
}: BuilderInput): JavaScriptSemanticGraph => {
const state = emptyState(applicationGraph);
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
for (const analyzed of analysis.files) {
if (analyzed.semantic === null) continue;
fingerprints.push(
...projectFile(analyzed.file, analyzed.semantic.ir, state),
);
}
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const semanticTruncated = analysis.files.some(
({ semantic }) => semantic?.ir.coverage.status === "truncated",
);
const truncated = semanticTruncated;
const unknowns = [...state.unknowns.values()];
return createJavaScriptSemanticGraph({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
nodes: [...state.nodes.values()],
relations: [...state.relations.values()],
fingerprints,
unknowns,
coverage: {
status: truncated ? "partial" : "unknown",
truncated,
omitted_nodes: truncated ? null : 0,
omitted_relations: truncated ? null : 0,
limits: [...semanticRecoveryLimits(analysis)],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family, analysis, truncated),
retained_relations: [...state.relations.values()].filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] === family,
).length,
omitted_relations: truncated ? null : 0,
unknown_ids: unknowns
.filter((unknown) => unknown.family === family)
.map(({ unknown_id: identifier }) => identifier),
})),
},
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 bounded 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.",
],
});
};
const projectFile = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
state: BuilderState,
): JavaScriptSemanticGraph["fingerprints"][number][] => {
const moduleNode = addNode(
state,
semanticNode(
file,
{
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
},
state,
),
);
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
const callableNodes = new Map(
ir.callables.flatMap((callable) => {
const node = addNode(
state,
semanticNode(
file,
{
kind: "function",
roleKey: `callable:${callable.callableId}`,
location: callable.location,
label: callable.name,
functionNodeId: null,
},
state,
),
);
return node === null ? [] : [[callable.callableId, node] as const];
}),
);
const bindingNodes = new Map(
ir.bindings.flatMap((binding) => {
const location = binding.definitions[0]?.location ?? null;
const kind = binding.kind === "parameter" ? "parameter" : "binding";
const owner = owningSemanticCallableNode(location, ir, callableNodes);
const node = addNode(
state,
semanticNode(
file,
{
kind,
roleKey: `binding:${binding.bindingId}`,
location,
label: binding.name,
functionNodeId: owner?.node_id ?? null,
},
state,
),
);
return node === null ? [] : [[binding.bindingId, node] as const];
}),
);
const returnSiteNodes = createReturnSiteNodes(file, ir, callableNodes, state);
const callSiteNodes = createCallSiteNodes(file, ir, callableNodes, state);
const context: FileContext = {
file,
ir,
state,
moduleNode,
bindingNodes,
callableNodes,
callSiteNodes,
returnSiteNodes,
argumentNodes: new Map(),
referenceNodes: [],
callResolutions: new Map(
ir.callSites.map((call) => [
call.callSiteId,
call.resolution === "exact" ? "resolved" : "candidate",
]),
),
};
projectDefinitionsAndReferences(context);
projectSemanticValues(context);
projectSemanticObjects(context);
projectCalls(context);
projectSemanticPromises(context);
projectSemanticEvents(context);
projectSemanticTimers(context);
projectSemanticChildProcesses(context);
projectSemanticConfiguration(context);
projectSemanticRequests(context);
projectSemanticBoundaries(context);
projectSemanticResources(context);
projectSemanticClosureCaptures(context);
projectSemanticFrontiers(context);
return projectSemanticFunctionFingerprints(file, ir, callableNodes);
};
const createReturnSiteNodes = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callables: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
state: BuilderState,
): Map<string, JavaScriptSemanticGraphNode> => {
const result = new Map<string, JavaScriptSemanticGraphNode>();
for (const callable of ir.callables) {
const owner = callables.get(callable.callableId);
if (owner === undefined) continue;
for (const site of callable.returnSites) {
const identifier = site.returnSiteId;
const node = addNode(
state,
semanticNode(
file,
{
kind: "return-site",
roleKey: identifier,
location: site.location,
label: "return",
functionNodeId: owner.node_id,
},
state,
),
);
if (node !== null) result.set(identifier, node);
}
}
return result;
};
const createCallSiteNodes = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callables: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
state: BuilderState,
): Map<string, JavaScriptSemanticGraphNode> => {
const result = new Map<string, JavaScriptSemanticGraphNode>();
for (const call of ir.callSites) {
const owner =
call.callerCallableId === null
? null
: (callables.get(call.callerCallableId)?.node_id ?? null);
const node = addNode(
state,
semanticNode(
file,
{
kind: "call-site",
roleKey: `call:${call.callSiteId}`,
location: call.location,
label: call.kind,
functionNodeId: owner,
},
state,
),
);
if (node !== null) result.set(call.callSiteId, node);
}
return result;
};
const projectDefinitionsAndReferences = (context: FileContext): void => {
for (const binding of context.ir.bindings) {
const bindingNode = context.bindingNodes.get(binding.bindingId);
if (bindingNode === undefined) continue;
for (const [index, definition] of binding.definitions.entries()) {
const definitionNode = addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `definition:${binding.bindingId}:${String(index)}`,
location: definition.location,
label: definition.kind,
functionNodeId: bindingNode.function_node_id,
},
context.state,
),
);
addRelation(context.state, {
source: definitionNode,
target: bindingNode,
relation: "defines",
resolution: "resolved",
});
}
}
for (const [index, reference] of context.ir.references.entries()) {
const expression = addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `reference:${String(index)}:${reference.role}:${reference.name}`,
location: reference.location,
label: reference.name,
functionNodeId:
owningSemanticCallableNode(
reference.location,
context.ir,
context.callableNodes,
)?.node_id ?? null,
},
context.state,
),
);
const binding =
reference.bindingId === null
? undefined
: context.bindingNodes.get(reference.bindingId);
if (expression !== null) context.referenceNodes.push(expression);
if (binding === undefined || expression === null) continue;
if (reference.role === "read")
addRelation(context.state, {
source: binding,
target: expression,
relation: "reads",
resolution: "resolved",
evidence: inferredSemanticEvidenceAt(context.file, reference.location),
});
else
addRelation(context.state, {
source: expression,
target: binding,
relation: "writes",
resolution: "resolved",
});
}
};
const projectCalls = (context: FileContext): void => {
for (const call of context.ir.callSites) {
const callNode = context.callSiteNodes.get(call.callSiteId);
if (callNode === undefined) continue;
for (const callableId of call.calleeCallableIds) {
const callable = context.callableNodes.get(callableId);
addRelation(context.state, {
source: callNode,
target: callable,
relation: "calls",
resolution: call.resolution === "exact" ? "resolved" : "candidate",
});
}
for (const argument of call.arguments) {
const argumentNode = createArgumentNode(
context,
callNode,
call.callSiteId,
argument,
);
if (argumentNode !== null)
context.argumentNodes.set(
`${call.callSiteId}\u0000${String(argument.index)}`,
argumentNode,
);
const references = semanticNodesWithinRange(
context.referenceNodes,
argument.location,
);
for (const reference of references)
addRelation(context.state, {
source: reference,
target: argumentNode,
relation: "aliases",
resolution:
references.length === 1 &&
rangesEqual(reference.identity.source_range, argument.location)
? "resolved"
: "candidate",
});
}
}
for (const flow of context.ir.argumentFlows) {
const argument = context.argumentNodes.get(
`${flow.callSiteId}\u0000${String(flow.argumentIndex)}`,
);
const parameter = context.bindingNodes.get(flow.parameterBindingId);
addRelation(context.state, {
source: argument,
target: parameter,
relation: "argument-to-parameter",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
}
for (const flow of context.ir.callReturnFlows)
addRelation(context.state, {
source: context.returnSiteNodes.get(flow.returnSiteId),
target: context.callSiteNodes.get(flow.callSiteId),
relation: "returns-to-call",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
for (const flow of context.ir.callResultFlows)
addRelation(context.state, {
source: context.callSiteNodes.get(flow.callSiteId),
target: context.bindingNodes.get(flow.bindingId),
relation: "aliases",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
projectSemanticReturnValues(context);
};
const createArgumentNode = (
context: FileContext,
callNode: JavaScriptSemanticGraphNode,
callSiteId: string,
argument: JavaScriptSemanticCallArgument,
): JavaScriptSemanticGraphNode | null =>
addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `argument:${callSiteId}:${String(argument.index)}`,
location: argument.location,
label: argument.spread
? "spread argument"
: `argument ${String(argument.index)}`,
functionNodeId: callNode.function_node_id,
},
context.state,
),
);