fix(javascript): yield during the largest AST traversals (#1545)

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>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-10 20:27:41 +08:00
1 parent e964a06b83
commit 5d6eeb22fb
8 files changed
+166 -97

No files matched your search

@@ -17,7 +17,7 @@ import type {
} from "./javascriptSemanticIr.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
interface SemanticFingerprintCollectionInput {
readonly state: JavaScriptSemanticAnalysisState;
@@ -33,10 +33,10 @@ interface SemanticFingerprintCollectionInput {
readonly parserPartial: boolean;
}
/** Build bounded rename- and formatting-resistant components per callable. */
export const collectJavaScriptSemanticFingerprints = (
/** Build bounded rename- and formatting-resistant components per callable, yielding between callables. */
export function* collectJavaScriptSemanticFingerprintsSteps(
input: SemanticFingerprintCollectionInput,
): JavaScriptSemanticFunctionFingerprint[] => {
): Generator<void, JavaScriptSemanticFunctionFingerprint[]> {
const {
state,
callables,
@@ -67,11 +67,13 @@ export const collectJavaScriptSemanticFingerprints = (
);
const networkEffectsByOwner = groupByOwner(requestOperations, ownerId);
const resourceEffectsByOwner = groupByOwner(resourceOperations, ownerId);
return callables.flatMap((callable) => {
if (callable.kind === "class") return [];
const fingerprints: JavaScriptSemanticFunctionFingerprint[] = [];
for (const callable of callables) {
if (callable.kind === "class") continue;
const node = state.callableNodesById.get(callable.callableId);
if (node === undefined) return [];
const syntax = fingerprintSyntax(node, 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) ?? [];
@@ -80,50 +82,49 @@ export const collectJavaScriptSemanticFingerprints = (
const networkEffects = networkEffectsByOwner.get(callable.callableId) ?? [];
const resourceEffects =
resourceEffectsByOwner.get(callable.callableId) ?? [];
return [
{
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,
),
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."]
),
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,
@@ -161,15 +162,15 @@ interface FingerprintSyntax {
readonly literals: readonly string[];
}
const fingerprintSyntax = (
function* fingerprintSyntaxSteps(
root: t.Node,
targetCallableId: string,
): FingerprintSyntax => {
): Generator<void, FingerprintSyntax> {
const normalizedTokens: string[] = [];
const controlFlowTokens: string[] = [];
const literals: string[] = [];
const callableStack: string[] = [];
traverseJavaScriptAst(root, {
yield* traverseJavaScriptAstSteps(root, {
enter: (node, parent) => {
const callableId = semanticCallableIdForNode(node);
if (callableId !== null) callableStack.push(callableId);
@@ -187,7 +188,7 @@ const fingerprintSyntax = (
},
});
return { normalizedTokens, controlFlowTokens, literals };
};
}
const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => {
if (t.isIdentifier(node))