Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2026-06-21 - AST Traversal Generator Overhead
**Learning:** In hot-path AST traversal functions (`iter_calls_in_function_body`), recursive generators using `yield from` combined with `ast.iter_child_nodes` carry a significant performance penalty compared to an explicit stack. A subtle gotcha when converting to an explicit stack: when a node has multiple attribute groups (like `ClassDef` with keywords, bases, and decorator_list), you must push the groups onto the stack in the *reverse* order of their intended traversal, so that the stack pops them in the correct forward order.
**Action:** When optimizing recursive AST functions to iterative ones, manually traverse `node._fields` in reverse, append to a stack, and pay careful attention to the stack push order for composite nodes to preserve correct traversal order.
62 changes: 36 additions & 26 deletions src/wardline/scanner/ast_primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,39 +104,49 @@ def iter_calls_in_function_body(
Header expressions that execute in the enclosing scope (decorators, default
values, base classes, metaclass keywords) are still attributed to ``node``.
"""
stack: list[ast.AST] = list(reversed(node.body))

while stack:
current = stack.pop()

def walk_node(current: ast.AST) -> Iterator[ast.Call]:
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):
for decorator in current.decorator_list:
yield from walk_node(decorator)
yield from _walk_argument_defaults(current.args)
return
# push in reverse order so they are popped and processed in forward order
for kw_default in reversed(current.args.kw_defaults):
if kw_default is not None:
stack.append(kw_default)
for default in reversed(current.args.defaults):
stack.append(default)
stack.extend(reversed(current.decorator_list))
continue

if isinstance(current, ast.ClassDef):
for decorator in current.decorator_list:
yield from walk_node(decorator)
for base in current.bases:
yield from walk_node(base)
for keyword in current.keywords:
yield from walk_node(keyword.value)
return
stack.extend(reversed([kw.value for kw in current.keywords]))
stack.extend(reversed(current.bases))
stack.extend(reversed(current.decorator_list))
continue
Comment on lines 122 to +126

if isinstance(current, ast.Lambda):
yield from _walk_argument_defaults(current.args)
return
for kw_default in reversed(current.args.kw_defaults):
if kw_default is not None:
stack.append(kw_default)
for default in reversed(current.args.defaults):
stack.append(default)
continue

if isinstance(current, ast.Call):
yield current
for child in ast.iter_child_nodes(current):
yield from walk_node(child)

def _walk_argument_defaults(args: ast.arguments) -> Iterator[ast.Call]:
for default in args.defaults:
yield from walk_node(default)
for kw_default in args.kw_defaults:
if kw_default is None:
continue
yield from walk_node(kw_default)

for stmt in node.body:
yield from walk_node(stmt)
for field in reversed(current._fields):
try:
value = getattr(current, field)
except AttributeError:
continue
Comment on lines +140 to +143
if isinstance(value, list):
for child in reversed(value):
if isinstance(child, ast.AST):
stack.append(child)
elif isinstance(value, ast.AST):
stack.append(value)


def resolve_self_method_fqn(
Expand Down
2 changes: 1 addition & 1 deletion tests/golden/identity/corpus/META.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"corpus_version": 6,
"fingerprint_scheme": "wlfp2",
"reason": "call-site full-span discriminator"
"reason": "Performance optimization: explicit stack for iter_calls_in_function_body"
}