Reuse the shared object-member owner for literal spreads, retain known property names under unresolved membership, and distinguish complete, partial, unknown, and absent projections. Keep historical coverage omission unchanged and preserve own keys across prototype setters. Refs #5075
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import * as t from "@babel/types";
|
|
import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js";
|
|
import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
|
|
|
/** TypeScript receiver annotations occupy no positional runtime parameter slot. */
|
|
export const runtimeFunctionParameters = (
|
|
node: t.Function,
|
|
): t.Function["params"] =>
|
|
t.isIdentifier(node.params[0], { name: "this" })
|
|
? node.params.slice(1)
|
|
: node.params;
|
|
|
|
/**
|
|
* Remove wrappers that affect TypeScript or Flow checking but not runtime
|
|
* expression identity. The returned depth lets bounded evaluators account for
|
|
* syntax that normalization would otherwise skip.
|
|
*/
|
|
export const unwrapJavaScriptExpression = (
|
|
node: t.Node,
|
|
options?: { readonly stopAtParentheses: boolean },
|
|
): { readonly node: t.Node; readonly depth: number } => {
|
|
let current = node;
|
|
let depth = 0;
|
|
while (
|
|
t.isParenthesizedExpression(current) ||
|
|
t.isTSAsExpression(current) ||
|
|
t.isTSTypeAssertion(current) ||
|
|
t.isTSNonNullExpression(current) ||
|
|
t.isTSSatisfiesExpression(current) ||
|
|
t.isTSInstantiationExpression(current) ||
|
|
t.isTypeCastExpression(current)
|
|
) {
|
|
// Optional-chain grouping affects execution even when expression identity
|
|
// is transparent. Keep every grouping boundary available to that consumer.
|
|
if (
|
|
options?.stopAtParentheses === true &&
|
|
(t.isParenthesizedExpression(current) ||
|
|
current.extra?.parenthesized === true)
|
|
)
|
|
break;
|
|
current = current.expression;
|
|
depth += 1;
|
|
}
|
|
return { node: current, depth };
|
|
};
|
|
|
|
/** Read one syntax-level primitive only when its value is exact. */
|
|
export const readExactJavaScriptLiteral = (
|
|
node: t.Node,
|
|
):
|
|
| { readonly found: true; readonly value: JavaScriptSemanticPrimitive }
|
|
| { readonly found: false } => {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
if (t.isStringLiteral(node)) return { found: true, value: node.value };
|
|
if (t.isNumericLiteral(node) && Number.isFinite(node.value))
|
|
return { found: true, value: node.value };
|
|
if (t.isBooleanLiteral(node)) return { found: true, value: node.value };
|
|
if (t.isNullLiteral(node)) return { found: true, value: null };
|
|
if (
|
|
t.isTemplateLiteral(node) &&
|
|
node.expressions.length === 0 &&
|
|
node.quasis.length === 1
|
|
) {
|
|
const cooked = node.quasis[0]?.value.cooked;
|
|
return cooked === null || cooked === undefined
|
|
? { found: false }
|
|
: { found: true, value: cooked };
|
|
}
|
|
return { found: false };
|
|
};
|
|
|
|
/** Read an identifier or literal property name without evaluating syntax. */
|
|
export const propertyName = (node: t.Node): string => {
|
|
if (t.isIdentifier(node)) return node.name;
|
|
if (t.isStringLiteral(node) || t.isNumericLiteral(node))
|
|
return String(node.value);
|
|
return "";
|
|
};
|
|
|
|
/** Read a property name only when its syntax commits to one exact key. */
|
|
export const semanticStaticPropertyName = (
|
|
property: t.Node,
|
|
computed: boolean,
|
|
): string => semanticStaticPropertyKey(property, computed) ?? "";
|
|
|
|
/** Read an exact property key, preserving the legal empty-string key. */
|
|
export const semanticStaticPropertyKey = (
|
|
property: t.Node,
|
|
computed: boolean,
|
|
): string | null => {
|
|
property = unwrapJavaScriptExpression(property).node;
|
|
if (
|
|
t.isStringLiteral(property) ||
|
|
t.isNumericLiteral(property) ||
|
|
t.isBooleanLiteral(property)
|
|
)
|
|
return String(property.value);
|
|
if (t.isNullLiteral(property)) return "null";
|
|
if (t.isBigIntLiteral(property)) return String(property.value);
|
|
if (t.isUnaryExpression(property, { operator: "void" })) return "undefined";
|
|
if (t.isUnaryExpression(property, { operator: "+" })) {
|
|
const argument = unwrapJavaScriptExpression(property.argument).node;
|
|
if (t.isNumericLiteral(argument)) return String(argument.value);
|
|
}
|
|
if (t.isUnaryExpression(property, { operator: "-" })) {
|
|
const argument = unwrapJavaScriptExpression(property.argument).node;
|
|
if (t.isNumericLiteral(argument)) return String(-argument.value);
|
|
if (t.isBigIntLiteral(argument)) return String(-BigInt(argument.value));
|
|
}
|
|
if (
|
|
t.isTemplateLiteral(property) &&
|
|
property.expressions.length === 0 &&
|
|
property.quasis.length === 1
|
|
) {
|
|
const cooked = property.quasis[0]?.value.cooked;
|
|
return cooked === null || cooked === undefined ? null : cooked;
|
|
}
|
|
return !computed && t.isIdentifier(property) ? property.name : null;
|
|
};
|
|
|
|
/** Syntax whose value cannot contribute own properties to an object spread. */
|
|
export const isNullishJavaScriptSyntax = (node: t.Node): boolean => {
|
|
node = unwrapJavaScriptExpression(node).node;
|
|
return (
|
|
t.isNullLiteral(node) || t.isUnaryExpression(node, { operator: "void" })
|
|
);
|
|
};
|
|
|
|
type StaticObjectProperty =
|
|
| { readonly status: "missing" | "dynamic" }
|
|
| { readonly status: "explicit"; readonly property: t.ObjectProperty };
|
|
|
|
type StaticObjectMember =
|
|
| { readonly kind: "unknown" }
|
|
| { readonly kind: "prototype-setter" }
|
|
| {
|
|
readonly kind: "property";
|
|
readonly property: t.ObjectProperty | t.ObjectMethod;
|
|
};
|
|
|
|
const UNKNOWN_STATIC_OBJECT_MEMBER: StaticObjectMember = { kind: "unknown" };
|
|
|
|
const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean =>
|
|
!property.computed &&
|
|
!property.shorthand &&
|
|
semanticStaticPropertyKey(property.key, false) === "__proto__";
|
|
|
|
/** Expand only bounded literal spreads, preserving unknown writes in order. */
|
|
const appendStaticObjectMembers = (
|
|
members: StaticObjectMember[],
|
|
object: t.ObjectExpression,
|
|
depth: number,
|
|
isNullish?: (node: t.Node) => boolean,
|
|
): void => {
|
|
for (const property of object.properties) {
|
|
if (!t.isSpreadElement(property)) {
|
|
if (t.isObjectProperty(property) && isObjectPrototypeSetter(property)) {
|
|
// A top-level setter changes lookup inheritance but is never an own
|
|
// key; one copied through object spread has no effect on the target.
|
|
if (depth === 0) members.push({ kind: "prototype-setter" });
|
|
continue;
|
|
}
|
|
members.push({ kind: "property", property });
|
|
continue;
|
|
}
|
|
const { node: spread, depth: wrappers } = unwrapJavaScriptExpression(
|
|
property.argument,
|
|
);
|
|
if (t.isObjectExpression(spread)) {
|
|
const nextDepth = depth + wrappers + 1;
|
|
// Copying a getter can run code that changes another source key.
|
|
if (
|
|
nextDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT ||
|
|
spread.properties.some((entry) =>
|
|
t.isObjectMethod(entry, { kind: "get" }),
|
|
)
|
|
) {
|
|
members.push(UNKNOWN_STATIC_OBJECT_MEMBER);
|
|
continue;
|
|
}
|
|
appendStaticObjectMembers(members, spread, nextDepth, isNullish);
|
|
continue;
|
|
}
|
|
// These primitive boxes have no own keys; strings can own index keys.
|
|
if (
|
|
t.isBooleanLiteral(spread) ||
|
|
t.isNumericLiteral(spread) ||
|
|
t.isBigIntLiteral(spread) ||
|
|
isNullish?.(property.argument) === true
|
|
)
|
|
continue;
|
|
members.push(UNKNOWN_STATIC_OBJECT_MEMBER);
|
|
}
|
|
};
|
|
|
|
const staticObjectMembers = (
|
|
object: t.ObjectExpression,
|
|
depth: number,
|
|
isNullish?: (node: t.Node) => boolean,
|
|
): readonly StaticObjectMember[] => {
|
|
const members: StaticObjectMember[] = [];
|
|
appendStaticObjectMembers(members, object, depth, isNullish);
|
|
return members;
|
|
};
|
|
|
|
/** Find the effective explicit property, preserving unresolved overrides. */
|
|
export const staticObjectProperty = (
|
|
object: t.Node | undefined,
|
|
name: string,
|
|
isNullish?: (node: t.Node) => boolean,
|
|
): StaticObjectProperty => {
|
|
if (object === undefined) return { status: "missing" };
|
|
if (!t.isObjectExpression(object)) return { status: "dynamic" };
|
|
let prototypeMaySupply = false;
|
|
for (const member of staticObjectMembers(object, 0, isNullish).toReversed()) {
|
|
if (member.kind === "prototype-setter") {
|
|
prototypeMaySupply = true;
|
|
continue;
|
|
}
|
|
if (member.kind === "unknown") return { status: "dynamic" };
|
|
const { property } = member;
|
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
|
if (key === null) return { status: "dynamic" };
|
|
if (key !== name) continue;
|
|
return t.isObjectProperty(property)
|
|
? { status: "explicit", property }
|
|
: { status: "dynamic" };
|
|
}
|
|
return prototypeMaySupply ? { status: "dynamic" } : { status: "missing" };
|
|
};
|
|
|
|
export type StaticObjectPropertyNames =
|
|
| { readonly status: "missing" | "dynamic" }
|
|
| { readonly status: "partial"; readonly names: readonly string[] }
|
|
| { readonly status: "complete"; readonly names: readonly string[] };
|
|
|
|
/** Retain known own names and report unresolved object membership separately. */
|
|
export const staticObjectPropertyNames = (
|
|
object: t.Node | undefined,
|
|
isNullish?: (node: t.Node) => boolean,
|
|
): StaticObjectPropertyNames => {
|
|
if (object === undefined) return { status: "missing" };
|
|
if (!t.isObjectExpression(object)) return { status: "dynamic" };
|
|
const names = new Set<string>();
|
|
let complete = true;
|
|
for (const member of staticObjectMembers(object, 0, isNullish)) {
|
|
if (member.kind === "prototype-setter") continue;
|
|
if (member.kind === "unknown") {
|
|
complete = false;
|
|
continue;
|
|
}
|
|
const key = semanticStaticPropertyKey(
|
|
member.property.key,
|
|
member.property.computed,
|
|
);
|
|
if (key === null) {
|
|
complete = false;
|
|
continue;
|
|
}
|
|
names.add(key);
|
|
}
|
|
return complete
|
|
? { status: "complete", names: [...names] }
|
|
: { status: "partial", names: [...names] };
|
|
};
|
|
|
|
/** Later properties own a key; an intervening dynamic write leaves it unknown. */
|
|
export const semanticFinalObjectProperty = (
|
|
node: t.ObjectExpression,
|
|
key: string,
|
|
): t.Node | undefined => {
|
|
const property = staticObjectProperty(node, key);
|
|
return property.status === "explicit" ? property.property.value : undefined;
|
|
};
|
|
|
|
export const semanticFinalObjectStringProperty = (
|
|
node: t.ObjectExpression,
|
|
key: string,
|
|
): string | undefined => {
|
|
const value = semanticFinalObjectProperty(node, key);
|
|
return t.isStringLiteral(value) ? value.value : undefined;
|
|
};
|
|
|
|
/** Read exact consumed object-pattern keys, leaving dynamic keys unknown. */
|
|
export const semanticObjectPatternKeys = (
|
|
pattern: t.ObjectPattern,
|
|
): readonly string[] =>
|
|
pattern.properties.flatMap((property) => {
|
|
if (t.isRestElement(property)) return [];
|
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
|
return key === null ? [] : [key];
|
|
});
|
|
|
|
/** Return the syntactic node whose target this construct writes. */
|
|
export const semanticWriteSiteTarget = (node: t.Node): t.Node | null => {
|
|
if (t.isAssignmentExpression(node)) return node.left;
|
|
if (t.isUpdateExpression(node)) return node.argument;
|
|
if (t.isUnaryExpression(node, { operator: "delete" })) return node.argument;
|
|
if (t.isForOfStatement(node) || t.isForInStatement(node)) return node.left;
|
|
return null;
|
|
};
|
|
|
|
/** Whether a write site reads the previous target value before writing. */
|
|
export const semanticWriteSiteReadsFirst = (site: t.Node): boolean =>
|
|
(t.isAssignmentExpression(site) && site.operator !== "=") ||
|
|
t.isUpdateExpression(site);
|
|
|
|
/** Find the owning write site through inert TypeScript and parenthesis wrappers. */
|
|
export const semanticWrappedWriteSite = (
|
|
node: t.Node,
|
|
parent: t.Node,
|
|
ancestors: readonly t.Node[],
|
|
): t.Node | null => {
|
|
const chain =
|
|
ancestors.at(-1) === parent ? ancestors : [...ancestors, parent];
|
|
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
|
const candidate = chain[index];
|
|
if (candidate === undefined) return null;
|
|
const target = semanticWriteSiteTarget(candidate);
|
|
if (target !== null)
|
|
return unwrapJavaScriptExpression(target).node === node
|
|
? candidate
|
|
: null;
|
|
if (unwrapJavaScriptExpression(candidate).depth === 0) return null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** Visit binding and member leaves in direct and destructuring write targets. */
|
|
export function* semanticWriteTargets(
|
|
root: t.Node,
|
|
): Generator<t.Identifier | t.MemberExpression | t.OptionalMemberExpression> {
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const next = pending.pop();
|
|
if (next === undefined) break;
|
|
const node = unwrapJavaScriptExpression(next).node;
|
|
if (
|
|
t.isIdentifier(node) ||
|
|
t.isMemberExpression(node) ||
|
|
t.isOptionalMemberExpression(node)
|
|
)
|
|
yield node;
|
|
else if (t.isRestElement(node)) pending.push(node.argument);
|
|
else if (t.isAssignmentPattern(node)) pending.push(node.left);
|
|
else if (t.isArrayPattern(node)) {
|
|
for (const element of node.elements.toReversed())
|
|
if (element !== null) pending.push(element);
|
|
} else if (t.isObjectPattern(node)) {
|
|
for (const property of node.properties.toReversed())
|
|
pending.push(
|
|
t.isRestElement(property) ? property.argument : property.value,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display an exact JavaScript string as nonempty label text. Graph labels and
|
|
* artifact-local keys are nonempty, so the legal empty value is shown as `""`;
|
|
* producers keep the exact value in their properties.
|
|
*/
|
|
export const javascriptDisplayText = (value: string): string =>
|
|
value === "" ? '""' : value;
|