feat(javascript): recover runtime semantic effects
This commit is contained in:
parent
697be942e8
commit
38c883bbc8
|
|
@ -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;
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue