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-25 - AST Node Iteration Traversal Optimization
**Learning:** AST traversal with recursive generators (`yield from`) and `ast.iter_child_nodes()` creates significant overhead due to multiple generator frames. This is especially true for hot-path traversals.
**Action:** Use an explicit stack instead of `yield from` recursion to preserve lazy evaluation and short-circuiting capabilities. Avoid eager list-appending (`list.append()`) to prevent computing the whole subtree when early matches exist. Reverse child nodes via `reversed()` before extending the stack to preserve traversal order. Critically, retain `isinstance(node, ast.AST)` checks to prevent `AttributeError`s when traversing `_fields`, as not all field values are AST nodes (some are strings or literals).
74 changes: 48 additions & 26 deletions src/wardline/scanner/ast_primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,39 +104,61 @@ 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
kw_defaults = current.args.kw_defaults
for i in range(len(kw_defaults) - 1, -1, -1):
val = kw_defaults[i]
if val is not None:
stack.append(val)
defaults = current.args.defaults
for i in range(len(defaults) - 1, -1, -1):
stack.append(defaults[i])
decorators = current.decorator_list
for i in range(len(decorators) - 1, -1, -1):
stack.append(decorators[i])
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
keywords = current.keywords
for i in range(len(keywords) - 1, -1, -1):
stack.append(keywords[i].value)
bases = current.bases
for i in range(len(bases) - 1, -1, -1):
stack.append(bases[i])
decorators = current.decorator_list
for i in range(len(decorators) - 1, -1, -1):
stack.append(decorators[i])
continue

if isinstance(current, ast.Lambda):
yield from _walk_argument_defaults(current.args)
return
kw_defaults = current.args.kw_defaults
for i in range(len(kw_defaults) - 1, -1, -1):
val = kw_defaults[i]
if val is not None:
stack.append(val)
defaults = current.args.defaults
for i in range(len(defaults) - 1, -1, -1):
stack.append(defaults[i])
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
if isinstance(value, list):
for item in reversed(value):
if isinstance(item, ast.AST):
stack.append(item)
elif isinstance(value, ast.AST):
stack.append(value)


def resolve_self_method_fqn(
Expand Down
Loading