After #1542 the longest event-loop blocks on large bundles were single AST traversals inside one analyzer: member-mutation collection with its fixpoint, static source analysis, and function fingerprints, each about one second on Obsidian's 3.7 MB app.js. - traverseJavaScriptAstSteps yields every 1,024 visited nodes with the same visit order; traverseJavaScriptAst drains it, so every existing caller is unchanged. - Member mutations, static analysis and fingerprints run as steps. The member-mutation fixpoint yields every 64 references, and fingerprints yield between callables. - The now-unused synchronous member-mutation and fingerprint wrappers are removed. Graph IDs and complete result digests match main on three inputs, including obsidian.asar. Over two runs there, the longest block falls from about 920 ms to 740 ms, the second from 860 to 595 ms, and blocks over 250 ms from 27 to 18, with total time within 1.5%. Refs #1462 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import * as t from "@babel/types";
|
|
|
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|
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 { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.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, yielding between callables. */
|
|
export function* collectJavaScriptSemanticFingerprintsSteps(
|
|
input: SemanticFingerprintCollectionInput,
|
|
): Generator<void, JavaScriptSemanticFunctionFingerprint[]> {
|
|
const {
|
|
state,
|
|
callables,
|
|
calls,
|
|
promises,
|
|
events,
|
|
timers,
|
|
childProcessSpawns,
|
|
childProcessInteractions,
|
|
requestOperations,
|
|
resourceOperations,
|
|
parserPartial,
|
|
} = input;
|
|
const callSitesByCaller = groupByOwner(
|
|
calls.callSites,
|
|
({ callerCallableId }) => callerCallableId,
|
|
);
|
|
const captureCountByCallable = countByOwner(
|
|
calls.closureCaptures,
|
|
({ callableId }) => callableId,
|
|
);
|
|
const promiseEffectsByOwner = groupByOwner(promises, ownerId);
|
|
const eventEffectsByOwner = groupByOwner(events, ownerId);
|
|
const timerEffectsByOwner = groupByOwner(timers, ownerId);
|
|
const childProcessEffectsByOwner = groupByOwner(
|
|
[...childProcessSpawns, ...childProcessInteractions],
|
|
ownerId,
|
|
);
|
|
const networkEffectsByOwner = groupByOwner(requestOperations, ownerId);
|
|
const resourceEffectsByOwner = groupByOwner(resourceOperations, ownerId);
|
|
const fingerprints: JavaScriptSemanticFunctionFingerprint[] = [];
|
|
for (const callable of callables) {
|
|
if (callable.kind === "class") continue;
|
|
const node = state.callableNodesById.get(callable.callableId);
|
|
if (node === undefined) continue;
|
|
yield;
|
|
const syntax = yield* fingerprintSyntaxSteps(node, callable.callableId);
|
|
const promiseEffects = promiseEffectsByOwner.get(callable.callableId) ?? [];
|
|
const eventEffects = eventEffectsByOwner.get(callable.callableId) ?? [];
|
|
const timerEffects = timerEffectsByOwner.get(callable.callableId) ?? [];
|
|
const childProcessEffects =
|
|
childProcessEffectsByOwner.get(callable.callableId) ?? [];
|
|
const networkEffects = networkEffectsByOwner.get(callable.callableId) ?? [];
|
|
const resourceEffects =
|
|
resourceEffectsByOwner.get(callable.callableId) ?? [];
|
|
fingerprints.push({
|
|
callableId: callable.callableId,
|
|
status:
|
|
parserPartial || callable.returnCoverage.status !== "complete"
|
|
? "partial"
|
|
: "complete",
|
|
components: {
|
|
parameterArity: callableParameterArity(node),
|
|
normalizedAstSha256: digest(syntax.normalizedTokens),
|
|
controlFlowSha256: digest(syntax.controlFlowTokens),
|
|
relationShapeSha256: digest(
|
|
relationShape(
|
|
callable,
|
|
callSitesByCaller.get(callable.callableId) ?? [],
|
|
captureCountByCallable.get(callable.callableId) ?? 0,
|
|
promiseEffects,
|
|
),
|
|
),
|
|
literalSetSha256: digest(
|
|
[...new Set(syntax.literals)].sort(compareUnicodeCodePoints),
|
|
),
|
|
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 static candidate and does not prove behavioral equivalence.",
|
|
...(parserPartial
|
|
? ["Incomplete semantic recovery makes this fingerprint partial."]
|
|
: []),
|
|
],
|
|
});
|
|
}
|
|
return fingerprints;
|
|
}
|
|
|
|
const ownerId = <T extends { readonly ownerCallableId: string | null }>(
|
|
value: T,
|
|
): string | null => value.ownerCallableId;
|
|
|
|
const groupByOwner = <T, K extends string | null>(
|
|
values: readonly T[],
|
|
getOwner: (value: T) => K,
|
|
): Map<K, T[]> => {
|
|
const grouped = new Map<K, T[]>();
|
|
for (const value of values) {
|
|
const owner = getOwner(value);
|
|
const ownedValues = grouped.get(owner);
|
|
if (ownedValues === undefined) grouped.set(owner, [value]);
|
|
else ownedValues.push(value);
|
|
}
|
|
return grouped;
|
|
};
|
|
|
|
const countByOwner = <T, K extends string | null>(
|
|
values: readonly T[],
|
|
getOwner: (value: T) => K,
|
|
): Map<K, number> => {
|
|
const counts = new Map<K, number>();
|
|
for (const value of values) {
|
|
const owner = getOwner(value);
|
|
counts.set(owner, (counts.get(owner) ?? 0) + 1);
|
|
}
|
|
return counts;
|
|
};
|
|
|
|
interface FingerprintSyntax {
|
|
readonly normalizedTokens: readonly string[];
|
|
readonly controlFlowTokens: readonly string[];
|
|
readonly literals: readonly string[];
|
|
}
|
|
|
|
function* fingerprintSyntaxSteps(
|
|
root: t.Node,
|
|
targetCallableId: string,
|
|
): Generator<void, FingerprintSyntax> {
|
|
const normalizedTokens: string[] = [];
|
|
const controlFlowTokens: string[] = [];
|
|
const literals: string[] = [];
|
|
const callableStack: string[] = [];
|
|
yield* traverseJavaScriptAstSteps(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,
|
|
callSites: JavaScriptSemanticCallAnalysis["callSites"],
|
|
captureCount: number,
|
|
promises: readonly JavaScriptSemanticPromiseOperation[],
|
|
): string[] => [
|
|
`returns:${String(callable.returnSites.length)}:${callable.returnCoverage.status}`,
|
|
...callSites.map(
|
|
({ kind, resolution, arguments: args, calleeCallableIds }) =>
|
|
`call:${kind}:${resolution}:${String(args.length)}:${String(calleeCallableIds.length)}`,
|
|
),
|
|
`captures:${String(captureCount)}`,
|
|
...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");
|