perf(javascript): skip nested callables during fingerprint traversal (#1871)

This commit is contained in:
cyanseek 2026-10-12 01:10:31 +08:00 committed by GitHub
parent e4ac7bbfc2
commit 097f0d54af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 63 additions and 12 deletions

View File

@ -179,6 +179,34 @@ describe("JavaScript semantic analysis: calls 2", () => {
).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256);
});
it("keeps nested callable syntax out of its owner's fingerprint", () => {
const analyze = (nested: number, owned: number) =>
analyzeJavaScriptSemantics(`
function outer() {
function inner() { return ${nested}; }
const object = { method() { return ${nested}; } };
class Nested { method() { return ${nested}; } }
return ${owned};
}
`);
const first = analyze(1, 2);
const changedNested = analyze(3, 2);
const changedOwner = analyze(1, 4);
const syntax = (ir: typeof first) =>
ir.functionFingerprints.map(({ components }) => ({
ast: components.normalizedAstSha256,
control: components.controlFlowSha256,
literals: components.literalSetSha256,
}));
const original = syntax(first);
expect(original).toHaveLength(4);
expect(syntax(changedNested)[0]).toEqual(original[0]);
for (const index of [1, 2, 3])
expect(syntax(changedNested)[index]).not.toEqual(original[index]);
expect(syntax(changedOwner)[0]).not.toEqual(original[0]);
expect(syntax(changedOwner).slice(1)).toEqual(original.slice(1));
});
it("keeps fingerprint relations ordered and effects scoped to their owner", () => {
const ordered = analyzeJavaScriptSemantics(`
function first() {}

View File

@ -63,6 +63,35 @@ it("traverses a large tree in steps with the synchronous visit order (#1462)", (
expect(stepped).toEqual(synchronous);
});
it("skips child subtrees without changing sibling ancestry or exit callbacks", () => {
const parsed = parseJavaScriptSource("[1, [2], 3]", "selection.js");
if (parsed === null) throw new Error("Expected parsed source");
const entered: number[] = [];
const exited: number[] = [];
const parents: string[][] = [];
const steps = traverseJavaScriptAstSteps(parsed.program, {
skip: (node) => node.type === "ArrayExpression" && node.start === 4,
enter: (node, parent, readAncestors) => {
if (node.type !== "NumericLiteral") return;
entered.push(node.value);
expect(readAncestors().at(-1)).toBe(parent);
parents.push(readAncestors().map(({ type }) => type));
},
exit: (node) => {
if (node.type === "NumericLiteral") exited.push(node.value);
},
});
while (steps.next().done !== true) {
// Complete the same cooperative traversal used by fingerprint collection.
}
expect(entered).toEqual([1, 3]);
expect(exited).toEqual([1, 3]);
expect(parents).toEqual([
["Program", "ExpressionStatement", "ArrayExpression"],
["Program", "ExpressionStatement", "ArrayExpression"],
]);
});
it("preserves receiver and Electron alias scope across traversal pauses (#1462)", () => {
const padding = Array.from(
{ length: 400 },

View File

@ -73,7 +73,7 @@ export function* collectJavaScriptSemanticFingerprintsSteps(
const node = state.callableNodesById.get(callable.callableId);
if (node === undefined) continue;
yield;
const syntax = yield* fingerprintSyntaxSteps(node, callable.callableId);
const syntax = yield* fingerprintSyntaxSteps(node);
const promiseEffects = promiseEffectsByOwner.get(callable.callableId) ?? [];
const eventEffects = eventEffectsByOwner.get(callable.callableId) ?? [];
const timerEffects = timerEffectsByOwner.get(callable.callableId) ?? [];
@ -164,28 +164,19 @@ interface FingerprintSyntax {
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, {
skip: (node) => semanticCallableIdForNode(node) !== null,
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 };
}

View File

@ -1,7 +1,7 @@
import * as t from "@babel/types";
/**
* Visit every Babel AST node in deterministic source-tree order.
* Visit Babel AST nodes in deterministic source-tree order, excluding skipped subtrees.
*
* `readAncestors` returns a snapshot during `enter`, outermost-first and
* ending with the direct parent. Callers
@ -17,6 +17,8 @@ export const traverseJavaScriptAst = (
/** Callbacks for one deterministic AST traversal. */
export interface JavaScriptAstVisitor {
/** Omit a child and its subtree without entering or exiting it. */
readonly skip?: (node: t.Node) => boolean;
readonly enter: (
node: t.Node,
parent: t.Node | null,
@ -50,6 +52,7 @@ export function* traverseJavaScriptAstSteps(
current.nextIndex += 1;
if (visited % 1024 === 0) yield;
visited += 1;
if (visitor.skip?.(child) === true) continue;
pending.push(enter(child, current.node));
} else {
visitor.exit?.(current.node, current.parent);