feat(javascript): recover runtime semantic effects

This commit is contained in:
morluto 2026-07-24 13:17:38 +00:00
parent 697be942e8
commit 38c883bbc8
28 changed files with 5376 additions and 27 deletions

View File

@ -0,0 +1,256 @@
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticEventOperation,
JavaScriptSemanticTimerOperation,
} from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project literal EventEmitter registrations, removals, and dispatches. */
export const projectSemanticEvents = (
context: SemanticFlowProjectionContext,
): void => {
const eventNodes = new Map<string, JavaScriptSemanticGraphNode>();
for (const operation of context.ir.eventOperations) {
const eventKey =
operation.eventName === null
? operation.eventId
: `${operation.emitterKey}\u0000${operation.eventName}`;
const event =
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
if (event === null) continue;
eventNodes.set(eventKey, event);
const relation = eventRelation(operation);
if (operation.kind === "dispatch")
addSemanticGraphRelation(context.state, {
source: eventEmitterNode(context, operation),
target: event,
relation,
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else {
const listener = addListenerNode(context, operation);
addSemanticGraphRelation(context.state, {
source: event,
target: listener,
relation,
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
}
if (operation.resolution !== "complete")
addEventUnknown(context, operation, event, relation);
}
};
const addEventNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
eventKey: string,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "event",
roleKey: `event:${eventKey}`,
location: operation.eventName === null ? operation.location : null,
label: operation.eventName,
functionNodeId: null,
properties: {
emitter_key: operation.emitterKey,
event_name: operation.eventName,
},
},
context.state,
),
);
const addListenerNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "listener",
roleKey: `listener:${operation.eventId}`,
location: operation.listenerLocation ?? operation.location,
label:
operation.eventName === null
? operation.method
: `${operation.method}:${operation.eventName}`,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
emitter_key: operation.emitterKey,
event_name: operation.eventName,
method: operation.method,
},
},
context.state,
),
);
const eventEmitterNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
): JavaScriptSemanticGraphNode =>
(operation.emitterBindingId === null
? undefined
: context.bindingNodes.get(operation.emitterBindingId)) ??
(operation.ownerCallableId === null
? context.moduleNode
: (context.callableNodes.get(operation.ownerCallableId) ??
context.moduleNode));
const eventRelation = (
operation: JavaScriptSemanticEventOperation,
): "registers-listener" | "removes-listener" | "dispatches-candidate" =>
operation.kind === "register"
? "registers-listener"
: operation.kind === "remove"
? "removes-listener"
: "dispatches-candidate";
const addEventUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
event: JavaScriptSemanticGraphNode,
relation: ReturnType<typeof eventRelation>,
): void => {
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: event.node_id,
family: "event",
relation_kinds: [relation],
reason: "ambiguous-target",
detail:
operation.eventName === null
? `Dynamic ${operation.method} event name remains unresolved.`
: `Static ${operation.method} emitter identity is partial.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
/** Project Node timer scheduling and exact local handle cancellation. */
export const projectSemanticTimers = (
context: SemanticFlowProjectionContext,
): void => {
const timerNodes = new Map(
context.ir.timerOperations.flatMap((operation) => {
if (operation.kind !== "schedule") return [];
const owner =
operation.ownerCallableId === null
? undefined
: context.callableNodes.get(operation.ownerCallableId);
const timer = addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "timer",
roleKey: operation.timerId,
location: operation.location,
label: operation.method,
functionNodeId: owner?.node_id ?? null,
properties: {
delay_milliseconds: operation.delayMilliseconds,
method: operation.method,
},
},
context.state,
),
);
return timer === null ? [] : [[operation.timerId, timer] as const];
}),
);
for (const operation of context.ir.timerOperations)
if (operation.kind === "schedule")
projectTimerSchedule(context, operation, timerNodes);
else projectTimerCancellation(context, operation, timerNodes);
};
const projectTimerSchedule = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticTimerOperation,
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
addSemanticGraphRelation(context.state, {
source: callSiteAt(context, operation.location),
target: timerNodes.get(operation.timerId),
relation: "schedules-timer",
resolution: "resolved",
});
};
const projectTimerCancellation = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticTimerOperation,
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const callSite = callSiteAt(context, operation.location);
const target =
operation.linkedTimerId === null
? undefined
: timerNodes.get(operation.linkedTimerId);
addSemanticGraphRelation(context.state, {
source: callSite,
target,
relation: "cancels-timer",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution === "complete") return;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: callSite?.node_id ?? null,
family: "timer",
relation_kinds: ["cancels-timer"],
reason: "ambiguous-target",
detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
const callSiteAt = (
context: SemanticFlowProjectionContext,
location: JavaScriptTimerLocation,
): JavaScriptSemanticGraphNode | undefined => {
const call = context.ir.callSites.find(({ location: candidate }) =>
rangesEqual(candidate, location),
);
return call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId);
};
type JavaScriptTimerLocation = JavaScriptSemanticTimerOperation["location"];
const rangesEqual = (
left: JavaScriptTimerLocation,
right: JavaScriptTimerLocation,
): boolean =>
left.start.line === right.start.line &&
left.start.column === right.start.column &&
left.end.line === right.end.line &&
left.end.column === right.end.column;

View File

@ -14,10 +14,25 @@ import type {
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,
@ -71,9 +86,12 @@ export const buildJavaScriptSemanticGraph = ({
analysis,
}: BuilderInput): JavaScriptSemanticGraph => {
const state = emptyState(applicationGraph);
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
for (const analyzed of analysis.files) {
if (analyzed.semantic === null) continue;
projectFile(analyzed.file, analyzed.semantic.ir, state);
fingerprints.push(
...projectFile(analyzed.file, analyzed.semantic.ir, state),
);
}
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const semanticTruncated = analysis.files.some(
@ -89,7 +107,7 @@ export const buildJavaScriptSemanticGraph = ({
root_node_ids: [...state.roots],
nodes: [...state.nodes.values()],
relations: [...state.relations.values()],
fingerprints: [],
fingerprints,
unknowns,
coverage: {
status: truncated ? "partial" : "unknown",
@ -135,7 +153,15 @@ export const buildJavaScriptSemanticGraph = ({
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.",
"Function fingerprints and promise, event, process, request, boundary, timer, configuration, and resource extraction are not available in this graph version's current extractor coverage.",
"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.",
...(truncated
? [
"Semantic graph projection reached one or more explicit hard limits.",
@ -149,7 +175,7 @@ const projectFile = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
state: BuilderState,
): void => {
): JavaScriptSemanticGraph["fingerprints"][number][] => {
const moduleNode = addNode(
state,
semanticNode(
@ -164,7 +190,7 @@ const projectFile = (
state,
),
);
if (moduleNode === null) return;
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
const callableNodes = new Map(
ir.callables.flatMap((callable) => {
@ -228,9 +254,20 @@ const projectFile = (
),
};
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 = (

View File

@ -0,0 +1,275 @@
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
} from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
interface ChildUnknownInput {
readonly context: SemanticFlowProjectionContext;
readonly node: JavaScriptSemanticGraphNode | null;
readonly location: JavaScriptSemanticChildProcessSpawn["location"];
readonly relationKinds: readonly (
| "spawns"
| "forwards-signal"
| "listens-exit"
| "listens-error"
)[];
readonly detail: string;
readonly candidateNodeIds: readonly string[];
}
/** Project child creation, argv/env/stdio, listeners, and signals. */
export const projectSemanticChildProcesses = (
context: SemanticFlowProjectionContext,
): void => {
const processNodes = new Map(
context.ir.childProcessSpawns.flatMap((spawn) => {
const node = addChildProcessNode(context, spawn);
return node === null ? [] : [[spawn.processId, node] as const];
}),
);
for (const spawn of context.ir.childProcessSpawns) {
const child = processNodes.get(spawn.processId);
if (child === undefined) continue;
projectSpawn(context, spawn, child);
}
for (const interaction of context.ir.childProcessInteractions)
projectInteraction(context, interaction, processNodes);
};
const addChildProcessNode = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "child-process",
roleKey: spawn.processId,
location: spawn.location,
label: spawn.command ?? spawn.method,
functionNodeId:
spawn.ownerCallableId === null
? null
: (context.callableNodes.get(spawn.ownerCallableId)?.node_id ??
null),
properties: {
argv_count: spawn.argvCount,
command: spawn.command,
environment_supplied: spawn.environmentSupplied,
method: spawn.method,
stdio_mode: spawn.stdioMode,
},
},
context.state,
),
);
const projectSpawn = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
child: JavaScriptSemanticGraphNode,
): void => {
const callSite = callSiteAt(context, spawn.location);
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "spawns",
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
});
if (spawn.argvCount !== null)
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "supplies-argv",
resolution: "resolved",
properties: { argument_count: spawn.argvCount },
});
if (spawn.environmentSupplied)
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "supplies-env",
resolution: "resolved",
});
const stdio = addStdioNode(context, spawn, child);
addSemanticGraphRelation(context.state, {
source: child,
target: stdio,
relation: "connects-stdio",
resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved",
});
if (spawn.resolution !== "complete")
addChildUnknown({
context,
node: child,
location: spawn.location,
relationKinds: ["spawns"],
detail: `Static ${spawn.method} command remains unresolved.`,
candidateNodeIds: [],
});
};
const addStdioNode = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
child: JavaScriptSemanticGraphNode,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "stdio",
roleKey: `stdio:${spawn.processId}`,
location: spawn.location,
label: spawn.stdioMode,
functionNodeId: child.function_node_id,
properties: { mode: spawn.stdioMode },
},
context.state,
),
);
const projectInteraction = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
processNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const children = interaction.linkedProcessIds.flatMap((identifier) => {
const child = processNodes.get(identifier);
return child === undefined ? [] : [child];
});
const effect =
interaction.kind === "listener"
? addChildListenerNode(context, interaction)
: addSignalNode(context, interaction);
for (const child of children)
addSemanticGraphRelation(context.state, {
source: interaction.kind === "listener" ? child : effect,
target: interaction.kind === "listener" ? effect : child,
relation:
interaction.kind === "signal"
? "forwards-signal"
: interaction.eventName === "exit"
? "listens-exit"
: "listens-error",
resolution:
interaction.resolution === "complete" ? "resolved" : "candidate",
});
if (interaction.resolution !== "complete")
addChildUnknown({
context,
node: effect,
location: interaction.location,
relationKinds: [
interaction.kind === "signal"
? "forwards-signal"
: interaction.eventName === "exit"
? "listens-exit"
: "listens-error",
],
detail: `Static child ${interaction.method} ownership is ambiguous.`,
candidateNodeIds: children.map(({ node_id: identifier }) => identifier),
});
};
const addChildListenerNode = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "listener",
roleKey: interaction.interactionId,
location: interaction.listenerLocation ?? interaction.location,
label: interaction.eventName,
functionNodeId:
interaction.ownerCallableId === null
? null
: (context.callableNodes.get(interaction.ownerCallableId)
?.node_id ?? null),
properties: {
event_name: interaction.eventName,
method: interaction.method,
},
},
context.state,
),
);
const addSignalNode = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "signal",
roleKey: interaction.interactionId,
location: interaction.location,
label: interaction.signalName,
functionNodeId:
interaction.ownerCallableId === null
? null
: (context.callableNodes.get(interaction.ownerCallableId)
?.node_id ?? null),
properties: { signal_name: interaction.signalName },
},
context.state,
),
);
const addChildUnknown = (input: ChildUnknownInput): void => {
const { context, node, location, relationKinds, detail, candidateNodeIds } =
input;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node?.node_id ?? null,
family: "child-process",
relation_kinds: relationKinds,
reason: "ambiguous-target",
detail,
candidate_node_ids: candidateNodeIds,
evidence: unknownSemanticEvidence(context.file, location),
}),
);
};
const callSiteAt = (
context: SemanticFlowProjectionContext,
location: JavaScriptSemanticChildProcessSpawn["location"],
): JavaScriptSemanticGraphNode | undefined => {
const call = context.ir.callSites.find(({ location: candidate }) =>
rangesEqual(candidate, location),
);
return call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId);
};
const rangesEqual = (
left: JavaScriptSemanticChildProcessSpawn["location"],
right: JavaScriptSemanticChildProcessSpawn["location"],
): boolean =>
left.start.line === right.start.line &&
left.start.column === right.start.column &&
left.end.line === right.end.line &&
left.end.column === right.end.column;

View File

@ -1,4 +1,5 @@
import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
createJavaScriptSemanticGraphNode,
createJavaScriptSemanticGraphRelation,
@ -49,6 +50,7 @@ export interface SemanticNodeConstructionInput {
readonly location: JavaScriptSourceRange | null;
readonly label: string | null;
readonly functionNodeId: string | null;
readonly properties?: Readonly<Record<string, JsonValue>>;
}
/** Input for one directed static semantic relationship. */
@ -58,6 +60,7 @@ export interface SemanticRelationConstructionInput {
readonly relation: JavaScriptSemanticGraphRelation["relation"];
readonly resolution?: JavaScriptSemanticGraphRelation["resolution"];
readonly evidence?: ApplicationGraphEvidence;
readonly properties?: Readonly<Record<string, JsonValue>>;
}
/** Create empty projection state with structural application-node mappings. */
@ -94,7 +97,7 @@ export const constructSemanticGraphNode = (
function_node_id: input.functionNodeId,
application_node_ids: matchingApplicationNodeIds(file, input, state),
label: input.label,
properties: {},
properties: input.properties ?? {},
evidence: observedSemanticEvidence(file, input.location),
});
@ -131,7 +134,7 @@ export const addSemanticGraphRelation = (
target_node_id: input.target.node_id,
relation: input.relation,
resolution: input.resolution ?? "candidate",
properties: {},
properties: input.properties ?? {},
evidence: input.evidence ?? inferredSemanticEvidence(input.source),
});
if (state.relations.has(value.relation_id)) return;

View File

@ -0,0 +1,351 @@
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticBoundaryOperation,
JavaScriptSemanticConfigurationOperation,
JavaScriptSemanticRequestOperation,
} from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project environment, argv, file, default, and precedence candidates. */
export const projectSemanticConfiguration = (
context: SemanticFlowProjectionContext,
): void => {
const configNodes = new Map(
context.ir.configurationOperations.flatMap((operation) => {
const node = addConfigurationNode(context, operation);
return node === null ? [] : [[operation.configId, node] as const];
}),
);
for (const operation of context.ir.configurationOperations) {
const config = configNodes.get(operation.configId);
if (config === undefined) continue;
const target = bindingOrOwner(
context,
operation.resultBindingId,
operation.ownerCallableId,
);
addSemanticGraphRelation(context.state, {
source: config,
target,
relation: configurationRelation(operation),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.kind === "default" && operation.sourceConfigId !== null)
addSemanticGraphRelation(context.state, {
source: configNodes.get(operation.sourceConfigId),
target: config,
relation: "overrides",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: config,
family: "configuration",
relationKinds: [
configurationRelation(operation),
...(operation.kind === "default" ? (["overrides"] as const) : []),
],
location: operation.location,
detail: `Static ${operation.kind} configuration identity is partial.`,
candidateNodeIds: [],
});
}
};
const addConfigurationNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticConfigurationOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "config-source",
roleKey: operation.configId,
location: operation.location,
label: operation.key ?? operation.kind,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
key: operation.key,
source_kind: operation.kind,
value: operation.value,
},
},
context.state,
),
);
const configurationRelation = (
operation: JavaScriptSemanticConfigurationOperation,
): "reads-environment" | "reads-argv" | "reads-config" | "defaults" =>
operation.kind === "environment"
? "reads-environment"
: operation.kind === "argv"
? "reads-argv"
: operation.kind === "file"
? "reads-config"
: "defaults";
/** Project request construction, field provenance, and response consumers. */
export const projectSemanticRequests = (
context: SemanticFlowProjectionContext,
): void => {
const requestNodes = new Map(
context.ir.requestOperations.flatMap((operation) => {
const node = addRequestNode(context, operation);
return node === null ? [] : [[operation.requestId, node] as const];
}),
);
for (const operation of context.ir.requestOperations) {
const request = requestNodes.get(operation.requestId);
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request);
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: request,
family: "request",
relationKinds:
operation.kind === "request"
? ["constructs-request", "supplies-request-field"]
: ["consumed-by"],
location: operation.location,
detail: `Static ${operation.method} request resolution was ${operation.resolution}.`,
candidateNodeIds: operation.linkedRequestIds.flatMap((identifier) => {
const candidate = requestNodes.get(identifier);
return candidate === undefined ? [] : [candidate.node_id];
}),
});
}
};
const addRequestNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: operation.kind === "request" ? "request" : "response",
roleKey: operation.requestId,
location: operation.location,
label: operation.endpoint ?? operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
endpoint: operation.endpoint,
method: operation.method,
operation_kind: operation.kind,
},
},
context.state,
),
);
const projectRequestConstruction = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
request: JavaScriptSemanticGraphNode,
): void => {
const callSite = callSiteAt(context, operation.location);
addSemanticGraphRelation(context.state, {
source: callSite,
target: request,
relation: "constructs-request",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
});
for (const field of operation.fields)
addSemanticGraphRelation(context.state, {
source:
field.sourceBindingId === null
? callSite
: context.bindingNodes.get(field.sourceBindingId),
target: request,
relation: "supplies-request-field",
resolution: field.sourceBindingId === null ? "candidate" : "resolved",
properties: { field: field.name },
});
};
const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
): void => {
addSemanticGraphRelation(context.state, {
source: response,
target: callSiteAt(context, operation.location),
relation: "consumed-by",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
properties: { method: operation.method },
});
};
/** Project parse, coercion, and validation consumers as static boundaries. */
export const projectSemanticBoundaries = (
context: SemanticFlowProjectionContext,
): void => {
for (const operation of context.ir.boundaryOperations) {
const boundary = addBoundaryNode(context, operation);
if (boundary === null) continue;
addSemanticGraphRelation(context.state, {
source: bindingOrOwner(
context,
operation.sourceBindingId,
operation.ownerCallableId,
),
target: boundary,
relation:
operation.kind === "parse"
? "parses"
: operation.kind === "coerce"
? "coerces"
: "validates",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: boundary,
family: "boundary",
relationKinds: [
operation.kind === "parse"
? "parses"
: operation.kind === "coerce"
? "coerces"
: "validates",
],
location: operation.location,
detail: `Static ${operation.method} boundary kind is a candidate.`,
candidateNodeIds: [],
});
}
};
const addBoundaryNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticBoundaryOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "boundary",
roleKey: operation.boundaryId,
location: operation.location,
label: operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
boundary_kind: operation.kind,
method: operation.method,
},
},
context.state,
),
);
const bindingOrOwner = (
context: SemanticFlowProjectionContext,
bindingId: string | null,
callableId: string | null,
): JavaScriptSemanticGraphNode =>
(bindingId === null ? undefined : context.bindingNodes.get(bindingId)) ??
(callableId === null
? context.moduleNode
: (context.callableNodes.get(callableId) ?? context.moduleNode));
interface DataUnknownInput {
readonly context: SemanticFlowProjectionContext;
readonly node: JavaScriptSemanticGraphNode;
readonly family: "boundary" | "configuration" | "request";
readonly relationKinds: readonly (
| "coerces"
| "constructs-request"
| "consumed-by"
| "defaults"
| "overrides"
| "parses"
| "reads-argv"
| "reads-config"
| "reads-environment"
| "supplies-request-field"
| "validates"
)[];
readonly location: JavaScriptSemanticBoundaryOperation["location"];
readonly detail: string;
readonly candidateNodeIds: readonly string[];
}
const addDataUnknown = (input: DataUnknownInput): void => {
const {
context,
node,
family,
relationKinds,
location,
detail,
candidateNodeIds,
} = input;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node.node_id,
family,
relation_kinds: relationKinds,
reason: "ambiguous-target",
detail,
candidate_node_ids: candidateNodeIds,
evidence: unknownSemanticEvidence(context.file, location),
}),
);
};
const callSiteAt = (
context: SemanticFlowProjectionContext,
location: JavaScriptSemanticBoundaryOperation["location"],
): JavaScriptSemanticGraphNode | undefined => {
const call = context.ir.callSites.find(({ location: candidate }) =>
rangesEqual(candidate, location),
);
return call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId);
};
const rangesEqual = (
left: JavaScriptSemanticBoundaryOperation["location"],
right: JavaScriptSemanticBoundaryOperation["location"],
): boolean =>
left.start.line === right.start.line &&
left.start.column === right.start.column &&
left.end.line === right.end.line &&
left.end.column === right.end.column;

View File

@ -0,0 +1,39 @@
import {
createJavaScriptSemanticFingerprint,
type JavaScriptSemanticGraph,
type JavaScriptSemanticGraphNode,
} from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
/** Bind domain fingerprint components to exact semantic function nodes. */
export const projectSemanticFunctionFingerprints = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): JavaScriptSemanticGraph["fingerprints"][number][] =>
ir.functionFingerprints.flatMap((fingerprint) => {
const callable = callableNodes.get(fingerprint.callableId);
if (callable === undefined) return [];
return [
createJavaScriptSemanticFingerprint({
function_node_id: callable.node_id,
algorithm: "rea.javascript-semantic-function/v1",
status: fingerprint.status,
components: {
parameter_arity: fingerprint.components.parameterArity,
normalized_ast_sha256: fingerprint.components.normalizedAstSha256,
control_flow_sha256: fingerprint.components.controlFlowSha256,
relation_shape_sha256: fingerprint.components.relationShapeSha256,
literal_set_sha256: fingerprint.components.literalSetSha256,
effects: fingerprint.components.effects,
},
limitations: fingerprint.limitations,
evidence: observedSemanticEvidence(
file,
callable.identity.source_range,
),
}),
];
});

View File

@ -4,8 +4,10 @@ import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
type SemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
@ -22,10 +24,184 @@ export interface SemanticFlowProjectionContext {
readonly moduleNode: JavaScriptSemanticGraphNode;
readonly bindingNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: readonly JavaScriptSemanticGraphNode[];
}
/** Project explicit static Promise/task ownership without runtime claims. */
export const projectSemanticPromises = (
context: SemanticFlowProjectionContext,
): void => {
const promiseNodes = new Map(
context.ir.promiseOperations.flatMap((operation) => {
const owner =
operation.ownerCallableId === null
? undefined
: context.callableNodes.get(operation.ownerCallableId);
const node = addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: operation.kind === "awaited-expression" ? "task" : "promise",
roleKey: operation.promiseId,
location: operation.location,
label: promiseLabel(operation),
functionNodeId: owner?.node_id ?? null,
properties: {
method: operation.method,
operation_kind: operation.kind,
ownership: operation.ownership,
source_resolution: operation.sourceResolution,
},
},
context.state,
),
);
return node === null ? [] : [[operation.promiseId, node] as const];
}),
);
for (const operation of context.ir.promiseOperations) {
const promise = promiseNodes.get(operation.promiseId);
if (promise === undefined) continue;
projectPromiseCreation(context, operation, promise);
projectPromiseSources(context, operation, promise, promiseNodes);
projectPromiseOwnership(context, operation, promise);
if (
operation.sourceResolution !== "complete" &&
["chain", "aggregate", "awaited-expression"].includes(operation.kind)
)
addPromiseUnknown(context, operation, promise, promiseNodes);
}
};
const promiseLabel = (
operation: JavaScriptSemanticIr["promiseOperations"][number],
): string =>
operation.kind === "awaited-expression"
? "awaited expression"
: `Promise.${operation.method}`;
const projectPromiseCreation = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
): void => {
if (operation.kind === "awaited-expression") return;
const call = context.ir.callSites.find(({ location }) =>
rangesEqual(location, operation.location),
);
addSemanticGraphRelation(context.state, {
source:
call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId),
target: promise,
relation: "creates-promise",
resolution:
operation.kind === "chain" || operation.sourceResolution !== "complete"
? "candidate"
: "resolved",
});
};
const projectPromiseSources = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
for (const sourceId of operation.sourcePromiseIds) {
const source = promiseNodes.get(sourceId);
if (operation.kind === "aggregate")
addSemanticGraphRelation(context.state, {
source: promise,
target: source,
relation: "aggregates",
resolution:
operation.sourceResolution === "complete" ? "resolved" : "candidate",
});
else
addSemanticGraphRelation(context.state, {
source,
target: promise,
relation: "chains",
resolution:
operation.sourceResolution === "complete" ? "resolved" : "candidate",
});
}
};
const projectPromiseOwnership = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
): void => {
const owner =
operation.ownerCallableId === null
? context.moduleNode
: context.callableNodes.get(operation.ownerCallableId);
if (operation.ownership === "awaited")
addSemanticGraphRelation(context.state, {
source: owner,
target: promise,
relation: "awaits",
resolution: "resolved",
});
else if (operation.ownership === "assigned")
addSemanticGraphRelation(context.state, {
source:
operation.ownerBindingId === null
? undefined
: context.bindingNodes.get(operation.ownerBindingId),
target: promise,
relation: "owns",
resolution: "resolved",
});
else if (operation.ownership === "returned")
addSemanticGraphRelation(context.state, {
source:
operation.returnSiteId === null
? undefined
: context.returnSiteNodes.get(operation.returnSiteId),
target: promise,
relation: "returns-task",
resolution: "resolved",
});
else if (operation.ownership === "detached")
addSemanticGraphRelation(context.state, {
source: owner,
target: promise,
relation: "detaches-task",
resolution: "resolved",
});
};
const addPromiseUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const relation = operation.kind === "aggregate" ? "aggregates" : "chains";
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: promise.node_id,
family: "promise-ownership",
relation_kinds: [relation],
reason: "ambiguous-target",
detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`,
candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => {
const candidate = promiseNodes.get(identifier);
return candidate === undefined ? [] : [candidate.node_id];
}),
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
/** Link direct return expressions to their retained semantic references. */
export const projectSemanticReturnValues = (
context: SemanticFlowProjectionContext,

View File

@ -0,0 +1,126 @@
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticObjectOperation } from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
/** Project static object reads, writes, spreads, and destructuring. */
export const projectSemanticObjects = (
context: SemanticFlowProjectionContext,
): void => {
for (const operation of context.ir.objectOperations) {
const occurrence = addObjectOccurrence(context, operation);
const slot =
operation.objectBindingId === null || operation.propertyName === null
? null
: semanticPropertySlot(
context,
operation.objectBindingId,
operation.propertyName,
);
if (operation.kind === "read")
addSemanticGraphRelation(context.state, {
source: slot,
target: occurrence,
relation: "reads-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "write")
addSemanticGraphRelation(context.state, {
source: occurrence,
target: slot,
relation: "writes-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "destructure")
addSemanticGraphRelation(context.state, {
source: slot,
target:
operation.targetBindingId === null
? undefined
: context.bindingNodes.get(operation.targetBindingId),
relation: "destructures",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else
addSemanticGraphRelation(context.state, {
source:
operation.objectBindingId === null
? undefined
: context.bindingNodes.get(operation.objectBindingId),
target: occurrence,
relation: "spreads",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addObjectUnknown(context, operation, slot ?? occurrence);
}
};
const addObjectOccurrence = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "expression",
roleKey: operation.objectOperationId,
location: operation.location,
label:
operation.propertyName === null
? operation.kind
: `${operation.kind}:${operation.propertyName}`,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
operation_kind: operation.kind,
property_name: operation.propertyName,
},
},
context.state,
),
);
const addObjectUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
node: JavaScriptSemanticGraphNode | null,
): void => {
const relation =
operation.kind === "read"
? "reads-property"
: operation.kind === "write"
? "writes-property"
: operation.kind === "spread"
? "spreads"
: "destructures";
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node?.node_id ?? null,
family: "object-flow",
relation_kinds: [relation],
reason: "ambiguous-target",
detail: `Static ${operation.kind} object identity is partial.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};

View File

@ -41,7 +41,22 @@ export const semanticFamilyStatus = (
analysis: JavaScriptArtifactAnalysis,
truncated: boolean,
): "complete" | "partial" | "unknown" | "unsupported" => {
if (!["call-flow", "closure", "data-flow", "object-flow"].includes(family))
if (
![
"call-flow",
"boundary",
"child-process",
"closure",
"configuration",
"data-flow",
"event",
"object-flow",
"promise-ownership",
"request",
"resource-lifecycle",
"timer",
].includes(family)
)
return "unsupported";
if (truncated) return "unknown";
return analysis.truncated_scopes === 0 ? "partial" : "unknown";

View File

@ -0,0 +1,113 @@
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticResourceOperation } from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project built-in resource acquisition and exact local release handles. */
export const projectSemanticResources = (
context: SemanticFlowProjectionContext,
): void => {
const resourceNodes = new Map(
context.ir.resourceOperations.flatMap((operation) => {
if (operation.kind !== "acquire") return [];
const node = addResourceNode(context, operation);
return node === null ? [] : [[operation.resourceId, node] as const];
}),
);
for (const operation of context.ir.resourceOperations)
if (operation.kind === "acquire")
addSemanticGraphRelation(context.state, {
source: callSiteAt(context, operation.location),
target: resourceNodes.get(operation.resourceId),
relation: "acquires",
resolution: "resolved",
});
else projectRelease(context, operation, resourceNodes);
};
const addResourceNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticResourceOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "resource",
roleKey: operation.resourceId,
location: operation.location,
label: operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: { method: operation.method },
},
context.state,
),
);
const projectRelease = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticResourceOperation,
resourceNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const callSite = callSiteAt(context, operation.location);
const resources = operation.linkedResourceIds.flatMap((identifier) => {
const resource = resourceNodes.get(identifier);
return resource === undefined ? [] : [resource];
});
for (const resource of resources)
addSemanticGraphRelation(context.state, {
source: callSite,
target: resource,
relation: "releases",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution === "complete") return;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: callSite?.node_id ?? null,
family: "resource-lifecycle",
relation_kinds: ["releases"],
reason: "ambiguous-target",
detail: `Static ${operation.method} resource ownership is ambiguous.`,
candidate_node_ids: resources.map(
({ node_id: identifier }) => identifier,
),
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
const callSiteAt = (
context: SemanticFlowProjectionContext,
location: JavaScriptSemanticResourceOperation["location"],
): JavaScriptSemanticGraphNode | undefined => {
const call = context.ir.callSites.find(({ location: candidate }) =>
rangesEqual(candidate, location),
);
return call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId);
};
const rangesEqual = (
left: JavaScriptSemanticResourceOperation["location"],
right: JavaScriptSemanticResourceOperation["location"],
): boolean =>
left.start.line === right.start.line &&
left.start.column === right.start.column &&
left.end.line === right.end.line &&
left.end.column === right.end.column;

View File

@ -0,0 +1,129 @@
import type { JsonValue } from "../domain/jsonValue.js";
import type {
JavaScriptSemanticBinding,
JavaScriptSemanticValue,
} from "../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project bounded literal values and object slots for exact query seeds. */
export const projectSemanticValues = (
context: SemanticFlowProjectionContext,
): void => {
for (const binding of context.ir.bindings) {
const bindingNode = context.bindingNodes.get(binding.bindingId);
if (bindingNode === undefined) continue;
projectValue({
context,
binding,
value: binding.value,
target: bindingNode,
role: "binding",
});
}
};
interface ValueProjectionInput {
readonly context: SemanticFlowProjectionContext;
readonly binding: JavaScriptSemanticBinding;
readonly value: JavaScriptSemanticValue;
readonly target: ReturnType<typeof semanticPropertySlot>;
readonly role: string;
}
const projectValue = (input: ValueProjectionInput): void => {
const { context, binding, value, target, role } = input;
if (target === null) return;
if (value.status === "literal") {
const literal = addLiteralNode(context, binding, value.value, role);
addSemanticGraphRelation(context.state, {
source: literal,
target,
relation: "defines",
resolution: "resolved",
});
} else if (value.status === "union")
for (const primitive of value.values) {
const literal = addLiteralNode(context, binding, primitive, role);
addSemanticGraphRelation(context.state, {
source: literal,
target,
relation: "defines",
resolution: "candidate",
});
}
else if (value.status === "object")
for (const property of value.properties) {
const slot = semanticPropertySlot(
context,
binding.bindingId,
property.name,
);
addSemanticGraphRelation(context.state, {
source: target,
target: slot,
relation: "writes-property",
resolution: value.unknownProperties ? "candidate" : "resolved",
});
projectValue({
context,
binding,
value: property.value,
target: slot,
role: `property:${property.name}`,
});
}
};
const addLiteralNode = (
context: SemanticFlowProjectionContext,
binding: JavaScriptSemanticBinding,
value: string | number | boolean | null,
role: string,
) =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "literal",
roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`,
location: binding.definitions[0]?.location ?? null,
label: JSON.stringify(value),
functionNodeId:
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
properties: { value },
},
context.state,
),
);
/** Create one canonical property slot for a binding/property identity. */
export const semanticPropertySlot = (
context: SemanticFlowProjectionContext,
objectBindingId: string,
name: string,
) =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "property-slot",
roleKey: `property:${objectBindingId}:${name}`,
location: null,
label: name,
functionNodeId:
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
properties: {
name,
object_binding_id: objectBindingId,
} satisfies Readonly<Record<string, JsonValue>>,
},
context.state,
),
);

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { isAbsolute, relative } from "node:path";
import canonicalize from "canonicalize";
import { z } from "zod";
@ -6,14 +7,24 @@ import { z } from "zod";
import {
browserAllowedOriginsSchema,
browserEndpointSchema,
browserOriginSchema,
webPageInspectionSchema,
type WebPageInspection,
} from "./browserObservation.js";
import {
classifyBrowserCompleteness,
type BrowserCompleteness,
} from "./browserCompleteness.js";
import {
electronFileRootsSchema,
electronPageInspectionSchema,
type ElectronPageInspection,
} from "./electronObservation.js";
import {
javascriptRuntimeObservationSchema,
javascriptRuntimeKindSchema,
type JavaScriptRuntimeObservation,
} from "./javascriptRuntimeObservation.js";
import { parseEvidence, type Evidence } from "./evidence.js";
import {
javascriptApplicationAnalysisResultSchema,
@ -45,6 +56,39 @@ interface RuntimeCaptureBase {
readonly scriptsCompleteWithinScope: boolean;
}
interface NormalizedV8Inspection {
readonly target:
| {
readonly target_id: string;
readonly type: string;
readonly title: string;
readonly attached: boolean;
readonly file_path: string;
}
| {
readonly target_id: string;
readonly type: string;
readonly title: string;
readonly attached: boolean;
readonly url: string;
};
readonly frames: readonly [];
readonly scripts: {
readonly items: readonly (Readonly<{
script_key: string;
frame_id: string | null;
cdp_hash: string;
length: number;
is_module: boolean;
language: null;
source: { readonly included: false; readonly reason: string };
}> &
({ readonly file_path: string } | { readonly url: string }))[];
};
readonly workers: readonly [];
readonly completeness: BrowserCompleteness;
}
export type ParsedRuntimeCapture =
| (RuntimeCaptureBase & {
readonly kind: "browser";
@ -53,6 +97,10 @@ export type ParsedRuntimeCapture =
| (RuntimeCaptureBase & {
readonly kind: "electron";
readonly inspection: ElectronPageInspection;
})
| (RuntimeCaptureBase & {
readonly kind: "v8-inspector";
readonly inspection: NormalizedV8Inspection;
});
/** Parse and semantically bind every static analysis Evidence layer. */
@ -184,11 +232,157 @@ const parseRuntimeCapture = (input: Evidence): ParsedRuntimeCapture => {
scriptsCompleteWithinScope: scriptsComplete(inspection.completeness),
};
}
if (evidence.operation === "observe_javascript_runtime") {
assertEvidenceIdentity(evidence, {
operation: "observe_javascript_runtime",
predicate: "rea.javascript-runtime-observation/v1",
providerId: "rea-v8-inspector",
providerName: "REA passive Node/Electron V8 Inspector provider",
providerVersion: "1",
authority: "external-service",
confidence: "observed",
});
const result = javascriptRuntimeObservationSchema.parse(
evidence.normalized_result,
);
assertV8RuntimeParameters(evidence, result);
const inspection = normalizeV8Inspection(result);
return {
kind: "v8-inspector",
evidence,
inspection,
captureSha256: digestCanonical(result),
scriptsCompleteWithinScope: false,
};
}
throw new TypeError(
"Runtime reconciliation requires inspect_web_page or inspect_electron_page Evidence",
"Runtime reconciliation requires inspect_web_page, inspect_electron_page, or observe_javascript_runtime Evidence",
);
};
const assertV8RuntimeParameters = (
evidence: Evidence,
result: JavaScriptRuntimeObservation,
): void => {
const parameters = z
.object({
inspector_endpoint: browserEndpointSchema,
allowed_file_roots: z
.array(z.string().min(1).max(16_384).refine(isAbsolute))
.max(32),
allowed_origins: z.array(browserOriginSchema).max(32),
target_id: z.string().trim().min(1).max(256),
runtime_kind: javascriptRuntimeKindSchema,
})
.passthrough()
.parse(evidence.parameters);
if (
parameters.target_id !== result.target.target_id ||
parameters.runtime_kind !== result.target.runtime_kind
)
throw new TypeError(
"Runtime Evidence target or declared role disagrees with its result",
);
for (const location of [
result.target.location,
...result.scripts.items.map(({ location }) => location),
])
if (
(location.kind === "file" &&
!parameters.allowed_file_roots.some((root) =>
isWithinRoot(root, location.file_path),
)) ||
(location.kind === "url" &&
!parameters.allowed_origins.includes(location.origin)) ||
(location.kind === "builtin" && !location.specifier.startsWith("node:"))
)
throw new TypeError(
"Runtime Evidence contains a location outside its recorded scope",
);
};
const isWithinRoot = (root: string, path: string): boolean => {
const remainder = relative(root, path);
return (
remainder === "" || (!remainder.startsWith("..") && !isAbsolute(remainder))
);
};
const normalizeV8Inspection = (
result: JavaScriptRuntimeObservation,
): NormalizedV8Inspection => ({
target: {
target_id: result.target.target_id,
type: result.target.protocol_type,
title: result.target.runtime_kind,
attached: result.target.attached,
...runtimeLocation(result.target.location),
},
frames: [],
scripts: {
items: result.scripts.items.map((script) => ({
script_key: script.script_key,
frame_id: script.execution_context_key,
...runtimeLocation(script.location),
cdp_hash: script.cdp_hash ?? "",
length: script.length,
is_module: script.is_module,
language: null,
source: {
included: false,
reason: "V8 Inspector source capture is outside passive authority.",
},
})),
},
workers: [],
completeness: classifyBrowserCompleteness({
policyFilteredSections: new Set(
excludedV8Scripts(result) > 0 ? ["scripts"] : [],
),
attachLimitedSections: new Set([
"frames",
"scripts",
"script_sources",
"workers",
]),
truncatedSections: new Set(result.capture.truncated ? ["scripts"] : []),
unavailableSections: new Set(["frames", "script_sources", "workers"]),
excluded:
excludedV8Scripts(result) === 0
? []
: [
{
section: "scripts",
reason: "out_of_target_scope",
count: excludedV8Scripts(result),
},
],
droppedEvents: {
scripts: result.capture.events_dropped,
network_requests: 0,
console_events: 0,
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: 0,
},
}),
});
const runtimeLocation = (
location: JavaScriptRuntimeObservation["target"]["location"],
): { readonly file_path: string } | { readonly url: string } => {
if (location.kind === "file") return { file_path: location.file_path };
if (location.kind === "url") return { url: location.sanitized_url };
return { url: location.specifier };
};
const excludedV8Scripts = (result: JavaScriptRuntimeObservation): number =>
Object.values(result.scripts.excluded).reduce(
(total, count) => total + count,
0,
);
const assertRuntimeParameters = (
evidence: Evidence,
expected:

View File

@ -88,10 +88,10 @@ const captureSummaries = (
kind: capture.kind,
target_node_id: targetNode.node_id,
target_key: capture.inspection.target.target_id,
target_location:
capture.kind === "browser"
? capture.inspection.target.url.slice(0, 4_096)
: capture.inspection.target.file_path.slice(0, 4_096),
target_location: ("url" in capture.inspection.target
? capture.inspection.target.url
: capture.inspection.target.file_path
).slice(0, 4_096),
frames: capture.inspection.frames.length,
scripts: capture.inspection.scripts.items.length,
workers: capture.inspection.workers.length,

View File

@ -286,8 +286,7 @@ const runtimeEvidence = (
version:
capture.evidence.provider.version ??
(capture.kind === "browser" ? "2" : "1"),
operation:
capture.kind === "browser" ? "inspect_web_page" : "inspect_electron_page",
operation: capture.evidence.operation,
executable_sha256: null,
},
coverage: runtimeSectionCoverage(capture, input.section),
@ -363,8 +362,7 @@ const runtimeRelationshipEvidence = (
version:
capture.evidence.provider.version ??
(capture.kind === "browser" ? "2" : "1"),
operation:
capture.kind === "browser" ? "inspect_web_page" : "inspect_electron_page",
operation: capture.evidence.operation,
executable_sha256: null,
},
coverage: runtimeSectionCoverage(capture, entity.section),

View File

@ -170,7 +170,7 @@ const layerSummarySchema = z.strictObject({
const captureSummarySchema = z.strictObject({
evidence_id: evidenceIdSchema,
capture_sha256: digestSchema,
kind: z.enum(["browser", "electron"]),
kind: z.enum(["browser", "electron", "v8-inspector"]),
target_node_id: nodeIdSchema,
target_key: boundedTextSchema,
target_location: boundedTextSchema,

View File

@ -35,7 +35,7 @@ import {
collectSemanticReturns,
resolveSemanticModuleCallables,
} from "./javascriptSemanticReturns.js";
import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js";
import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js";
import {
compareCodePoints,
propertyName,
@ -87,16 +87,21 @@ export const analyzeJavaScriptSemantics = (
const bindings = immutableSemanticBindings(state);
const callables = collectSemanticReturns(file.program, state, parserPartial);
const moduleLinks = resolveSemanticModuleCallables(state, callables);
const calls = collectJavaScriptSemanticCalls(file.program, state, callables);
const derived = collectJavaScriptDerivedSemantics(
file.program,
state,
callables,
parserPartial,
);
return {
schema: "JavaScriptSemanticIR",
schemaVersion: 3,
schemaVersion: 4,
scopes: immutableSemanticScopes(state),
bindings,
callables,
references,
moduleLinks,
...calls,
...derived,
coverage: {
status:
state.limitsReached.size > 0
@ -119,6 +124,7 @@ export const analyzeJavaScriptSemantics = (
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
"Return sites include only direct callable returns; nested callable returns remain separate.",
"Local call, argument, return, and closure relations are static candidates and do not prove runtime invocation.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Cross-function mutation and dynamic property resolution remain unknown.",
],
};

View File

@ -0,0 +1,374 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticEventOperation,
JavaScriptSemanticTimerOperation,
} from "./javascriptSemanticIr.js";
import {
reachSemanticLimit,
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
const EVENT_REGISTER_METHODS = [
"on",
"once",
"addListener",
"prependListener",
"prependOnceListener",
] as const;
const EVENT_REMOVE_METHODS = [
"off",
"removeListener",
"removeAllListeners",
] as const;
const EVENT_METHODS = [
...EVENT_REGISTER_METHODS,
...EVENT_REMOVE_METHODS,
"emit",
] as const;
const TIMER_SCHEDULE_METHODS = [
"setTimeout",
"setInterval",
"setImmediate",
] as const;
const TIMER_CANCEL_METHODS = [
"clearTimeout",
"clearInterval",
"clearImmediate",
] as const;
interface AsyncEffectAnalysis {
readonly eventOperations: readonly JavaScriptSemanticEventOperation[];
readonly timerOperations: readonly JavaScriptSemanticTimerOperation[];
}
interface TimerCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticTimerOperation["method"];
readonly kind: JavaScriptSemanticTimerOperation["kind"];
readonly ownerCallableId: string | null;
readonly handleBindingId: string | null;
}
interface TimerCollectionInput {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly parent: t.Node | null;
readonly ownerCallableId: string | null;
readonly state: JavaScriptSemanticAnalysisState;
readonly output: TimerCandidate[];
}
/** Recover explicit EventEmitter and Node timer candidates from inert syntax. */
export const collectJavaScriptSemanticAsyncEffects = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): AsyncEffectAnalysis => {
const events: JavaScriptSemanticEventOperation[] = [];
const timers: TimerCandidate[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack: string[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableIds.has(callableId))
callableStack.push(callableId);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const ownerCallableId = callableStack.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectTimer({ node, parent, ownerCallableId, state, output: timers });
},
exit: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableStack.at(-1) === callableId)
callableStack.pop();
},
});
return {
eventOperations: events,
timerOperations: resolveTimers(timers),
};
};
const collectEvent = (
node: t.CallExpression | t.OptionalCallExpression,
ownerCallableId: string | null,
state: JavaScriptSemanticAnalysisState,
output: JavaScriptSemanticEventOperation[],
): void => {
const member = memberCallee(node);
if (member === null) return;
const method = EVENT_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(member.property, member.computed),
);
if (method === undefined) return;
const kind = eventKind(method);
if (output.length >= state.limits.maxEventOperations) {
reachSemanticLimit(state, "maxEventOperations");
return;
}
const eventArgument = node.arguments[0];
const listenerArgument = node.arguments[1];
const emitter = emitterIdentity(member.object, state);
const eventName = literalString(eventArgument);
output.push({
eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind,
method,
location: range(node),
ownerCallableId,
emitterKey: emitter.key,
emitterBindingId: emitter.bindingId,
eventName,
listenerBindingId: bindingId(listenerArgument, state),
listenerLocation: t.isNode(listenerArgument)
? range(listenerArgument)
: null,
resolution:
eventName === null
? "unresolved"
: emitter.complete
? "complete"
: "partial",
});
};
const eventKind = (
method: JavaScriptSemanticEventOperation["method"],
): JavaScriptSemanticEventOperation["kind"] => {
if (EVENT_REGISTER_METHODS.some((candidate) => candidate === method))
return "register";
if (EVENT_REMOVE_METHODS.some((candidate) => candidate === method))
return "remove";
return "dispatch";
};
const emitterIdentity = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
): {
readonly key: string;
readonly bindingId: string | null;
readonly complete: boolean;
} => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding !== undefined)
return {
key: `binding:${binding.bindingId}`,
bindingId: binding.bindingId,
complete: true,
};
return {
key: `global:${node.name}`,
bindingId: null,
complete: !semanticResolutionBlocked(state, node, node.name),
};
}
if (t.isThisExpression(node))
return { key: "this", bindingId: null, complete: true };
return {
key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
bindingId: null,
complete: false,
};
};
const collectTimer = (input: TimerCollectionInput): void => {
const { node, parent, ownerCallableId, state, output } = input;
const method = timerMethod(node.callee, state);
if (method === null) return;
if (output.length >= state.limits.maxTimerOperations) {
reachSemanticLimit(state, "maxTimerOperations");
return;
}
const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method)
? "schedule"
: "cancel";
output.push({
node,
method,
kind,
ownerCallableId,
handleBindingId:
kind === "schedule"
? assignedBindingId(parent, node, state)
: bindingId(node.arguments[0], state),
});
};
const resolveTimers = (
candidates: readonly TimerCandidate[],
): JavaScriptSemanticTimerOperation[] => {
const schedulesByBinding = new Map<string, TimerCandidate[]>();
for (const candidate of candidates) {
if (candidate.kind !== "schedule" || candidate.handleBindingId === null)
continue;
const existing = schedulesByBinding.get(candidate.handleBindingId) ?? [];
existing.push(candidate);
schedulesByBinding.set(candidate.handleBindingId, existing);
}
return candidates.map((candidate) => {
const linked =
candidate.kind === "cancel" && candidate.handleBindingId !== null
? (schedulesByBinding.get(candidate.handleBindingId) ?? [])
: [];
return {
timerId: timerId(candidate),
kind: candidate.kind,
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
handleBindingId: candidate.handleBindingId,
linkedTimerId:
linked.length === 1 && linked[0] !== undefined
? timerId(linked[0])
: null,
delayMilliseconds: timerDelay(candidate),
resolution:
candidate.kind === "schedule"
? "complete"
: linked.length === 1
? "complete"
: linked.length > 1
? "partial"
: "unresolved",
};
});
};
const timerMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticTimerOperation["method"] | null => {
if (t.isIdentifier(callee)) {
const binding = resolveSemanticBindingState(state, callee, callee.name);
if (binding === undefined) {
const method = admittedTimerMethod(callee.name);
if (method === null) return null;
return semanticResolutionBlocked(state, callee, callee.name)
? null
: method;
}
return timerMethodFromBinding(binding);
}
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return null;
const method = admittedTimerMethod(
semanticStaticPropertyName(callee.property, callee.computed),
);
return method !== null &&
t.isIdentifier(callee.object) &&
timerNamespaceBinding(
resolveSemanticBindingState(state, callee.object, callee.object.name),
)
? method
: null;
};
const admittedTimerMethod = (
value: string,
): JavaScriptSemanticTimerOperation["method"] | null =>
[...TIMER_SCHEDULE_METHODS, ...TIMER_CANCEL_METHODS].find(
(candidate) => candidate === value,
) ?? null;
const timerMethodFromBinding = (
binding: JavaScriptSemanticBindingState,
): JavaScriptSemanticTimerOperation["method"] | null => {
for (const { specifier, importedPath } of binding.directOrigins) {
if (!timerModule(specifier)) continue;
const method = admittedTimerMethod(importedPath.at(-1) ?? "");
if (method !== null) return method;
}
return null;
};
const timerNamespaceBinding = (
binding: JavaScriptSemanticBindingState | undefined,
): boolean =>
binding?.directOrigins.some(
({ specifier, importedPath }) =>
timerModule(specifier) && importedPath.length === 0,
) ?? false;
const timerModule = (specifier: string): boolean =>
specifier === "timers" || specifier === "node:timers";
const assignedBindingId = (
parent: t.Node | null,
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null => {
const identifier =
t.isVariableDeclarator(parent) &&
parent.init === node &&
t.isIdentifier(parent.id)
? parent.id
: t.isAssignmentExpression(parent) &&
parent.right === node &&
t.isIdentifier(parent.left)
? parent.left
: null;
return identifier === null
? null
: (resolveSemanticBindingState(state, identifier, identifier.name)
?.bindingId ?? null);
};
const bindingId = (
node:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
state: JavaScriptSemanticAnalysisState,
): string | null =>
t.isIdentifier(node)
? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null)
: null;
const literalString = (
node:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
): string | null => {
if (t.isStringLiteral(node)) return node.value;
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null;
return null;
};
const timerDelay = (candidate: TimerCandidate): number | null => {
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
return null;
const delay = candidate.node.arguments[1];
return t.isNumericLiteral(delay) ? delay.value : null;
};
const timerId = (candidate: TimerCandidate): string =>
`timer:${candidate.kind}:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
const memberCallee = (
node: t.CallExpression | t.OptionalCallExpression,
): t.MemberExpression | t.OptionalMemberExpression | null =>
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;

View File

@ -0,0 +1,391 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import {
reachSemanticLimit,
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
compareCodePoints,
propertyName,
range,
} from "./javascriptStaticAnalysisHelpers.js";
const CHILD_PROCESS_METHODS = ["spawn", "exec", "execFile", "fork"] as const;
const CHILD_LISTENER_METHODS = ["on", "once", "addListener"] as const;
interface ChildProcessAnalysis {
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
}
interface SpawnCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
}
interface ChildInteractionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly spawnByNode: WeakMap<t.Node, SpawnCandidate>;
readonly spawnsByBinding: ReadonlyMap<string, readonly SpawnCandidate[]>;
}
/** Recover explicit asynchronous node:child_process ownership candidates. */
export const collectJavaScriptSemanticChildProcesses = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): ChildProcessAnalysis => {
const candidates = collectSpawns(program, state, callables);
const childProcessSpawns = candidates.map((candidate) =>
immutableSpawn(candidate),
);
return {
childProcessSpawns,
childProcessInteractions: collectInteractions(
program,
state,
callables,
candidates,
),
};
};
const collectSpawns = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): SpawnCandidate[] => {
const output: SpawnCandidate[] = [];
const admittedCallables = new Set(
callables.map(({ callableId }) => callableId),
);
const callableStack: string[] = [];
traverseJavaScriptAst(program, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && admittedCallables.has(callableId))
callableStack.push(callableId);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const method = childProcessMethod(node.callee, state);
if (method === null) return;
if (operationLimitReached(output.length, state)) return;
output.push({
node,
method,
ownerCallableId: callableStack.at(-1) ?? null,
resultBindingId: assignedBindingId(parent, node, state),
});
},
exit: (node) => popCallable(node, callableStack),
});
return output;
};
const immutableSpawn = (
candidate: SpawnCandidate,
): JavaScriptSemanticChildProcessSpawn => {
const options = spawnOptions(candidate);
const command = literalString(candidate.node.arguments[0]);
return {
processId: spawnId(candidate),
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
command,
argvCount: spawnArgvCount(candidate),
environmentSupplied: objectHasProperty(options, "env"),
stdioMode: stdioMode(options),
resolution: command === null ? "partial" : "complete",
};
};
const collectInteractions = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
spawns: readonly SpawnCandidate[],
): JavaScriptSemanticChildProcessInteraction[] => {
const output: JavaScriptSemanticChildProcessInteraction[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack: string[] = [];
const spawnByNode = new WeakMap<t.Node, SpawnCandidate>();
const spawnsByBinding = new Map<string, SpawnCandidate[]>();
for (const spawn of spawns) {
spawnByNode.set(spawn.node, spawn);
if (spawn.resultBindingId === null) continue;
const existing = spawnsByBinding.get(spawn.resultBindingId) ?? [];
existing.push(spawn);
spawnsByBinding.set(spawn.resultBindingId, existing);
}
const context: ChildInteractionContext = {
state,
spawnByNode,
spawnsByBinding,
};
traverseJavaScriptAst(program, {
enter: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableIds.has(callableId))
callableStack.push(callableId);
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
const interaction = childInteraction(
node,
callableStack.at(-1) ?? null,
context,
);
if (interaction === null) return;
if (operationLimitReached(spawns.length + output.length, state)) return;
output.push(interaction);
},
exit: (node) => popCallable(node, callableStack),
});
return output;
};
const childInteraction = (
node: t.CallExpression | t.OptionalCallExpression,
ownerCallableId: string | null,
context: ChildInteractionContext,
): JavaScriptSemanticChildProcessInteraction | null => {
const { state, spawnByNode, spawnsByBinding } = context;
const member = memberCallee(node);
if (member === null) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
const binding = objectBinding(member.object, state);
const linked = resolveChildCandidates(
member.object,
binding,
spawnByNode,
spawnsByBinding,
);
if (linked.length === 0) return null;
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
const { kind, interactionMethod, eventName } = classification;
const listener = node.arguments[1];
return {
interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind,
method: interactionMethod,
location: range(node),
ownerCallableId,
processBindingId: binding?.bindingId ?? null,
linkedProcessIds: linked
.map((candidate) => spawnId(candidate))
.sort(compareCodePoints),
eventName: kind === "listener" ? eventName : null,
signalName:
kind === "signal"
? (literalString(node.arguments[0]) ?? "SIGTERM")
: null,
listenerLocation:
kind === "listener" && t.isNode(listener) ? range(listener) : null,
resolution: linked.length === 1 ? "complete" : "partial",
};
};
const classifyInteraction = (
method: string,
argument:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
): {
readonly kind: JavaScriptSemanticChildProcessInteraction["kind"];
readonly interactionMethod: JavaScriptSemanticChildProcessInteraction["method"];
readonly eventName: "exit" | "error" | null;
} | null => {
if (method === "kill")
return { kind: "signal", interactionMethod: "kill", eventName: null };
const listenerMethod = CHILD_LISTENER_METHODS.find(
(candidate) => candidate === method,
);
const eventName = literalString(argument);
return listenerMethod !== undefined &&
(eventName === "exit" || eventName === "error")
? { kind: "listener", interactionMethod: listenerMethod, eventName }
: null;
};
const childProcessMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
if (t.isIdentifier(callee)) {
const binding = resolveSemanticBindingState(state, callee, callee.name);
return binding === undefined ? null : methodFromBinding(binding);
}
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return null;
const method = admittedChildMethod(
semanticStaticPropertyName(callee.property, callee.computed),
);
if (method === null || !t.isIdentifier(callee.object)) return null;
const binding = resolveSemanticBindingState(
state,
callee.object,
callee.object.name,
);
return childProcessNamespace(binding) ? method : null;
};
const methodFromBinding = (
binding: JavaScriptSemanticBindingState,
): JavaScriptSemanticChildProcessSpawn["method"] | null => {
for (const { specifier, importedPath } of binding.directOrigins) {
if (!childProcessModule(specifier)) continue;
const method = admittedChildMethod(importedPath.at(-1) ?? "");
if (method !== null) return method;
}
return null;
};
const admittedChildMethod = (
value: string,
): JavaScriptSemanticChildProcessSpawn["method"] | null =>
CHILD_PROCESS_METHODS.find((candidate) => candidate === value) ?? null;
const childProcessNamespace = (
binding: JavaScriptSemanticBindingState | undefined,
): boolean =>
binding?.directOrigins.some(
({ specifier, importedPath }) =>
childProcessModule(specifier) && importedPath.length === 0,
) ?? false;
const childProcessModule = (specifier: string): boolean =>
specifier === "child_process" || specifier === "node:child_process";
const resolveChildCandidates = (
node: t.Node,
binding: JavaScriptSemanticBindingState | undefined,
spawnByNode: WeakMap<t.Node, SpawnCandidate>,
spawnsByBinding: ReadonlyMap<string, readonly SpawnCandidate[]>,
): readonly SpawnCandidate[] => {
const direct = spawnByNode.get(node);
if (direct !== undefined) return [direct];
return binding === undefined
? []
: (spawnsByBinding.get(binding.bindingId) ?? []);
};
const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => {
const index = candidate.method === "exec" ? 1 : 2;
const value = candidate.node.arguments[index];
return t.isObjectExpression(value) ? value : null;
};
const spawnArgvCount = (candidate: SpawnCandidate): number | null => {
if (candidate.method === "exec") return null;
const args = candidate.node.arguments[1];
return t.isArrayExpression(args) ? args.elements.length : null;
};
const objectHasProperty = (
object: t.ObjectExpression | null,
name: string,
): boolean => objectProperty(object, name) !== null;
const stdioMode = (options: t.ObjectExpression | null): string => {
const property = objectProperty(options, "stdio");
if (property === null || !t.isObjectProperty(property)) return "default";
if (t.isStringLiteral(property.value)) return property.value.value;
if (t.isArrayExpression(property.value)) return "array";
return "dynamic";
};
const objectProperty = (
object: t.ObjectExpression | null,
name: string,
): t.ObjectMethod | t.ObjectProperty | null =>
object?.properties.find(
(property): property is t.ObjectMethod | t.ObjectProperty =>
(t.isObjectMethod(property) || t.isObjectProperty(property)) &&
propertyName(property.key) === name,
) ?? null;
const objectBinding = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticBindingState | undefined =>
t.isIdentifier(node)
? resolveSemanticBindingState(state, node, node.name)
: undefined;
const assignedBindingId = (
parent: t.Node | null,
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null => {
const identifier =
t.isVariableDeclarator(parent) &&
parent.init === node &&
t.isIdentifier(parent.id)
? parent.id
: t.isAssignmentExpression(parent) &&
parent.right === node &&
t.isIdentifier(parent.left)
? parent.left
: null;
return identifier === null
? null
: (resolveSemanticBindingState(state, identifier, identifier.name)
?.bindingId ?? null);
};
const literalString = (
node:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
): string | null => {
if (t.isStringLiteral(node)) return node.value;
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null;
return null;
};
const operationLimitReached = (
retained: number,
state: JavaScriptSemanticAnalysisState,
): boolean => {
if (retained < state.limits.maxChildProcessOperations) return false;
reachSemanticLimit(state, "maxChildProcessOperations");
return true;
};
const popCallable = (node: t.Node, stack: string[]): void => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && stack.at(-1) === callableId) stack.pop();
};
const spawnId = (candidate: SpawnCandidate): string =>
`child:spawn:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
const memberCallee = (
node: t.CallExpression | t.OptionalCallExpression,
): t.MemberExpression | t.OptionalMemberExpression | null =>
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;

View File

@ -0,0 +1,224 @@
import * as t from "@babel/types";
import {
reachSemanticLimit,
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
/** Shared mutable stacks reset before each deterministic data-effect pass. */
export interface DataEffectTraversalContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableIds: ReadonlySet<string>;
readonly callableStack: string[];
readonly ancestors: t.Node[];
}
/** Resolve a direct or namespace-imported built-in method. */
export const builtinDataEffectMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
specifiers: readonly string[],
): string | null => {
if (t.isIdentifier(callee)) {
const binding = resolveSemanticBindingState(state, callee, callee.name);
return moduleMethod(binding, specifiers);
}
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return null;
if (!t.isIdentifier(callee.object)) return null;
const binding = resolveSemanticBindingState(
state,
callee.object,
callee.object.name,
);
const namespace = binding?.directOrigins.some(
({ specifier, importedPath }) =>
specifiers.includes(specifier) && importedPath.length === 0,
);
return namespace
? semanticStaticPropertyName(callee.property, callee.computed)
: null;
};
const moduleMethod = (
binding: JavaScriptSemanticBindingState | undefined,
specifiers: readonly string[],
): string | null => {
for (const { specifier, importedPath } of binding?.directOrigins ?? []) {
if (!specifiers.includes(specifier)) continue;
return importedPath.at(-1) ?? null;
}
return null;
};
/** Recognize global or node:process aliases without accepting shadowing. */
export const isSemanticProcessObject = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
): boolean => {
if (!t.isIdentifier(node)) return false;
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined)
return (
node.name === "process" &&
!semanticResolutionBlocked(state, node, node.name)
);
return binding.directOrigins.some(
({ specifier }) => specifier === "process" || specifier === "node:process",
);
};
/** Resolve the nearest direct variable/assignment owner before a callable. */
export const outerDataEffectBinding = (
node: t.Node,
context: DataEffectTraversalContext,
): string | null => {
for (let index = context.ancestors.length - 1; index >= 0; index -= 1) {
const ancestor = context.ancestors[index];
if (ancestor === undefined) continue;
if (
t.isVariableDeclarator(ancestor) &&
t.isNode(ancestor.init) &&
containsSemanticNode(ancestor.init, node) &&
t.isIdentifier(ancestor.id)
)
return (
resolveSemanticBindingState(
context.state,
ancestor.id,
ancestor.id.name,
)?.bindingId ?? null
);
if (
t.isAssignmentExpression(ancestor) &&
containsSemanticNode(ancestor.right, node) &&
t.isIdentifier(ancestor.left)
)
return (
resolveSemanticBindingState(
context.state,
ancestor.left,
ancestor.left.name,
)?.bindingId ?? null
);
if (semanticCallableIdForNode(ancestor) !== null) return null;
}
return null;
};
/** Traverse with exact callable and ancestor stacks. */
export const traverseDataEffects = (
program: t.Program,
context: DataEffectTraversalContext,
visit: (node: t.Node, parent: t.Node | null) => void,
): void => {
context.callableStack.length = 0;
context.ancestors.length = 0;
traverseJavaScriptAst(program, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && context.callableIds.has(callableId))
context.callableStack.push(callableId);
visit(node, parent);
context.ancestors.push(node);
},
exit: (node) => {
context.ancestors.pop();
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && context.callableStack.at(-1) === callableId)
context.callableStack.pop();
},
});
};
/** Resolve one direct identifier argument to a lexical binding. */
export const dataEffectArgumentBindingId = (
node:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
state: JavaScriptSemanticAnalysisState,
): string | null =>
t.isIdentifier(node)
? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null)
: null;
/** Read one JSON primitive default without evaluating code. */
export const dataEffectPrimitive = (
node: t.Node,
): string | number | boolean | null => {
if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return node.value;
if (t.isBooleanLiteral(node)) return node.value;
return null;
};
/** Read one static string or expression-free template. */
export const dataEffectLiteralString = (
node:
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| undefined,
): string | null => {
if (t.isStringLiteral(node)) return node.value;
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null;
return null;
};
/** Read a member callee from call or construction syntax. */
export const dataEffectMemberCallee = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): t.MemberExpression | t.OptionalMemberExpression | null =>
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;
/** Read a parent member's object when present. */
export const dataEffectMemberObject = (
node: t.Node | null,
): t.Expression | t.Super | null =>
node !== null &&
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
? node.object
: null;
/** Compare Babel source offsets for exact containment. */
export const containsSemanticNode = (outer: t.Node, inner: t.Node): boolean =>
outer.start !== null &&
outer.start !== undefined &&
outer.end !== null &&
outer.end !== undefined &&
inner.start !== null &&
inner.start !== undefined &&
inner.end !== null &&
inner.end !== undefined &&
outer.start <= inner.start &&
outer.end >= inner.end;
/** Record one bounded data-effect frontier. */
export const dataEffectLimitReached = (
retained: number,
limit: number,
name:
| "maxConfigurationOperations"
| "maxRequestOperations"
| "maxBoundaryOperations"
| "maxResourceOperations",
state: JavaScriptSemanticAnalysisState,
): boolean => {
if (retained < limit) return false;
reachSemanticLimit(state, name);
return true;
};

View File

@ -0,0 +1,441 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticBoundaryOperation,
JavaScriptSemanticCallable,
JavaScriptSemanticConfigurationOperation,
JavaScriptSemanticRequestOperation,
} from "./javascriptSemanticIr.js";
import { semanticStaticPropertyName } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import {
builtinDataEffectMethod as builtinMethod,
containsSemanticNode as containsNode,
dataEffectArgumentBindingId as argumentBindingId,
dataEffectLimitReached as limitReached,
dataEffectLiteralString as literalString,
dataEffectMemberCallee as memberCallee,
dataEffectMemberObject as memberObject,
dataEffectPrimitive as primitiveValue,
isSemanticProcessObject as isProcessObject,
outerDataEffectBinding as outerAssignedBinding,
traverseDataEffects as traverseWithContext,
type DataEffectTraversalContext as TraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import {
compareCodePoints,
propertyName,
range,
} from "./javascriptStaticAnalysisHelpers.js";
interface DataEffectAnalysis {
readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[];
readonly requestOperations: readonly JavaScriptSemanticRequestOperation[];
readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[];
}
interface ConfigurationCandidate {
readonly node: t.Node;
readonly operation: JavaScriptSemanticConfigurationOperation;
}
interface RequestCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression | t.NewExpression;
readonly operation: JavaScriptSemanticRequestOperation;
}
/** Recover configuration, request, and boundary candidates from inert syntax. */
export const collectJavaScriptSemanticDataEffects = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): DataEffectAnalysis => {
const context: TraversalContext = {
state,
callableIds: new Set(callables.map(({ callableId }) => callableId)),
callableStack: [],
ancestors: [],
};
const configuration = collectConfigurations(program, context);
const requests = collectRequests(program, context);
return {
configurationOperations: [
...configuration.map(({ operation }) => operation),
...collectDefaults(program, context, configuration),
],
requestOperations: [
...requests.map(({ operation }) => operation),
...collectResponseConsumers(program, context, requests),
],
boundaryOperations: collectBoundaries(program, context),
};
};
const collectConfigurations = (
program: t.Program,
context: TraversalContext,
): ConfigurationCandidate[] => {
const output: ConfigurationCandidate[] = [];
traverseWithContext(program, context, (node, parent) => {
const details = configurationDetails(node, parent, context);
if (details === null) return;
if (
limitReached(
output.length,
context.state.limits.maxConfigurationOperations,
"maxConfigurationOperations",
context.state,
)
)
return;
const operation: JavaScriptSemanticConfigurationOperation = {
configId: `config:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: details.kind,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
key: details.key,
sourceConfigId: null,
value: null,
resolution: details.resolution,
};
output.push({ node, operation });
});
return output;
};
const configurationDetails = (
node: t.Node,
parent: t.Node | null,
context: TraversalContext,
): {
readonly kind: "environment" | "argv" | "file";
readonly key: string | null;
readonly resolution: "complete" | "partial";
} | null => {
if (
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
!(memberObject(parent) === node)
) {
const processMember = processConfigurationMember(node, context.state);
if (processMember !== null) return processMember;
}
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return null;
const fileMethod = builtinMethod(node.callee, context.state, [
"fs",
"node:fs",
]);
return fileMethod === "readFile" || fileMethod === "readFileSync"
? {
kind: "file",
key: literalString(node.arguments[0]),
resolution:
literalString(node.arguments[0]) === null ? "partial" : "complete",
}
: null;
};
const processConfigurationMember = (
node: t.MemberExpression | t.OptionalMemberExpression,
state: JavaScriptSemanticAnalysisState,
): {
readonly kind: "environment" | "argv";
readonly key: string | null;
readonly resolution: "complete" | "partial";
} | null => {
const object = node.object;
if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object))
return null;
if (!isProcessObject(object.object, state)) return null;
const family = semanticStaticPropertyName(object.property, object.computed);
if (family !== "env" && family !== "argv") return null;
const key = semanticStaticPropertyName(node.property, node.computed) || null;
return {
kind: family === "env" ? "environment" : "argv",
key,
resolution: key === null ? "partial" : "complete",
};
};
const collectDefaults = (
program: t.Program,
context: TraversalContext,
configurations: readonly ConfigurationCandidate[],
): JavaScriptSemanticConfigurationOperation[] => {
const output: JavaScriptSemanticConfigurationOperation[] = [];
traverseWithContext(program, context, (node) => {
if (
!t.isLogicalExpression(node) ||
(node.operator !== "??" && node.operator !== "||")
)
return;
const sources = configurations.filter(({ node: source }) =>
containsNode(node.left, source),
);
if (sources.length !== 1 || sources[0] === undefined) return;
if (
limitReached(
configurations.length + output.length,
context.state.limits.maxConfigurationOperations,
"maxConfigurationOperations",
context.state,
)
)
return;
output.push({
configId: `config:default:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "default",
location: range(node.right),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
key: sources[0].operation.key,
sourceConfigId: sources[0].operation.configId,
value: primitiveValue(node.right),
resolution: primitiveValue(node.right) === null ? "partial" : "complete",
});
});
return output;
};
const collectRequests = (
program: t.Program,
context: TraversalContext,
): RequestCandidate[] => {
const output: RequestCandidate[] = [];
traverseWithContext(program, context, (node) => {
if (
!t.isCallExpression(node) &&
!t.isOptionalCallExpression(node) &&
!t.isNewExpression(node)
)
return;
const method = requestMethod(node, context.state);
if (method === null) return;
if (
limitReached(
output.length,
context.state.limits.maxRequestOperations,
"maxRequestOperations",
context.state,
)
)
return;
const endpoint = requestEndpoint(node);
output.push({
node,
operation: {
requestId: `request:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "request",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
linkedRequestIds: [],
endpoint,
fields: requestFields(node, method, context.state),
resolution: endpoint === null ? "partial" : "complete",
},
});
});
return output;
};
const collectResponseConsumers = (
program: t.Program,
context: TraversalContext,
requests: readonly RequestCandidate[],
): JavaScriptSemanticRequestOperation[] => {
const requestsByBinding = new Map<string, RequestCandidate[]>();
for (const request of requests) {
const bindingId = request.operation.resultBindingId;
if (bindingId === null) continue;
const existing = requestsByBinding.get(bindingId) ?? [];
existing.push(request);
requestsByBinding.set(bindingId, existing);
}
const output: JavaScriptSemanticRequestOperation[] = [];
traverseWithContext(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const member = memberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return;
const method = semanticStaticPropertyName(member.property, member.computed);
if (method !== "json" && method !== "text" && method !== "arrayBuffer")
return;
const binding = resolveSemanticBindingState(
context.state,
member.object,
member.object.name,
);
const linked =
binding === undefined
? []
: (requestsByBinding.get(binding.bindingId) ?? []);
if (linked.length === 0) return;
if (
limitReached(
requests.length + output.length,
context.state.limits.maxRequestOperations,
"maxRequestOperations",
context.state,
)
)
return;
output.push({
requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "response-consumer",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
linkedRequestIds: linked
.map(({ operation }) => operation.requestId)
.sort(compareCodePoints),
endpoint: null,
fields: [],
resolution: linked.length === 1 ? "complete" : "partial",
});
});
return output;
};
const collectBoundaries = (
program: t.Program,
context: TraversalContext,
): JavaScriptSemanticBoundaryOperation[] => {
const output: JavaScriptSemanticBoundaryOperation[] = [];
traverseWithContext(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const details = boundaryDetails(node, context.state);
if (details === null) return;
if (
limitReached(
output.length,
context.state.limits.maxBoundaryOperations,
"maxBoundaryOperations",
context.state,
)
)
return;
output.push({
boundaryId: `boundary:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: details.kind,
method: details.method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
sourceBindingId: argumentBindingId(node.arguments[0], context.state),
resultBindingId: outerAssignedBinding(node, context),
resolution: details.resolution,
});
});
return output;
};
const boundaryDetails = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
): {
readonly kind: JavaScriptSemanticBoundaryOperation["kind"];
readonly method: string;
readonly resolution: "complete" | "partial";
} | null => {
if (t.isIdentifier(node.callee)) {
const name = node.callee.name;
const unbound =
resolveSemanticBindingState(state, node.callee, name) === undefined &&
!semanticResolutionBlocked(state, node.callee, name);
if (
unbound &&
["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(name)
)
return { kind: "coerce", method: name, resolution: "complete" };
}
const member = memberCallee(node);
if (member === null) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
if (
method === "parse" &&
t.isIdentifier(member.object, { name: "JSON" }) &&
resolveSemanticBindingState(state, member.object, "JSON") === undefined &&
!semanticResolutionBlocked(state, member.object, "JSON")
)
return { kind: "parse", method: "JSON.parse", resolution: "complete" };
if (method === "parse" || method === "safeParse")
return { kind: "parse", method, resolution: "partial" };
if (method === "validate" || method === "assert" || method === "is")
return { kind: "validate", method, resolution: "partial" };
return null;
};
const requestMethod = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticRequestOperation["method"] | null => {
if (t.isIdentifier(node.callee)) {
const name = node.callee.name;
const unbound =
resolveSemanticBindingState(state, node.callee, name) === undefined &&
!semanticResolutionBlocked(state, node.callee, name);
if (!t.isNewExpression(node) && unbound && name === "fetch") return "fetch";
if (t.isNewExpression(node) && unbound && name === "WebSocket")
return "WebSocket";
}
const member = memberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
if (method !== "request" && method !== "get") return null;
const binding = resolveSemanticBindingState(
state,
member.object,
member.object.name,
);
return binding?.directOrigins.some(
({ specifier, importedPath }) =>
["http", "https", "node:http", "node:https"].includes(specifier) &&
importedPath.length === 0,
)
? method
: null;
};
const requestFields = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
method: JavaScriptSemanticRequestOperation["method"],
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticRequestOperation["fields"] => {
const candidate =
method === "fetch" || method === "WebSocket"
? node.arguments[1]
: t.isObjectExpression(node.arguments[0])
? node.arguments[0]
: node.arguments[1];
if (!t.isObjectExpression(candidate)) return [];
return candidate.properties.flatMap((property) => {
if (!t.isObjectProperty(property)) return [];
const name = propertyName(property.key);
if (name.length === 0) return [];
return [
{
name,
sourceBindingId: t.isIdentifier(property.value)
? (resolveSemanticBindingState(
state,
property.value,
property.value.name,
)?.bindingId ?? null)
: null,
},
];
});
};
const requestEndpoint = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): string | null =>
t.isObjectExpression(node.arguments[0])
? null
: literalString(node.arguments[0]);

View File

@ -0,0 +1,99 @@
import type * as t from "@babel/types";
import { collectJavaScriptSemanticAsyncEffects } from "./javascriptSemanticAsyncEffects.js";
import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js";
import { collectJavaScriptSemanticChildProcesses } from "./javascriptSemanticChildProcesses.js";
import { collectJavaScriptSemanticFingerprints } from "./javascriptSemanticFingerprints.js";
import { collectJavaScriptSemanticDataEffects } from "./javascriptSemanticDataEffects.js";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticIr,
} from "./javascriptSemanticIr.js";
import { collectJavaScriptSemanticPromises } from "./javascriptSemanticPromises.js";
import { collectJavaScriptSemanticObjects } from "./javascriptSemanticObjects.js";
import { collectJavaScriptSemanticResources } from "./javascriptSemanticResources.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
type DerivedSemanticAnalysis = Pick<
JavaScriptSemanticIr,
| "argumentFlows"
| "callResultFlows"
| "callReturnFlows"
| "callSites"
| "boundaryOperations"
| "childProcessInteractions"
| "childProcessSpawns"
| "closureCaptures"
| "configurationOperations"
| "eventOperations"
| "frontiers"
| "functionFingerprints"
| "objectOperations"
| "promiseOperations"
| "requestOperations"
| "resourceOperations"
| "timerOperations"
>;
/** Compose bounded semantic passes after lexical definitions are available. */
export const collectJavaScriptDerivedSemantics = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
parserPartial: boolean,
): DerivedSemanticAnalysis => {
const calls = collectJavaScriptSemanticCalls(program, state, callables);
const promiseOperations = collectJavaScriptSemanticPromises(
program,
state,
callables,
);
const asyncEffects = collectJavaScriptSemanticAsyncEffects(
program,
state,
callables,
);
const childProcesses = collectJavaScriptSemanticChildProcesses(
program,
state,
callables,
);
const dataEffects = collectJavaScriptSemanticDataEffects(
program,
state,
callables,
);
const resourceOperations = collectJavaScriptSemanticResources(
program,
state,
callables,
);
const objectOperations = collectJavaScriptSemanticObjects(
program,
state,
callables,
);
const functionFingerprints = collectJavaScriptSemanticFingerprints({
state,
callables,
calls,
promises: promiseOperations,
events: asyncEffects.eventOperations,
timers: asyncEffects.timerOperations,
childProcessSpawns: childProcesses.childProcessSpawns,
childProcessInteractions: childProcesses.childProcessInteractions,
requestOperations: dataEffects.requestOperations,
resourceOperations,
parserPartial,
});
return {
...calls,
promiseOperations,
...asyncEffects,
...childProcesses,
...dataEffects,
resourceOperations,
objectOperations,
functionFingerprints,
};
};

View File

@ -0,0 +1,262 @@
import { createHash } from "node:crypto";
import * as t from "@babel/types";
import type { JavaScriptSemanticCallAnalysis } from "./javascriptSemanticCalls.js";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
JavaScriptSemanticEventOperation,
JavaScriptSemanticFunctionFingerprint,
JavaScriptSemanticPromiseOperation,
JavaScriptSemanticRequestOperation,
JavaScriptSemanticResourceOperation,
JavaScriptSemanticTimerOperation,
} from "./javascriptSemanticIr.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareCodePoints } from "./javascriptStaticAnalysisHelpers.js";
interface SemanticFingerprintCollectionInput {
readonly state: JavaScriptSemanticAnalysisState;
readonly callables: readonly JavaScriptSemanticCallable[];
readonly calls: JavaScriptSemanticCallAnalysis;
readonly promises: readonly JavaScriptSemanticPromiseOperation[];
readonly events: readonly JavaScriptSemanticEventOperation[];
readonly timers: readonly JavaScriptSemanticTimerOperation[];
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
readonly requestOperations: readonly JavaScriptSemanticRequestOperation[];
readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[];
readonly parserPartial: boolean;
}
/** Build bounded rename- and formatting-resistant components per callable. */
export const collectJavaScriptSemanticFingerprints = (
input: SemanticFingerprintCollectionInput,
): JavaScriptSemanticFunctionFingerprint[] => {
const {
state,
callables,
calls,
promises,
events,
timers,
childProcessSpawns,
childProcessInteractions,
requestOperations,
resourceOperations,
parserPartial,
} = input;
return callables.flatMap((callable) => {
if (callable.kind === "class") return [];
const node = state.callableNodesById.get(callable.callableId);
if (node === undefined) return [];
const syntax = fingerprintSyntax(node, callable.callableId);
const promiseEffects = promises.filter(
({ ownerCallableId }) => ownerCallableId === callable.callableId,
);
const eventEffects = events.filter(
({ ownerCallableId }) => ownerCallableId === callable.callableId,
);
const timerEffects = timers.filter(
({ ownerCallableId }) => ownerCallableId === callable.callableId,
);
const childProcessEffects = [
...childProcessSpawns,
...childProcessInteractions,
].filter(({ ownerCallableId }) => ownerCallableId === callable.callableId);
const networkEffects = requestOperations.filter(
({ ownerCallableId }) => ownerCallableId === callable.callableId,
);
const resourceEffects = resourceOperations.filter(
({ ownerCallableId }) => ownerCallableId === callable.callableId,
);
return [
{
callableId: callable.callableId,
status:
parserPartial ||
state.limitsReached.size > 0 ||
callable.returnCoverage.status !== "complete"
? "partial"
: "complete",
components: {
parameterArity: callableParameterArity(node),
normalizedAstSha256: digest(syntax.normalizedTokens),
controlFlowSha256: digest(syntax.controlFlowTokens),
relationShapeSha256: digest(
relationShape(callable, calls, promiseEffects),
),
literalSetSha256: digest(
[...new Set(syntax.literals)].sort(compareCodePoints),
),
effects: [
...(isAsyncCallable(node) ? (["async"] as const) : []),
...(childProcessEffects.length > 0
? (["child-process"] as const)
: []),
...(eventEffects.length > 0 ? (["event"] as const) : []),
...(networkEffects.length > 0 ? (["network"] as const) : []),
...(promiseEffects.length > 0 ? (["promise"] as const) : []),
...(resourceEffects.length > 0 ? (["resource"] as const) : []),
...(timerEffects.length > 0 ? (["timer"] as const) : []),
],
},
limitations: [
"The fingerprint is a bounded static candidate and does not prove behavioral equivalence.",
...(parserPartial || state.limitsReached.size > 0
? ["Incomplete semantic recovery makes this fingerprint partial."]
: []),
],
},
];
});
};
interface FingerprintSyntax {
readonly normalizedTokens: readonly string[];
readonly controlFlowTokens: readonly string[];
readonly literals: readonly string[];
}
const fingerprintSyntax = (
root: t.Node,
targetCallableId: string,
): FingerprintSyntax => {
const normalizedTokens: string[] = [];
const controlFlowTokens: string[] = [];
const literals: string[] = [];
const callableStack: string[] = [];
traverseJavaScriptAst(root, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null) callableStack.push(callableId);
if (callableStack.at(-1) !== targetCallableId) return;
normalizedTokens.push(normalizedNodeToken(node, parent));
const control = controlFlowToken(node);
if (control !== null) controlFlowTokens.push(control);
const literal = semanticLiteral(node);
if (literal !== null) literals.push(literal);
},
exit: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableStack.at(-1) === callableId)
callableStack.pop();
},
});
return { normalizedTokens, controlFlowTokens, literals };
};
const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => {
if (t.isIdentifier(node))
return propertyIdentity(node, parent)
? `property:${node.name}`
: "identifier";
if (t.isStringLiteral(node))
return propertyIdentity(node, parent)
? `property:${node.value}`
: "string-literal";
const attributes = [
primitiveAttribute(node, "operator"),
primitiveAttribute(node, "kind"),
primitiveAttribute(node, "computed"),
primitiveAttribute(node, "optional"),
primitiveAttribute(node, "async"),
primitiveAttribute(node, "generator"),
].filter((value): value is string => value !== null);
return [node.type, ...attributes].join(":");
};
const primitiveAttribute = (node: t.Node, key: string): string | null => {
const value: unknown = Reflect.get(node, key);
return typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? `${key}=${String(value)}`
: null;
};
const propertyIdentity = (node: t.Node, parent: t.Node | null): boolean => {
if (parent === null) return false;
if (
(t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) &&
parent.property === node
)
return !parent.computed || t.isStringLiteral(node);
if (
(t.isObjectProperty(parent) ||
t.isObjectMethod(parent) ||
t.isClassMethod(parent) ||
t.isClassProperty(parent)) &&
parent.key === node
)
return !parent.computed || t.isStringLiteral(node);
return false;
};
const controlFlowToken = (node: t.Node): string | null => {
if (
!t.isIfStatement(node) &&
!t.isConditionalExpression(node) &&
!t.isSwitchStatement(node) &&
!t.isSwitchCase(node) &&
!t.isLoop(node) &&
!t.isTryStatement(node) &&
!t.isCatchClause(node) &&
!t.isReturnStatement(node) &&
!t.isThrowStatement(node) &&
!t.isAwaitExpression(node) &&
!t.isYieldExpression(node) &&
!t.isLogicalExpression(node)
)
return null;
const operator = primitiveAttribute(node, "operator");
return operator === null ? node.type : `${node.type}:${operator}`;
};
const semanticLiteral = (node: t.Node): string | null => {
if (t.isStringLiteral(node)) return `string:${node.value}`;
if (t.isNumericLiteral(node)) return `number:${String(node.value)}`;
if (t.isBooleanLiteral(node)) return `boolean:${String(node.value)}`;
if (t.isNullLiteral(node)) return "null";
if (t.isBigIntLiteral(node)) return `bigint:${node.value}`;
if (t.isRegExpLiteral(node)) return `regexp:${node.pattern}/${node.flags}`;
if (t.isTemplateElement(node))
return `template:${node.value.cooked ?? node.value.raw}`;
return null;
};
const relationShape = (
callable: JavaScriptSemanticCallable,
calls: JavaScriptSemanticCallAnalysis,
promises: readonly JavaScriptSemanticPromiseOperation[],
): string[] => [
`returns:${String(callable.returnSites.length)}:${callable.returnCoverage.status}`,
...calls.callSites
.filter(({ callerCallableId }) => callerCallableId === callable.callableId)
.map(
({ kind, resolution, arguments: args, calleeCallableIds }) =>
`call:${kind}:${resolution}:${String(args.length)}:${String(calleeCallableIds.length)}`,
),
`captures:${String(
calls.closureCaptures.filter(
({ callableId }) => callableId === callable.callableId,
).length,
)}`,
...promises.map(
({ kind, method, ownership, sourceResolution }) =>
`promise:${kind}:${method}:${ownership}:${sourceResolution}`,
),
];
const callableParameterArity = (node: t.Node): number =>
t.isFunction(node) ? node.params.length : 0;
const isAsyncCallable = (node: t.Node): boolean =>
t.isFunction(node) && node.async;
const digest = (value: readonly string[]): string =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");

View File

@ -13,6 +13,15 @@ export interface JavaScriptSemanticLimits {
readonly maxArgumentFlows: number;
readonly maxCallReturnFlows: number;
readonly maxClosureCaptures: number;
readonly maxPromiseOperations: number;
readonly maxEventOperations: number;
readonly maxTimerOperations: number;
readonly maxChildProcessOperations: number;
readonly maxConfigurationOperations: number;
readonly maxRequestOperations: number;
readonly maxBoundaryOperations: number;
readonly maxResourceOperations: number;
readonly maxObjectOperations: number;
readonly maxFrontiers: number;
readonly maxValueDepth: number;
readonly maxUnionValues: number;
@ -32,6 +41,15 @@ export const DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS: JavaScriptSemanticLimits = {
maxArgumentFlows: 100_000,
maxCallReturnFlows: 100_000,
maxClosureCaptures: 100_000,
maxPromiseOperations: 100_000,
maxEventOperations: 100_000,
maxTimerOperations: 100_000,
maxChildProcessOperations: 100_000,
maxConfigurationOperations: 100_000,
maxRequestOperations: 100_000,
maxBoundaryOperations: 100_000,
maxResourceOperations: 100_000,
maxObjectOperations: 100_000,
maxFrontiers: 20_000,
maxValueDepth: 16,
maxUnionValues: 32,
@ -208,6 +226,218 @@ export interface JavaScriptSemanticClosureCapture {
readonly referenceLocation: JavaScriptSourceRange;
}
/** One bounded promise/task operation recovered from explicit syntax. */
export interface JavaScriptSemanticPromiseOperation {
readonly promiseId: string;
readonly kind:
| "constructor"
| "static"
| "chain"
| "aggregate"
| "awaited-expression";
readonly method:
| "new"
| "resolve"
| "reject"
| "then"
| "catch"
| "finally"
| "all"
| "allSettled"
| "await";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly ownership:
| "awaited"
| "returned"
| "detached"
| "assigned"
| "chained"
| "aggregated"
| "unknown";
readonly ownerBindingId: string | null;
readonly returnSiteId: string | null;
readonly sourcePromiseIds: readonly string[];
readonly sourceResolution: "complete" | "partial" | "unresolved";
}
/** Rename-resistant bounded component commitment for one callable. */
export interface JavaScriptSemanticFunctionFingerprint {
readonly callableId: string;
readonly status: "complete" | "partial" | "unavailable";
readonly components: {
readonly parameterArity: number;
readonly normalizedAstSha256: string;
readonly controlFlowSha256: string;
readonly relationShapeSha256: string;
readonly literalSetSha256: string;
readonly effects: readonly (
| "async"
| "child-process"
| "event"
| "network"
| "promise"
| "resource"
| "timer"
)[];
};
readonly limitations: readonly string[];
}
/** One EventEmitter-style registration, removal, or dispatch candidate. */
export interface JavaScriptSemanticEventOperation {
readonly eventId: string;
readonly kind: "register" | "remove" | "dispatch";
readonly method:
| "on"
| "once"
| "addListener"
| "prependListener"
| "prependOnceListener"
| "off"
| "removeListener"
| "removeAllListeners"
| "emit";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly emitterKey: string;
readonly emitterBindingId: string | null;
readonly eventName: string | null;
readonly listenerBindingId: string | null;
readonly listenerLocation: JavaScriptSourceRange | null;
readonly resolution: "complete" | "partial" | "unresolved";
}
/** One bounded timer scheduling or cancellation candidate. */
export interface JavaScriptSemanticTimerOperation {
readonly timerId: string;
readonly kind: "schedule" | "cancel";
readonly method:
| "setTimeout"
| "setInterval"
| "setImmediate"
| "clearTimeout"
| "clearInterval"
| "clearImmediate";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly handleBindingId: string | null;
readonly linkedTimerId: string | null;
readonly delayMilliseconds: number | null;
readonly resolution: "complete" | "partial" | "unresolved";
}
/** One explicit asynchronous node:child_process creation candidate. */
export interface JavaScriptSemanticChildProcessSpawn {
readonly processId: string;
readonly method: "spawn" | "exec" | "execFile" | "fork";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly command: string | null;
readonly argvCount: number | null;
readonly environmentSupplied: boolean;
readonly stdioMode: string;
readonly resolution: "complete" | "partial";
}
/** One exit/error listener or signal operation linked to child candidates. */
export interface JavaScriptSemanticChildProcessInteraction {
readonly interactionId: string;
readonly kind: "listener" | "signal";
readonly method: "on" | "once" | "addListener" | "kill";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly processBindingId: string | null;
readonly linkedProcessIds: readonly string[];
readonly eventName: "exit" | "error" | null;
readonly signalName: string | null;
readonly listenerLocation: JavaScriptSourceRange | null;
readonly resolution: "complete" | "partial";
}
/** One environment, argv, file, or default configuration source. */
export interface JavaScriptSemanticConfigurationOperation {
readonly configId: string;
readonly kind: "environment" | "argv" | "file" | "default";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly key: string | null;
readonly sourceConfigId: string | null;
readonly value: string | number | boolean | null;
readonly resolution: "complete" | "partial";
}
/** One request construction or response-consumer candidate. */
export interface JavaScriptSemanticRequestOperation {
readonly requestId: string;
readonly kind: "request" | "response-consumer";
readonly method:
| "fetch"
| "WebSocket"
| "request"
| "get"
| "json"
| "text"
| "arrayBuffer";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly linkedRequestIds: readonly string[];
readonly endpoint: string | null;
readonly fields: readonly {
readonly name: string;
readonly sourceBindingId: string | null;
}[];
readonly resolution: "complete" | "partial" | "unresolved";
}
/** One parse, coercion, or validation boundary candidate. */
export interface JavaScriptSemanticBoundaryOperation {
readonly boundaryId: string;
readonly kind: "parse" | "coerce" | "validate";
readonly method: string;
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly sourceBindingId: string | null;
readonly resultBindingId: string | null;
readonly resolution: "complete" | "partial";
}
/** One explicit built-in resource acquisition or release candidate. */
export interface JavaScriptSemanticResourceOperation {
readonly resourceId: string;
readonly kind: "acquire" | "release";
readonly method:
| "open"
| "openSync"
| "createReadStream"
| "createWriteStream"
| "connect"
| "createConnection"
| "close"
| "destroy"
| "end";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
readonly linkedResourceIds: readonly string[];
readonly resolution: "complete" | "partial";
}
/** One static property read/write, spread, or destructuring candidate. */
export interface JavaScriptSemanticObjectOperation {
readonly objectOperationId: string;
readonly kind: "read" | "write" | "spread" | "destructure";
readonly location: JavaScriptSourceRange;
readonly ownerCallableId: string | null;
readonly objectBindingId: string | null;
readonly targetBindingId: string | null;
readonly propertyName: string | null;
readonly resolution: "complete" | "partial";
}
/** One syntax location where exact local semantic continuation is unavailable. */
export interface JavaScriptSemanticFrontier {
readonly kind: "dynamic-call" | "dynamic-property";
@ -247,10 +477,10 @@ interface JavaScriptSemanticCoverage {
readonly limitsReached: readonly (keyof JavaScriptSemanticLimits)[];
}
/** Provider-neutral JavaScript semantic IR v3. */
/** Provider-neutral JavaScript semantic IR v4. */
export interface JavaScriptSemanticIr {
readonly schema: "JavaScriptSemanticIR";
readonly schemaVersion: 3;
readonly schemaVersion: 4;
readonly scopes: readonly JavaScriptSemanticScope[];
readonly bindings: readonly JavaScriptSemanticBinding[];
readonly callables: readonly JavaScriptSemanticCallable[];
@ -261,6 +491,17 @@ export interface JavaScriptSemanticIr {
readonly callReturnFlows: readonly JavaScriptSemanticCallReturnFlow[];
readonly callResultFlows: readonly JavaScriptSemanticCallResultFlow[];
readonly closureCaptures: readonly JavaScriptSemanticClosureCapture[];
readonly promiseOperations: readonly JavaScriptSemanticPromiseOperation[];
readonly eventOperations: readonly JavaScriptSemanticEventOperation[];
readonly timerOperations: readonly JavaScriptSemanticTimerOperation[];
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[];
readonly requestOperations: readonly JavaScriptSemanticRequestOperation[];
readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[];
readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[];
readonly objectOperations: readonly JavaScriptSemanticObjectOperation[];
readonly functionFingerprints: readonly JavaScriptSemanticFunctionFingerprint[];
readonly frontiers: readonly JavaScriptSemanticFrontier[];
readonly coverage: JavaScriptSemanticCoverage;
readonly limitations: readonly string[];
@ -287,7 +528,7 @@ export const semanticBinding = (
/** Fail-closed result when Babel cannot produce an inert syntax tree. */
export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({
schema: "JavaScriptSemanticIR",
schemaVersion: 3,
schemaVersion: 4,
scopes: [],
bindings: [],
callables: [],
@ -298,6 +539,17 @@ export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({
callReturnFlows: [],
callResultFlows: [],
closureCaptures: [],
promiseOperations: [],
eventOperations: [],
timerOperations: [],
childProcessSpawns: [],
childProcessInteractions: [],
configurationOperations: [],
requestOperations: [],
boundaryOperations: [],
resourceOperations: [],
objectOperations: [],
functionFingerprints: [],
frontiers: [],
coverage: { status: "failed", omittedCount: null, limitsReached: [] },
limitations: [

View File

@ -0,0 +1,181 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticObjectOperation,
} from "./javascriptSemanticIr.js";
import {
reachSemanticLimit,
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { propertyName, range } from "./javascriptStaticAnalysisHelpers.js";
/** Recover bounded static property reads, writes, spreads, and destructuring. */
export const collectJavaScriptSemanticObjects = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticObjectOperation[] => {
const output: JavaScriptSemanticObjectOperation[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack: string[] = [];
const context: ObjectCollectionContext = { state, output };
traverseJavaScriptAst(program, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableIds.has(callableId))
callableStack.push(callableId);
const ownerCallableId = callableStack.at(-1) ?? null;
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
collectMember(node, parent, ownerCallableId, context);
else if (
t.isSpreadElement(node) &&
parent !== null &&
t.isObjectExpression(parent)
)
addObjectOperation(
{
node,
kind: "spread",
ownerCallableId,
objectBindingId: expressionBindingId(node.argument, state),
targetBindingId: null,
propertyName: null,
},
state,
output,
);
else if (t.isVariableDeclarator(node) && t.isObjectPattern(node.id))
collectDestructuring(node, ownerCallableId, state, output);
},
exit: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableStack.at(-1) === callableId)
callableStack.pop();
},
});
return output;
};
interface ObjectCollectionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly output: JavaScriptSemanticObjectOperation[];
}
const collectMember = (
node: t.MemberExpression | t.OptionalMemberExpression,
parent: t.Node | null,
ownerCallableId: string | null,
context: ObjectCollectionContext,
): void => {
const { state, output } = context;
const name = semanticStaticPropertyName(node.property, node.computed);
if (name.length === 0) return;
const write =
(t.isAssignmentExpression(parent) && parent.left === node) ||
(t.isUpdateExpression(parent) && parent.argument === node);
addObjectOperation(
{
node,
kind: write ? "write" : "read",
ownerCallableId,
objectBindingId: expressionBindingId(node.object, state),
targetBindingId: null,
propertyName: name,
},
state,
output,
);
};
const collectDestructuring = (
node: t.VariableDeclarator,
ownerCallableId: string | null,
state: JavaScriptSemanticAnalysisState,
output: JavaScriptSemanticObjectOperation[],
): void => {
if (!t.isObjectPattern(node.id)) return;
const objectBindingId = expressionBindingId(node.init, state);
for (const property of node.id.properties) {
if (!t.isObjectProperty(property)) continue;
const name = propertyName(property.key);
if (name.length === 0) continue;
const target = bindingIdentifier(property.value);
addObjectOperation(
{
node: property,
kind: "destructure",
ownerCallableId,
objectBindingId,
targetBindingId:
target === null
? null
: (resolveSemanticBindingState(state, target, target.name)
?.bindingId ?? null),
propertyName: name,
},
state,
output,
);
}
};
interface AddObjectOperationInput {
readonly node: t.Node;
readonly kind: JavaScriptSemanticObjectOperation["kind"];
readonly ownerCallableId: string | null;
readonly objectBindingId: string | null;
readonly targetBindingId: string | null;
readonly propertyName: string | null;
}
const addObjectOperation = (
input: AddObjectOperationInput,
state: JavaScriptSemanticAnalysisState,
output: JavaScriptSemanticObjectOperation[],
): void => {
if (output.length >= state.limits.maxObjectOperations) {
reachSemanticLimit(state, "maxObjectOperations");
return;
}
output.push({
objectOperationId: `object:${input.kind}:${String(input.node.start ?? -1)}:${String(input.node.end ?? -1)}`,
kind: input.kind,
location: range(input.node),
ownerCallableId: input.ownerCallableId,
objectBindingId: input.objectBindingId,
targetBindingId: input.targetBindingId,
propertyName: input.propertyName,
resolution:
input.kind === "spread"
? input.objectBindingId === null
? "partial"
: "complete"
: input.objectBindingId === null ||
input.propertyName === null ||
(input.kind === "destructure" && input.targetBindingId === null)
? "partial"
: "complete",
});
};
const expressionBindingId = (
node: t.Node | null | undefined,
state: JavaScriptSemanticAnalysisState,
): string | null =>
t.isIdentifier(node)
? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null)
: null;
const bindingIdentifier = (node: t.Node): t.Identifier | null => {
if (t.isIdentifier(node)) return node;
if (t.isAssignmentPattern(node) && t.isIdentifier(node.left))
return node.left;
return null;
};

View File

@ -0,0 +1,478 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticPromiseOperation,
} from "./javascriptSemanticIr.js";
import {
reachSemanticLimit,
semanticCallableIdForNode,
semanticStaticPropertyName,
} from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareCodePoints, range } from "./javascriptStaticAnalysisHelpers.js";
type PromiseMethod = JavaScriptSemanticPromiseOperation["method"];
type PromiseKind = JavaScriptSemanticPromiseOperation["kind"];
interface PromiseCandidate {
readonly node: t.Node;
readonly kind: PromiseKind;
readonly method: PromiseMethod;
readonly promiseId: string;
readonly ownerCallableId: string | null;
readonly ancestors: readonly t.Node[];
}
interface PromiseResolution {
readonly promiseIds: readonly string[];
readonly status: JavaScriptSemanticPromiseOperation["sourceResolution"];
}
interface PromiseOwnership {
readonly ownership: JavaScriptSemanticPromiseOperation["ownership"];
readonly ownerBindingId: string | null;
readonly returnSiteId: string | null;
}
interface PromiseExpressionResolutionContext {
readonly current: PromiseCandidate;
readonly candidateByNode: WeakMap<t.Node, PromiseCandidate>;
readonly state: JavaScriptSemanticAnalysisState;
readonly seenBindings: ReadonlySet<string>;
readonly depth: number;
}
interface PromiseOwnershipContext {
readonly candidate: PromiseCandidate;
readonly candidateByNode: WeakMap<t.Node, PromiseCandidate>;
readonly state: JavaScriptSemanticAnalysisState;
readonly callables: readonly JavaScriptSemanticCallable[];
}
/** Recover explicit Promise construction, chaining, aggregation, and ownership. */
export const collectJavaScriptSemanticPromises = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticPromiseOperation[] => {
const candidates = collectCandidates(program, state, callables);
const candidateByNode = new WeakMap<t.Node, PromiseCandidate>();
for (const candidate of candidates)
candidateByNode.set(candidate.node, candidate);
return candidates.map((candidate) => {
const ownership = promiseOwnership(
candidate,
candidateByNode,
state,
callables,
);
const sources = promiseSources(candidate, candidateByNode, state);
return {
promiseId: candidate.promiseId,
kind: candidate.kind,
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
...ownership,
sourcePromiseIds: [...new Set(sources.promiseIds)].sort(
compareCodePoints,
),
sourceResolution: sources.status,
};
});
};
const collectCandidates = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): PromiseCandidate[] => {
const output: PromiseCandidate[] = [];
const admittedCallables = new Set(
callables.map(({ callableId }) => callableId),
);
const callableStack: string[] = [];
const ancestors: t.Node[] = [];
traverseJavaScriptAst(program, {
enter: (node) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && admittedCallables.has(callableId))
callableStack.push(callableId);
const details = promiseCandidateDetails(node, state);
if (details !== null) {
if (output.length >= state.limits.maxPromiseOperations)
reachSemanticLimit(state, "maxPromiseOperations");
else
output.push({
node,
...details,
promiseId: semanticPromiseId(details.kind, node),
ownerCallableId: callableStack.at(-1) ?? null,
ancestors: [...ancestors],
});
}
ancestors.push(node);
},
exit: (node) => {
ancestors.pop();
const callableId = semanticCallableIdForNode(node);
if (callableId !== null && callableStack.at(-1) === callableId)
callableStack.pop();
},
});
return output;
};
const promiseCandidateDetails = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): Pick<PromiseCandidate, "kind" | "method"> | null => {
if (
t.isNewExpression(node) &&
t.isIdentifier(node.callee, { name: "Promise" }) &&
isUnshadowedGlobalPromise(node.callee, state)
)
return { kind: "constructor", method: "new" };
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const member = memberCallee(node);
if (member === null) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
if (
t.isIdentifier(member.object, { name: "Promise" }) &&
isUnshadowedGlobalPromise(member.object, state)
) {
if (method === "all" || method === "allSettled")
return { kind: "aggregate", method };
if (method === "resolve" || method === "reject")
return { kind: "static", method };
}
if (method === "then" || method === "catch" || method === "finally")
return { kind: "chain", method };
}
if (t.isAwaitExpression(node) && !isPromiseProducer(node.argument, state))
return { kind: "awaited-expression", method: "await" };
return null;
};
const isUnshadowedGlobalPromise = (
node: t.Identifier,
state: JavaScriptSemanticAnalysisState,
): boolean =>
resolveSemanticBindingState(state, node, "Promise") === undefined &&
!semanticResolutionBlocked(state, node, "Promise");
const isPromiseProducer = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean => promiseCandidateDetails(unwrapExpression(node), state) !== null;
const semanticPromiseId = (kind: PromiseKind, node: t.Node): string =>
`promise:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
const memberCallee = (
node: t.CallExpression | t.OptionalCallExpression,
): t.MemberExpression | t.OptionalMemberExpression | null =>
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;
const promiseOwnership = (
candidate: PromiseCandidate,
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): PromiseOwnership => {
if (candidate.kind === "awaited-expression") return emptyOwnership("awaited");
const context: PromiseOwnershipContext = {
candidate,
candidateByNode,
state,
callables,
};
for (let index = candidate.ancestors.length - 1; index >= 0; index -= 1) {
const ancestor = candidate.ancestors[index];
if (ancestor === undefined) continue;
const ownership = ownershipAtAncestor(ancestor, context);
if (ownership === "boundary") break;
if (ownership !== null) return ownership;
}
return emptyOwnership("unknown");
};
const ownershipAtAncestor = (
ancestor: t.Node,
context: PromiseOwnershipContext,
): PromiseOwnership | "boundary" | null => {
const { candidate, candidateByNode, state, callables } = context;
if (t.isAwaitExpression(ancestor)) return emptyOwnership("awaited");
const outer = candidateByNode.get(ancestor);
if (
outer !== undefined &&
outer.ownerCallableId === candidate.ownerCallableId &&
outerConsumesCandidate(outer, candidate)
)
return outer.kind === "chain"
? emptyOwnership("chained")
: emptyOwnership("aggregated");
const assigned = assignedPromiseBinding(ancestor, state);
if (assigned !== undefined)
return {
ownership: "assigned",
ownerBindingId: assigned,
returnSiteId: null,
};
if (t.isReturnStatement(ancestor))
return returnedPromiseOwnership(
candidate.ownerCallableId,
range(ancestor),
callables,
);
if (t.isExpressionStatement(ancestor)) return emptyOwnership("detached");
const callableId = semanticCallableIdForNode(ancestor);
if (callableId === null || callableId !== candidate.ownerCallableId)
return null;
return t.isArrowFunctionExpression(ancestor) &&
ancestor.body === candidate.node
? returnedPromiseOwnership(
candidate.ownerCallableId,
range(candidate.node),
callables,
)
: "boundary";
};
const assignedPromiseBinding = (
ancestor: t.Node,
state: JavaScriptSemanticAnalysisState,
): string | null | undefined => {
if (
t.isVariableDeclarator(ancestor) &&
ancestor.init !== null &&
t.isIdentifier(ancestor.id)
)
return (
resolveSemanticBindingState(state, ancestor.id, ancestor.id.name)
?.bindingId ?? null
);
if (t.isAssignmentExpression(ancestor) && t.isIdentifier(ancestor.left))
return (
resolveSemanticBindingState(state, ancestor.left, ancestor.left.name)
?.bindingId ?? null
);
return undefined;
};
const returnedPromiseOwnership = (
callableId: string | null,
location: JavaScriptSemanticPromiseOperation["location"],
callables: readonly JavaScriptSemanticCallable[],
): PromiseOwnership => ({
ownership: "returned",
ownerBindingId: null,
returnSiteId: matchingReturnSite(callableId, location, callables),
});
const outerConsumesCandidate = (
outer: PromiseCandidate,
candidate: PromiseCandidate,
): boolean => {
if (
outer.kind === "chain" &&
(t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node))
) {
const member = memberCallee(outer.node);
return member !== null && containsNode(member.object, candidate.node);
}
if (
outer.kind === "aggregate" &&
(t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node))
) {
const input = outer.node.arguments[0];
return t.isNode(input) && containsNode(input, candidate.node);
}
return false;
};
const containsNode = (outer: t.Node, inner: t.Node): boolean =>
outer.start !== null &&
outer.start !== undefined &&
outer.end !== null &&
outer.end !== undefined &&
inner.start !== null &&
inner.start !== undefined &&
inner.end !== null &&
inner.end !== undefined &&
outer.start <= inner.start &&
outer.end >= inner.end;
const emptyOwnership = (
ownership: JavaScriptSemanticPromiseOperation["ownership"],
): PromiseOwnership => ({
ownership,
ownerBindingId: null,
returnSiteId: null,
});
const matchingReturnSite = (
callableId: string | null,
location: JavaScriptSemanticPromiseOperation["location"],
callables: readonly JavaScriptSemanticCallable[],
): string | null =>
callables
.find(({ callableId: candidate }) => candidate === callableId)
?.returnSites.find(({ location: candidate }) =>
rangesEqual(candidate, location),
)?.returnSiteId ?? null;
const promiseSources = (
candidate: PromiseCandidate,
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
state: JavaScriptSemanticAnalysisState,
): PromiseResolution => {
if (candidate.kind === "chain") {
const member =
t.isCallExpression(candidate.node) ||
t.isOptionalCallExpression(candidate.node)
? memberCallee(candidate.node)
: null;
return member === null
? unresolvedPromise()
: resolvePromiseExpression(
member.object,
promiseResolutionContext(candidate, candidateByNode, state),
);
}
if (candidate.kind === "aggregate")
return aggregateSources(candidate, candidateByNode, state);
if (candidate.kind === "awaited-expression")
return t.isAwaitExpression(candidate.node)
? resolvePromiseExpression(
candidate.node.argument,
promiseResolutionContext(candidate, candidateByNode, state),
)
: unresolvedPromise();
return { promiseIds: [], status: "complete" };
};
const aggregateSources = (
candidate: PromiseCandidate,
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
state: JavaScriptSemanticAnalysisState,
): PromiseResolution => {
if (
!t.isCallExpression(candidate.node) &&
!t.isOptionalCallExpression(candidate.node)
)
return unresolvedPromise();
const input = candidate.node.arguments[0];
if (!t.isArrayExpression(input)) return unresolvedPromise();
const resolutions = input.elements.map((element) => {
if (element === null) return completePromise();
if (t.isSpreadElement(element)) return unresolvedPromise();
if (isObviouslyNonPromise(element)) return completePromise();
return resolvePromiseExpression(
element,
promiseResolutionContext(candidate, candidateByNode, state),
);
});
return combinePromiseResolutions(resolutions);
};
const resolvePromiseExpression = (
rawNode: t.Node,
context: PromiseExpressionResolutionContext,
): PromiseResolution => {
const { current, candidateByNode, state, seenBindings, depth } = context;
if (depth >= state.limits.maxValueDepth) return unresolvedPromise();
const node = unwrapExpression(rawNode);
const direct = candidateByNode.get(node);
if (direct !== undefined && direct.promiseId !== current.promiseId)
return { promiseIds: [direct.promiseId], status: "complete" };
if (!t.isIdentifier(node)) return unresolvedPromise();
const binding = resolveSemanticBindingState(state, node, node.name);
if (
binding === undefined ||
binding.mutable ||
binding.initializers.length !== 1 ||
seenBindings.has(binding.bindingId)
)
return unresolvedPromise();
const initializer = binding.initializers[0];
if (initializer === undefined || initializer.projection.length > 0)
return unresolvedPromise();
const nestedSeen = new Set(seenBindings);
nestedSeen.add(binding.bindingId);
return resolvePromiseExpression(initializer.node, {
...context,
seenBindings: nestedSeen,
depth: depth + 1,
});
};
const promiseResolutionContext = (
current: PromiseCandidate,
candidateByNode: WeakMap<t.Node, PromiseCandidate>,
state: JavaScriptSemanticAnalysisState,
): PromiseExpressionResolutionContext => ({
current,
candidateByNode,
state,
seenBindings: new Set(),
depth: 0,
});
const unwrapExpression = (node: t.Node): t.Node => {
if (
t.isParenthesizedExpression(node) ||
t.isTSAsExpression(node) ||
t.isTSSatisfiesExpression(node) ||
t.isTSNonNullExpression(node) ||
t.isTypeCastExpression(node)
)
return unwrapExpression(node.expression);
return node;
};
const combinePromiseResolutions = (
resolutions: readonly PromiseResolution[],
): PromiseResolution => ({
promiseIds: resolutions.flatMap(({ promiseIds }) => promiseIds),
status: resolutions.some(({ status }) => status === "unresolved")
? resolutions.some(({ status }) => status === "complete")
? "partial"
: "unresolved"
: resolutions.some(({ status }) => status === "partial")
? "partial"
: "complete",
});
const completePromise = (): PromiseResolution => ({
promiseIds: [],
status: "complete",
});
const unresolvedPromise = (): PromiseResolution => ({
promiseIds: [],
status: "unresolved",
});
const isObviouslyNonPromise = (node: t.Node): boolean =>
t.isLiteral(node) ||
t.isObjectExpression(node) ||
t.isFunction(node) ||
t.isClassExpression(node);
const rangesEqual = (
left: JavaScriptSemanticPromiseOperation["location"],
right: JavaScriptSemanticPromiseOperation["location"],
): boolean =>
left.start.line === right.start.line &&
left.start.column === right.start.column &&
left.end.line === right.end.line &&
left.end.column === right.end.column;

View File

@ -0,0 +1,215 @@
import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticResourceOperation,
} from "./javascriptSemanticIr.js";
import { semanticStaticPropertyName } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
dataEffectLimitReached,
outerDataEffectBinding,
traverseDataEffects,
type DataEffectTraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
const ACQUIRE_METHODS = [
"open",
"openSync",
"createReadStream",
"createWriteStream",
"connect",
"createConnection",
] as const;
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
interface ResourceCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticResourceOperation["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
}
/** Recover built-in filesystem/network acquisition and local release handles. */
export const collectJavaScriptSemanticResources = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticResourceOperation[] => {
const context: DataEffectTraversalContext = {
state,
callableIds: new Set(callables.map(({ callableId }) => callableId)),
callableStack: [],
ancestors: [],
};
const acquisitions = collectAcquisitions(program, context);
return [
...acquisitions.map(immutableAcquisition),
...collectReleases(program, context, acquisitions),
];
};
const collectAcquisitions = (
program: t.Program,
context: DataEffectTraversalContext,
): ResourceCandidate[] => {
const output: ResourceCandidate[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const method = acquisitionMethod(node.callee, context.state);
if (method === null) return;
if (resourceLimitReached(output.length, context.state)) return;
output.push({
node,
method,
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerDataEffectBinding(node, context),
});
});
return output;
};
const collectReleases = (
program: t.Program,
context: DataEffectTraversalContext,
acquisitions: readonly ResourceCandidate[],
): JavaScriptSemanticResourceOperation[] => {
const byBinding = new Map<string, ResourceCandidate[]>();
for (const acquisition of acquisitions) {
if (acquisition.resultBindingId === null) continue;
const existing = byBinding.get(acquisition.resultBindingId) ?? [];
existing.push(acquisition);
byBinding.set(acquisition.resultBindingId, existing);
}
const output: JavaScriptSemanticResourceOperation[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const member = memberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return;
const method = RELEASE_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(member.property, member.computed),
);
if (method === undefined) return;
const binding = resolveSemanticBindingState(
context.state,
member.object,
member.object.name,
);
const linked =
binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []);
if (linked.length === 0) return;
if (
resourceLimitReached(acquisitions.length + output.length, context.state)
)
return;
output.push({
resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "release",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: binding?.bindingId ?? null,
linkedResourceIds: linked.map(resourceCandidateId).sort(),
resolution: linked.length === 1 ? "complete" : "partial",
});
});
return output;
};
const immutableAcquisition = (
candidate: ResourceCandidate,
): JavaScriptSemanticResourceOperation => ({
resourceId: resourceCandidateId(candidate),
kind: "acquire",
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
linkedResourceIds: [],
resolution: "complete",
});
const acquisitionMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticResourceOperation["method"] | null => {
if (t.isIdentifier(callee)) {
const binding = resolveSemanticBindingState(state, callee, callee.name);
return methodFromBinding(binding);
}
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return null;
const method = ACQUIRE_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(callee.property, callee.computed),
);
if (method === undefined || !t.isIdentifier(callee.object)) return null;
const binding = resolveSemanticBindingState(
state,
callee.object,
callee.object.name,
);
return resourceNamespace(binding) ? method : null;
};
const methodFromBinding = (
binding: JavaScriptSemanticBindingState | undefined,
): JavaScriptSemanticResourceOperation["method"] | null => {
for (const { specifier, importedPath } of binding?.directOrigins ?? []) {
if (!resourceModule(specifier)) continue;
const method = ACQUIRE_METHODS.find(
(candidate) => candidate === importedPath.at(-1),
);
if (method !== undefined) return method;
}
return null;
};
const resourceNamespace = (
binding: JavaScriptSemanticBindingState | undefined,
): boolean =>
binding?.directOrigins.some(
({ specifier, importedPath }) =>
resourceModule(specifier) && importedPath.length === 0,
) ?? false;
const resourceModule = (specifier: string): boolean =>
[
"fs",
"fs/promises",
"net",
"node:fs",
"node:fs/promises",
"node:net",
"node:tls",
"tls",
].includes(specifier);
const resourceLimitReached = (
retained: number,
state: JavaScriptSemanticAnalysisState,
): boolean =>
dataEffectLimitReached(
retained,
state.limits.maxResourceOperations,
"maxResourceOperations",
state,
);
const resourceCandidateId = (candidate: ResourceCandidate): string =>
`resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
const memberCallee = (
node: t.CallExpression | t.OptionalCallExpression,
): t.MemberExpression | t.OptionalMemberExpression | null =>
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;

View File

@ -210,7 +210,7 @@ describe("JavaScript semantic analysis", () => {
const result = alias(input, { mode: "fast" });
`);
expect(ir.schemaVersion).toBe(3);
expect(ir.schemaVersion).toBe(4);
const render = onlyCallable(ir, "render");
const call = ir.callSites.find(
({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId,
@ -253,6 +253,139 @@ describe("JavaScript semantic analysis", () => {
expect(ir.frontiers).toEqual([]);
});
it("recovers explicit Promise ownership, chains, aggregation, and awaits", () => {
const ir = analyzeJavaScriptSemantics(`
async function run(value) {
const base = Promise.resolve(value);
const chained = base.then(work).finally(cleanup);
await chained;
Promise.resolve(value).then(work);
return Promise.all([base, Promise.resolve(value)]);
}
const settled = Promise.allSettled([Promise.resolve(1)]);
`);
expect(
ir.promiseOperations.map(
({ kind, method, ownership, sourceResolution }) => ({
kind,
method,
ownership,
sourceResolution,
}),
),
).toEqual(
expect.arrayContaining([
{
kind: "static",
method: "resolve",
ownership: "assigned",
sourceResolution: "complete",
},
{
kind: "chain",
method: "then",
ownership: "chained",
sourceResolution: "complete",
},
{
kind: "chain",
method: "finally",
ownership: "assigned",
sourceResolution: "complete",
},
{
kind: "awaited-expression",
method: "await",
ownership: "awaited",
sourceResolution: "complete",
},
{
kind: "chain",
method: "then",
ownership: "detached",
sourceResolution: "complete",
},
{
kind: "aggregate",
method: "all",
ownership: "returned",
sourceResolution: "complete",
},
{
kind: "aggregate",
method: "allSettled",
ownership: "assigned",
sourceResolution: "complete",
},
]),
);
const aggregate = ir.promiseOperations.find(
({ method }) => method === "all",
);
expect(aggregate?.sourcePromiseIds).toHaveLength(2);
expect(aggregate?.returnSiteId).not.toBeNull();
});
it("does not treat a shadowed Promise binding as the intrinsic", () => {
const ir = analyzeJavaScriptSemantics(`
function run(Promise) {
return Promise.resolve(1).then(work);
}
`);
expect(ir.promiseOperations).toEqual([
expect.objectContaining({
kind: "chain",
method: "then",
ownership: "returned",
sourcePromiseIds: [],
sourceResolution: "unresolved",
}),
]);
});
it("bounds Promise recovery with an explicit frontier", () => {
const ir = analyzeJavaScriptSemantics(
`
Promise.resolve(1);
Promise.resolve(2);
Promise.resolve(3);
`,
{ maxPromiseOperations: 1 },
);
expect(ir.promiseOperations).toHaveLength(1);
expect(ir.coverage).toMatchObject({
status: "truncated",
limitsReached: ["maxPromiseOperations"],
});
});
it("fingerprints formatting and local-name changes identically", () => {
const left = analyzeJavaScriptSemantics(`
function calculate(value) {
const result = value + 1;
return result;
}
`);
const right = analyzeJavaScriptSemantics(
"function renamed(input){const output=input+1;return output}",
);
const changed = analyzeJavaScriptSemantics(
"function renamed(input){const output=input+2;return output}",
);
expect(left.functionFingerprints).toHaveLength(1);
expect(right.functionFingerprints).toHaveLength(1);
expect(left.functionFingerprints[0]?.components).toEqual(
right.functionFingerprints[0]?.components,
);
expect(
changed.functionFingerprints[0]?.components.literalSetSha256,
).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256);
});
it("retains ambiguous local callees and explicit dynamic frontiers", () => {
const ir = analyzeJavaScriptSemantics(`
function left(value) { return value; }
@ -295,6 +428,323 @@ describe("JavaScript semantic analysis", () => {
);
});
it("recovers EventEmitter candidates and imported timer handle cancellation", () => {
const ir = analyzeJavaScriptSemantics(`
import {
setTimeout as later,
clearTimeout as cancel,
} from "node:timers";
function run(bus, dynamicName) {
const handle = later(work, 25);
bus.on("ready", handler);
bus.once(dynamicName, handler);
bus.emit("ready");
bus.off("ready", handler);
cancel(handle);
}
`);
expect(
ir.eventOperations.map(({ kind, method, eventName, resolution }) => ({
kind,
method,
eventName,
resolution,
})),
).toEqual([
{
kind: "register",
method: "on",
eventName: "ready",
resolution: "complete",
},
{
kind: "register",
method: "once",
eventName: null,
resolution: "unresolved",
},
{
kind: "dispatch",
method: "emit",
eventName: "ready",
resolution: "complete",
},
{
kind: "remove",
method: "off",
eventName: "ready",
resolution: "complete",
},
]);
expect(ir.timerOperations).toEqual([
expect.objectContaining({
kind: "schedule",
method: "setTimeout",
delayMilliseconds: 25,
resolution: "complete",
}),
expect.objectContaining({
kind: "cancel",
method: "clearTimeout",
delayMilliseconds: null,
resolution: "complete",
linkedTimerId: ir.timerOperations[0]?.timerId,
}),
]);
expect(ir.functionFingerprints[0]?.components.effects).toEqual([
"event",
"timer",
]);
});
it("does not treat shadowed timer globals as Node timers", () => {
const ir = analyzeJavaScriptSemantics(`
function run(setTimeout, clearTimeout) {
const handle = setTimeout(work, 1);
clearTimeout(handle);
}
`);
expect(ir.timerOperations).toEqual([]);
});
it("recovers child-process argv, env, stdio, listeners, and signals", () => {
const ir = analyzeJavaScriptSemantics(`
import { spawn as launch } from "node:child_process";
function run() {
const child = launch(
"/bin/tool",
["--mode", "fast"],
{ env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] },
);
child.on("exit", onExit);
child.once("error", onError);
child.kill("SIGINT");
}
`);
expect(ir.childProcessSpawns).toEqual([
expect.objectContaining({
method: "spawn",
command: "/bin/tool",
argvCount: 2,
environmentSupplied: true,
stdioMode: "array",
resolution: "complete",
}),
]);
expect(
ir.childProcessInteractions.map(
({ kind, eventName, signalName, resolution }) => ({
kind,
eventName,
signalName,
resolution,
}),
),
).toEqual([
{
kind: "listener",
eventName: "exit",
signalName: null,
resolution: "complete",
},
{
kind: "listener",
eventName: "error",
signalName: null,
resolution: "complete",
},
{
kind: "signal",
eventName: null,
signalName: "SIGINT",
resolution: "complete",
},
]);
expect(ir.functionFingerprints[0]?.components.effects).toContain(
"child-process",
);
});
it("recovers configuration, requests, response consumers, and boundaries", () => {
const ir = analyzeJavaScriptSemantics(`
import { readFileSync } from "node:fs";
import * as http from "node:http";
async function run(schema) {
const endpoint = process.env.API_URL ?? "https://fallback.test";
const mode = process.argv[2];
const raw = readFileSync("./config.json", "utf8");
const parsed = JSON.parse(raw);
const port = Number(process.env.PORT);
const validated = schema.parse(parsed);
const response = await fetch(endpoint, {
method: "POST",
body: validated,
});
const body = await response.json();
http.request("https://audit.test", { body });
return { mode, port, body };
}
`);
expect(
ir.configurationOperations.map(({ kind, key }) => ({ kind, key })),
).toEqual(
expect.arrayContaining([
{ kind: "environment", key: "API_URL" },
{ kind: "default", key: "API_URL" },
{ kind: "argv", key: "2" },
{ kind: "file", key: "./config.json" },
{ kind: "environment", key: "PORT" },
]),
);
expect(
ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({
kind,
method,
endpoint,
resolution,
})),
).toEqual(
expect.arrayContaining([
{
kind: "request",
method: "fetch",
endpoint: null,
resolution: "partial",
},
{
kind: "response-consumer",
method: "json",
endpoint: null,
resolution: "complete",
},
{
kind: "request",
method: "request",
endpoint: "https://audit.test",
resolution: "complete",
},
]),
);
expect(
ir.boundaryOperations.map(({ kind, method, resolution }) => ({
kind,
method,
resolution,
})),
).toEqual(
expect.arrayContaining([
{ kind: "parse", method: "JSON.parse", resolution: "complete" },
{ kind: "coerce", method: "Number", resolution: "complete" },
{ kind: "parse", method: "parse", resolution: "partial" },
]),
);
expect(ir.functionFingerprints[0]?.components.effects).toContain("network");
});
it("recovers built-in resource acquisition and exact local release", () => {
const ir = analyzeJavaScriptSemantics(`
import { open } from "node:fs/promises";
import { connect as dial } from "node:net";
async function run(path) {
const file = await open(path);
const socket = dial({ port: 9000 });
await file.close();
socket.destroy();
}
`);
expect(
ir.resourceOperations.map(({ kind, method, resolution }) => ({
kind,
method,
resolution,
})),
).toEqual([
{ kind: "acquire", method: "open", resolution: "complete" },
{ kind: "acquire", method: "connect", resolution: "complete" },
{ kind: "release", method: "close", resolution: "complete" },
{ kind: "release", method: "destroy", resolution: "complete" },
]);
expect(ir.functionFingerprints[0]?.components.effects).toContain(
"resource",
);
});
it("bounds every added semantic effect family independently", () => {
const ir = analyzeJavaScriptSemantics(
`
import { spawn } from "node:child_process";
import { open } from "node:fs/promises";
Promise.resolve(1);
bus.on("ready", handler);
setTimeout(work, 1);
spawn("/bin/tool");
const endpoint = process.env.API_URL;
fetch("https://example.test");
JSON.parse(raw);
open(path);
`,
{
maxPromiseOperations: 0,
maxEventOperations: 0,
maxTimerOperations: 0,
maxChildProcessOperations: 0,
maxConfigurationOperations: 0,
maxRequestOperations: 0,
maxBoundaryOperations: 0,
maxResourceOperations: 0,
maxObjectOperations: 0,
},
);
expect(ir.coverage.limitsReached).toEqual(
expect.arrayContaining([
"maxBoundaryOperations",
"maxChildProcessOperations",
"maxConfigurationOperations",
"maxEventOperations",
"maxPromiseOperations",
"maxRequestOperations",
"maxResourceOperations",
"maxTimerOperations",
"maxObjectOperations",
]),
);
});
it("recovers static object reads, writes, spreads, and destructuring", () => {
const ir = analyzeJavaScriptSemantics(`
const source = { token: "TOKEN", count: 1 };
const { token } = source;
const copy = { ...source };
source.count = 2;
const read = source.token;
`);
expect(
ir.objectOperations.map(({ kind, propertyName, resolution }) => ({
kind,
propertyName,
resolution,
})),
).toEqual(
expect.arrayContaining([
{
kind: "destructure",
propertyName: "token",
resolution: "complete",
},
{ kind: "spread", propertyName: null, resolution: "complete" },
{ kind: "write", propertyName: "count", resolution: "complete" },
{ kind: "read", propertyName: "token", resolution: "complete" },
]),
);
});
it("does not project positional argument flow after a spread", () => {
const ir = analyzeJavaScriptSemantics(`
function target(first, second, third) { return third; }

View File

@ -202,7 +202,271 @@ describe("JavaScript semantic companion graph", () => {
graph.coverage.families.find(
({ family }) => family === "promise-ownership",
),
).toMatchObject({ status: "unsupported", retained_relations: 0 });
).toMatchObject({ status: "unknown", retained_relations: 0 });
});
it("projects bounded Promise ownership and unresolved sources", () => {
const graph = graphFor(`
async function run(value) {
const owned = new Promise((resolve) => resolve(value));
await Promise.resolve(owned);
Promise.resolve(value).then(work).finally(cleanup);
value.then(work);
return Promise.all([owned, Promise.resolve(value)]);
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"aggregates",
"awaits",
"chains",
"creates-promise",
"detaches-task",
"owns",
"returns-task",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.filter(({ kind }) => kind === "promise"),
).not.toHaveLength(0);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "promise" &&
properties.method === "all" &&
properties.ownership === "returned",
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, reason }) =>
family === "promise-ownership" && reason === "ambiguous-target",
),
).toBe(true);
expect(
graph.coverage.families.find(
({ family }) => family === "promise-ownership",
),
).toMatchObject({ status: "partial" });
});
it("keeps duplicate function fingerprints ambiguous", () => {
const graph = graphFor(`
function first(value) { return value + 1; }
function second(input) { return input + 1; }
`);
expect(graph.fingerprints).toHaveLength(2);
const digest = graph.fingerprints[0]?.fingerprint_sha256;
expect(digest).toBeDefined();
expect(graph.fingerprints[1]?.fingerprint_sha256).toBe(digest);
if (digest === undefined) throw new Error("Expected fingerprint");
const query = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "function", fingerprint_sha256: digest },
direction: "forward-influence",
});
expect(query.status).toBe("ambiguous");
expect(query.summary.total_seed_matches).toBe(2);
});
it("projects literal events and exact timer cancellation", () => {
const graph = graphFor(`
function run(bus, dynamicName) {
const handle = setInterval(tick, 10);
bus.on("ready", handler);
bus.once(dynamicName, handler);
bus.emit("ready");
bus.removeListener("ready", handler);
clearInterval(handle);
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"cancels-timer",
"dispatches-candidate",
"registers-listener",
"removes-listener",
"schedules-timer",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "event" && properties.event_name === "ready",
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, detail }) =>
family === "event" && detail.includes("Dynamic once event name"),
),
).toBe(true);
expect(
graph.coverage.families.find(({ family }) => family === "event"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "timer"),
).toMatchObject({ status: "partial" });
});
it("projects child-process creation, I/O, listeners, and signals", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
function run(command) {
const child = spawn(
command,
["--mode", "fast"],
{ env: process.env, stdio: "pipe" },
);
child.on("exit", onExit);
child.once("error", onError);
child.kill("SIGTERM");
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"connects-stdio",
"forwards-signal",
"listens-error",
"listens-exit",
"spawns",
"supplies-argv",
"supplies-env",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "child-process" &&
properties.method === "spawn" &&
properties.command === null,
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, detail }) =>
family === "child-process" &&
detail.includes("command remains unresolved"),
),
).toBe(true);
expect(
graph.coverage.families.find(({ family }) => family === "child-process"),
).toMatchObject({ status: "partial" });
});
it("projects config precedence, request fields, and boundaries", () => {
const graph = graphFor(`
import { readFileSync } from "node:fs";
async function run(schema) {
const endpoint = process.env.API_URL ?? "https://fallback.test";
const raw = readFileSync("./config.json", "utf8");
const parsed = JSON.parse(raw);
const port = Number(process.argv[2]);
const validated = schema.parse(parsed);
const response = await fetch(endpoint, {
method: "POST",
body: validated,
});
return response.json();
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"coerces",
"constructs-request",
"consumed-by",
"defaults",
"overrides",
"parses",
"reads-argv",
"reads-config",
"reads-environment",
"supplies-request-field",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "request" && properties.method === "fetch",
),
).toBeDefined();
expect(
graph.nodes.filter(({ kind }) => kind === "boundary"),
).not.toHaveLength(0);
expect(
graph.coverage.families.find(({ family }) => family === "configuration"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "request"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "boundary"),
).toMatchObject({ status: "partial" });
});
it("projects built-in resource acquisition and release", () => {
const graph = graphFor(`
import { open } from "node:fs/promises";
import { connect } from "node:net";
async function run(path) {
const file = await open(path);
const socket = connect({ port: 9000 });
await file.close();
socket.end();
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
expect(relations.has("acquires")).toBe(true);
expect(relations.has("releases")).toBe(true);
expect(graph.nodes.filter(({ kind }) => kind === "resource")).toHaveLength(
2,
);
expect(
graph.coverage.families.find(
({ family }) => family === "resource-lifecycle",
),
).toMatchObject({ status: "partial" });
});
it("projects literal seeds and static object flow", () => {
const graph = graphFor(`
const source = { token: "TOKEN", count: 1 };
const { token } = source;
const copy = { ...source };
source.count = 2;
const read = source.token;
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"destructures",
"reads-property",
"spreads",
"writes-property",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.name === "token",
),
).toBeDefined();
const literal = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "literal", value: "TOKEN" },
direction: "forward-influence",
});
expect(literal.status).toBe("ambiguous");
expect(literal.summary.total_seed_matches).toBe(3);
const property = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "token" },
direction: "forward-influence",
});
expect(property.status).toBe("found");
});
it("does not promote transformed return expressions to resolved aliases", () => {