From 38c883bbc8a8d059ef21cdd85272e6f31a9ee45c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:17:38 +0000 Subject: [PATCH] feat(javascript): recover runtime semantic effects --- .../JavaScriptSemanticGraphAsyncProjection.ts | 256 ++++++++++ .../JavaScriptSemanticGraphBuilder.ts | 47 +- ...riptSemanticGraphChildProcessProjection.ts | 275 ++++++++++ .../JavaScriptSemanticGraphConstruction.ts | 7 +- .../JavaScriptSemanticGraphDataProjection.ts | 351 +++++++++++++ ...criptSemanticGraphFingerprintProjection.ts | 39 ++ .../JavaScriptSemanticGraphFlowProjection.ts | 176 +++++++ ...JavaScriptSemanticGraphObjectProjection.ts | 126 +++++ .../JavaScriptSemanticGraphProjection.ts | 17 +- ...vaScriptSemanticGraphResourceProjection.ts | 113 +++++ .../JavaScriptSemanticGraphValueProjection.ts | 129 +++++ .../javascriptRuntimeReconciliationParsing.ts | 196 ++++++- .../javascriptRuntimeReconciliationResult.ts | 8 +- .../javascriptRuntimeReconciliationRuntime.ts | 6 +- .../javascriptRuntimeReconciliationSchemas.ts | 2 +- src/domain/javascriptSemanticAnalysis.ts | 14 +- src/domain/javascriptSemanticAsyncEffects.ts | 374 ++++++++++++++ .../javascriptSemanticChildProcesses.ts | 391 ++++++++++++++ .../javascriptSemanticDataEffectHelpers.ts | 224 ++++++++ src/domain/javascriptSemanticDataEffects.ts | 441 ++++++++++++++++ .../javascriptSemanticDerivedAnalysis.ts | 99 ++++ src/domain/javascriptSemanticFingerprints.ts | 262 ++++++++++ src/domain/javascriptSemanticIr.ts | 258 +++++++++- src/domain/javascriptSemanticObjects.ts | 181 +++++++ src/domain/javascriptSemanticPromises.ts | 478 ++++++++++++++++++ src/domain/javascriptSemanticResources.ts | 215 ++++++++ tests/javascriptSemanticAnalysis.test.ts | 452 ++++++++++++++++- tests/javascriptSemanticGraphBuilder.test.ts | 266 +++++++++- 28 files changed, 5376 insertions(+), 27 deletions(-) create mode 100644 src/application/JavaScriptSemanticGraphAsyncProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphChildProcessProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphDataProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphFingerprintProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphObjectProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphResourceProjection.ts create mode 100644 src/application/JavaScriptSemanticGraphValueProjection.ts create mode 100644 src/domain/javascriptSemanticAsyncEffects.ts create mode 100644 src/domain/javascriptSemanticChildProcesses.ts create mode 100644 src/domain/javascriptSemanticDataEffectHelpers.ts create mode 100644 src/domain/javascriptSemanticDataEffects.ts create mode 100644 src/domain/javascriptSemanticDerivedAnalysis.ts create mode 100644 src/domain/javascriptSemanticFingerprints.ts create mode 100644 src/domain/javascriptSemanticObjects.ts create mode 100644 src/domain/javascriptSemanticPromises.ts create mode 100644 src/domain/javascriptSemanticResources.ts diff --git a/src/application/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/JavaScriptSemanticGraphAsyncProjection.ts new file mode 100644 index 000000000..3db4e4404 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphAsyncProjection.ts @@ -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(); + 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, +): 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, +): 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, +): 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; diff --git a/src/application/JavaScriptSemanticGraphBuilder.ts b/src/application/JavaScriptSemanticGraphBuilder.ts index c04e4adc9..ccd9a88d4 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.ts @@ -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 = ( diff --git a/src/application/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/JavaScriptSemanticGraphChildProcessProjection.ts new file mode 100644 index 000000000..9c9e79686 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphChildProcessProjection.ts @@ -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, +): 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; diff --git a/src/application/JavaScriptSemanticGraphConstruction.ts b/src/application/JavaScriptSemanticGraphConstruction.ts index b2614afd9..0c5b44b4c 100644 --- a/src/application/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/JavaScriptSemanticGraphConstruction.ts @@ -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>; } /** 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>; } /** 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; diff --git a/src/application/JavaScriptSemanticGraphDataProjection.ts b/src/application/JavaScriptSemanticGraphDataProjection.ts new file mode 100644 index 000000000..12668a5e5 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphDataProjection.ts @@ -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; diff --git a/src/application/JavaScriptSemanticGraphFingerprintProjection.ts b/src/application/JavaScriptSemanticGraphFingerprintProjection.ts new file mode 100644 index 000000000..8521806a1 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphFingerprintProjection.ts @@ -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, +): 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, + ), + }), + ]; + }); diff --git a/src/application/JavaScriptSemanticGraphFlowProjection.ts b/src/application/JavaScriptSemanticGraphFlowProjection.ts index 292deecd9..71fd0c54d 100644 --- a/src/application/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/JavaScriptSemanticGraphFlowProjection.ts @@ -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; readonly callableNodes: ReadonlyMap; + readonly callSiteNodes: ReadonlyMap; readonly returnSiteNodes: ReadonlyMap; 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, +): 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, +): 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, diff --git a/src/application/JavaScriptSemanticGraphObjectProjection.ts b/src/application/JavaScriptSemanticGraphObjectProjection.ts new file mode 100644 index 000000000..73baf0670 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphObjectProjection.ts @@ -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), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphProjection.ts b/src/application/JavaScriptSemanticGraphProjection.ts index 5de02a28d..fe6327e18 100644 --- a/src/application/JavaScriptSemanticGraphProjection.ts +++ b/src/application/JavaScriptSemanticGraphProjection.ts @@ -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"; diff --git a/src/application/JavaScriptSemanticGraphResourceProjection.ts b/src/application/JavaScriptSemanticGraphResourceProjection.ts new file mode 100644 index 000000000..e869cdd88 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphResourceProjection.ts @@ -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, +): 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; diff --git a/src/application/JavaScriptSemanticGraphValueProjection.ts b/src/application/JavaScriptSemanticGraphValueProjection.ts new file mode 100644 index 000000000..4b61dcd4a --- /dev/null +++ b/src/application/JavaScriptSemanticGraphValueProjection.ts @@ -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; + 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>, + }, + context.state, + ), + ); diff --git a/src/domain/javascriptRuntimeReconciliationParsing.ts b/src/domain/javascriptRuntimeReconciliationParsing.ts index 448afb2f0..eeb51a900 100644 --- a/src/domain/javascriptRuntimeReconciliationParsing.ts +++ b/src/domain/javascriptRuntimeReconciliationParsing.ts @@ -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: diff --git a/src/domain/javascriptRuntimeReconciliationResult.ts b/src/domain/javascriptRuntimeReconciliationResult.ts index 05da848d7..dbdc22e1d 100644 --- a/src/domain/javascriptRuntimeReconciliationResult.ts +++ b/src/domain/javascriptRuntimeReconciliationResult.ts @@ -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, diff --git a/src/domain/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascriptRuntimeReconciliationRuntime.ts index 9f9fdb22f..bfebc8406 100644 --- a/src/domain/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascriptRuntimeReconciliationRuntime.ts @@ -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), diff --git a/src/domain/javascriptRuntimeReconciliationSchemas.ts b/src/domain/javascriptRuntimeReconciliationSchemas.ts index b31a7250b..50ce85fcf 100644 --- a/src/domain/javascriptRuntimeReconciliationSchemas.ts +++ b/src/domain/javascriptRuntimeReconciliationSchemas.ts @@ -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, diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 6e39f758d..aa71b493c 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -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.", ], }; diff --git a/src/domain/javascriptSemanticAsyncEffects.ts b/src/domain/javascriptSemanticAsyncEffects.ts new file mode 100644 index 000000000..bfbc72742 --- /dev/null +++ b/src/domain/javascriptSemanticAsyncEffects.ts @@ -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(); + 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; diff --git a/src/domain/javascriptSemanticChildProcesses.ts b/src/domain/javascriptSemanticChildProcesses.ts new file mode 100644 index 000000000..d8e0cd131 --- /dev/null +++ b/src/domain/javascriptSemanticChildProcesses.ts @@ -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; + readonly spawnsByBinding: ReadonlyMap; +} + +/** 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(); + const spawnsByBinding = new Map(); + 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, + spawnsByBinding: ReadonlyMap, +): 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; diff --git a/src/domain/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascriptSemanticDataEffectHelpers.ts new file mode 100644 index 000000000..315b6ff9d --- /dev/null +++ b/src/domain/javascriptSemanticDataEffectHelpers.ts @@ -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; + 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; +}; diff --git a/src/domain/javascriptSemanticDataEffects.ts b/src/domain/javascriptSemanticDataEffects.ts new file mode 100644 index 000000000..64030cef7 --- /dev/null +++ b/src/domain/javascriptSemanticDataEffects.ts @@ -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(); + 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]); diff --git a/src/domain/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascriptSemanticDerivedAnalysis.ts new file mode 100644 index 000000000..d3e630ac9 --- /dev/null +++ b/src/domain/javascriptSemanticDerivedAnalysis.ts @@ -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, + }; +}; diff --git a/src/domain/javascriptSemanticFingerprints.ts b/src/domain/javascriptSemanticFingerprints.ts new file mode 100644 index 000000000..b96190fa4 --- /dev/null +++ b/src/domain/javascriptSemanticFingerprints.ts @@ -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"); diff --git a/src/domain/javascriptSemanticIr.ts b/src/domain/javascriptSemanticIr.ts index d098b51b8..82e92504d 100644 --- a/src/domain/javascriptSemanticIr.ts +++ b/src/domain/javascriptSemanticIr.ts @@ -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: [ diff --git a/src/domain/javascriptSemanticObjects.ts b/src/domain/javascriptSemanticObjects.ts new file mode 100644 index 000000000..2058ea976 --- /dev/null +++ b/src/domain/javascriptSemanticObjects.ts @@ -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; +}; diff --git a/src/domain/javascriptSemanticPromises.ts b/src/domain/javascriptSemanticPromises.ts new file mode 100644 index 000000000..07c08b826 --- /dev/null +++ b/src/domain/javascriptSemanticPromises.ts @@ -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; + readonly state: JavaScriptSemanticAnalysisState; + readonly seenBindings: ReadonlySet; + readonly depth: number; +} + +interface PromiseOwnershipContext { + readonly candidate: PromiseCandidate; + readonly candidateByNode: WeakMap; + 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(); + 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 | 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, + 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, + 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, + 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, + 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; diff --git a/src/domain/javascriptSemanticResources.ts b/src/domain/javascriptSemanticResources.ts new file mode 100644 index 000000000..3400d1784 --- /dev/null +++ b/src/domain/javascriptSemanticResources.ts @@ -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(); + 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; diff --git a/tests/javascriptSemanticAnalysis.test.ts b/tests/javascriptSemanticAnalysis.test.ts index e182e4d86..746a54c22 100644 --- a/tests/javascriptSemanticAnalysis.test.ts +++ b/tests/javascriptSemanticAnalysis.test.ts @@ -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; } diff --git a/tests/javascriptSemanticGraphBuilder.test.ts b/tests/javascriptSemanticGraphBuilder.test.ts index 735cd67ca..4e77fa8ac 100644 --- a/tests/javascriptSemanticGraphBuilder.test.ts +++ b/tests/javascriptSemanticGraphBuilder.test.ts @@ -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", () => {