Files
rea/src/domain/javascriptSemanticPromises.ts
T

479 lines
15 KiB
TypeScript

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