From 097f0d54afceb3ce61dbaa24dd6736658d1fce06 Mon Sep 17 00:00:00 2001 From: cyanseek Date: Mon, 12 Oct 2026 01:10:31 +0800 Subject: [PATCH] perf(javascript): skip nested callables during fingerprint traversal (#1871) --- .../javascriptSemanticAnalysis.calls.test.ts | 28 ++++++++++++++++++ .../javascriptSemanticAnalysisSteps.test.ts | 29 +++++++++++++++++++ .../javascriptSemanticFingerprints.ts | 13 ++------- .../javascript/javascriptSemanticTraversal.ts | 5 +++- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 0fd5aa194..f5ac65b7a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -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() {} diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts index 0c5b4b12d..633cca121 100644 --- a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -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 }, diff --git a/src/domain/javascript/javascriptSemanticFingerprints.ts b/src/domain/javascript/javascriptSemanticFingerprints.ts index 3d4b828be..8b6edc036 100644 --- a/src/domain/javascript/javascriptSemanticFingerprints.ts +++ b/src/domain/javascript/javascriptSemanticFingerprints.ts @@ -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 { 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 }; } diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index e2e351254..581d7d464 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -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);