code-towel 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_towel-1.0.0.dist-info/METADATA +722 -0
- code_towel-1.0.0.dist-info/RECORD +27 -0
- code_towel-1.0.0.dist-info/WHEEL +5 -0
- code_towel-1.0.0.dist-info/entry_points.txt +4 -0
- code_towel-1.0.0.dist-info/licenses/LICENSE +201 -0
- code_towel-1.0.0.dist-info/top_level.txt +1 -0
- towel/__init__.py +20 -0
- towel/cli.py +813 -0
- towel/unification/__init__.py +24 -0
- towel/unification/assignment_analyzer.py +403 -0
- towel/unification/ast_normalizer.py +223 -0
- towel/unification/ast_pretty_printer.py +133 -0
- towel/unification/binding_detector.py +381 -0
- towel/unification/block_signature.py +123 -0
- towel/unification/builtins.py +209 -0
- towel/unification/exceptions.py +113 -0
- towel/unification/extractor.py +884 -0
- towel/unification/models.py +190 -0
- towel/unification/nominal_unifier.py +375 -0
- towel/unification/orphan_detector.py +187 -0
- towel/unification/pipeline.py +524 -0
- towel/unification/project_layout.py +201 -0
- towel/unification/refactor_engine.py +3381 -0
- towel/unification/scope_analyzer.py +597 -0
- towel/unification/unifier.py +2128 -0
- towel/unification/visitor_utils.py +83 -0
- towel/unification/visitors.py +377 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# Copyright 2025 Eric Allen
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Analyze identifier bindings and scopes in Python code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
from typing import Callable, Dict, Set, List, Optional, Tuple, Iterable, Union
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from .builtins import filter_builtins
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Binding:
|
|
27
|
+
"""Represents a binding of an identifier to a value."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
scope_id: int # Unique identifier for the scope
|
|
31
|
+
node: ast.AST # The AST node that creates this binding
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Scope:
|
|
36
|
+
"""Represents a lexical scope."""
|
|
37
|
+
|
|
38
|
+
scope_id: int
|
|
39
|
+
parent: Optional["Scope"]
|
|
40
|
+
bindings: Dict[str, Binding] = field(default_factory=dict)
|
|
41
|
+
children: List["Scope"] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def lookup(self, name: str) -> Optional[Binding]:
|
|
44
|
+
"""Lookup a binding in this scope or parent scopes."""
|
|
45
|
+
if name in self.bindings:
|
|
46
|
+
return self.bindings[name]
|
|
47
|
+
if self.parent:
|
|
48
|
+
return self.parent.lookup(name)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def add_binding(self, name: str, node: ast.AST) -> None:
|
|
52
|
+
"""Add a binding to this scope."""
|
|
53
|
+
self.bindings[name] = Binding(name, self.scope_id, node)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _iter_argument_nodes(args: ast.arguments) -> Iterable[ast.arg]:
|
|
57
|
+
"""Yield every ast.arg (positional, keyword, var/kw) from an arguments block."""
|
|
58
|
+
for arg in args.posonlyargs:
|
|
59
|
+
yield arg
|
|
60
|
+
for arg in args.args:
|
|
61
|
+
yield arg
|
|
62
|
+
for arg in args.kwonlyargs:
|
|
63
|
+
yield arg
|
|
64
|
+
if args.vararg:
|
|
65
|
+
yield args.vararg
|
|
66
|
+
if args.kwarg:
|
|
67
|
+
yield args.kwarg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iter_argument_names(args: ast.arguments) -> Iterable[str]:
|
|
71
|
+
for arg in _iter_argument_nodes(args):
|
|
72
|
+
yield arg.arg
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _visit_node_and_iterables(
|
|
76
|
+
visitor: ast.NodeVisitor, first: ast.AST, rest: Iterable[ast.AST]
|
|
77
|
+
) -> None:
|
|
78
|
+
visitor.visit(first)
|
|
79
|
+
for expr in rest:
|
|
80
|
+
visitor.visit(expr)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _visit_comprehension_generators(
|
|
84
|
+
visitor: ast.NodeVisitor, generators: List[ast.comprehension]
|
|
85
|
+
) -> None:
|
|
86
|
+
for gen in generators:
|
|
87
|
+
visitor.visit(gen)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ScopeAnalyzer(ast.NodeVisitor):
|
|
91
|
+
"""
|
|
92
|
+
Analyze scopes and identifier bindings in an AST.
|
|
93
|
+
|
|
94
|
+
This builds a scope tree and tracks which identifiers refer to which
|
|
95
|
+
bindings in the enclosing environment.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
self.scope_counter = 0
|
|
100
|
+
self.current_scope: Optional[Scope] = None
|
|
101
|
+
self.root_scope: Optional[Scope] = None
|
|
102
|
+
|
|
103
|
+
# Map AST nodes to their scopes
|
|
104
|
+
self.node_scopes: Dict[ast.AST, Scope] = {}
|
|
105
|
+
|
|
106
|
+
# Map identifier uses to their bindings
|
|
107
|
+
self.identifier_bindings: Dict[ast.Name, Optional[Binding]] = {}
|
|
108
|
+
|
|
109
|
+
# Track global and nonlocal declarations per scope
|
|
110
|
+
# Maps scope_id -> set of variable names
|
|
111
|
+
self.global_vars: Dict[int, Set[str]] = {}
|
|
112
|
+
self.nonlocal_vars: Dict[int, Set[str]] = {}
|
|
113
|
+
|
|
114
|
+
# Cache for free-variable analysis (keyed by node identity tuple)
|
|
115
|
+
self._free_var_cache: Dict[Tuple[int, ...], Set[str]] = {}
|
|
116
|
+
|
|
117
|
+
def analyze(self, tree: ast.AST) -> Scope:
|
|
118
|
+
"""Analyze an AST and return the root scope."""
|
|
119
|
+
self.root_scope = self._create_scope(None)
|
|
120
|
+
self.current_scope = self.root_scope
|
|
121
|
+
self.visit(tree)
|
|
122
|
+
return self.root_scope
|
|
123
|
+
|
|
124
|
+
def _create_scope(self, parent: Optional[Scope]) -> Scope:
|
|
125
|
+
"""Create a new scope."""
|
|
126
|
+
scope = Scope(self.scope_counter, parent)
|
|
127
|
+
self.scope_counter += 1
|
|
128
|
+
if parent:
|
|
129
|
+
parent.children.append(scope)
|
|
130
|
+
return scope
|
|
131
|
+
|
|
132
|
+
def _enter_scope(self, node: ast.AST) -> Scope:
|
|
133
|
+
"""Enter a new scope."""
|
|
134
|
+
new_scope = self._create_scope(self.current_scope)
|
|
135
|
+
self.node_scopes[node] = new_scope
|
|
136
|
+
self.current_scope = new_scope
|
|
137
|
+
return new_scope
|
|
138
|
+
|
|
139
|
+
def _exit_scope(self) -> None:
|
|
140
|
+
"""Exit the current scope."""
|
|
141
|
+
if self.current_scope and self.current_scope.parent:
|
|
142
|
+
self.current_scope = self.current_scope.parent
|
|
143
|
+
|
|
144
|
+
def _enter_named_scope(
|
|
145
|
+
self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]
|
|
146
|
+
) -> None:
|
|
147
|
+
assert self.current_scope is not None
|
|
148
|
+
self.current_scope.add_binding(node.name, node)
|
|
149
|
+
self._enter_scope(node)
|
|
150
|
+
|
|
151
|
+
def _visit_body_and_exit(self, body: List[ast.stmt]) -> None:
|
|
152
|
+
for stmt in body:
|
|
153
|
+
self.visit(stmt)
|
|
154
|
+
self._exit_scope()
|
|
155
|
+
|
|
156
|
+
def _bind_function_parameters(self, args: ast.arguments) -> None:
|
|
157
|
+
assert self.current_scope is not None
|
|
158
|
+
for arg in _iter_argument_nodes(args):
|
|
159
|
+
self.current_scope.add_binding(arg.arg, arg)
|
|
160
|
+
|
|
161
|
+
def _visit_loop(self, node: Union[ast.For, ast.AsyncFor]) -> None:
|
|
162
|
+
self.visit(node.iter)
|
|
163
|
+
self._add_assignment_bindings(node.target)
|
|
164
|
+
for stmt in node.body:
|
|
165
|
+
self.visit(stmt)
|
|
166
|
+
for stmt in node.orelse:
|
|
167
|
+
self.visit(stmt)
|
|
168
|
+
|
|
169
|
+
def _visit_with_statement(self, node: Union[ast.With, ast.AsyncWith]) -> None:
|
|
170
|
+
for item in node.items:
|
|
171
|
+
self.visit(item.context_expr)
|
|
172
|
+
if item.optional_vars:
|
|
173
|
+
self._add_assignment_bindings(item.optional_vars)
|
|
174
|
+
for stmt in node.body:
|
|
175
|
+
self.visit(stmt)
|
|
176
|
+
|
|
177
|
+
def _record_scope_declaration(self, table: Dict[int, Set[str]], names: List[str]) -> None:
|
|
178
|
+
if not self.current_scope:
|
|
179
|
+
return
|
|
180
|
+
scope_id = self.current_scope.scope_id
|
|
181
|
+
table.setdefault(scope_id, set()).update(names)
|
|
182
|
+
|
|
183
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
184
|
+
"""Visit a function definition."""
|
|
185
|
+
self._enter_named_scope(node)
|
|
186
|
+
self._bind_function_parameters(node.args)
|
|
187
|
+
self._visit_body_and_exit(node.body)
|
|
188
|
+
|
|
189
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
190
|
+
"""Visit an async function definition."""
|
|
191
|
+
# Type ignore needed because mypy doesn't recognize structural compatibility
|
|
192
|
+
self.visit_FunctionDef(node) # type: ignore[arg-type]
|
|
193
|
+
|
|
194
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
195
|
+
"""Visit a class definition."""
|
|
196
|
+
self._enter_named_scope(node)
|
|
197
|
+
self._visit_body_and_exit(node.body)
|
|
198
|
+
|
|
199
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
200
|
+
"""Visit an assignment."""
|
|
201
|
+
# Visit RHS first
|
|
202
|
+
self.visit(node.value)
|
|
203
|
+
|
|
204
|
+
# Add bindings for LHS targets
|
|
205
|
+
for target in node.targets:
|
|
206
|
+
self._add_assignment_bindings(target)
|
|
207
|
+
|
|
208
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
209
|
+
"""Visit an annotated assignment."""
|
|
210
|
+
if node.value:
|
|
211
|
+
self.visit(node.value)
|
|
212
|
+
self._add_assignment_bindings(node.target)
|
|
213
|
+
|
|
214
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
215
|
+
"""Visit an augmented assignment."""
|
|
216
|
+
self.visit(node.value)
|
|
217
|
+
# Aug assigns don't create new bindings, they modify existing ones
|
|
218
|
+
self.visit(node.target)
|
|
219
|
+
|
|
220
|
+
def visit_For(self, node: ast.For) -> None:
|
|
221
|
+
"""Visit a for loop."""
|
|
222
|
+
self._visit_loop(node)
|
|
223
|
+
|
|
224
|
+
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
|
225
|
+
"""Visit an async for loop."""
|
|
226
|
+
self._visit_loop(node)
|
|
227
|
+
|
|
228
|
+
def visit_With(self, node: ast.With) -> None:
|
|
229
|
+
"""Visit a with statement."""
|
|
230
|
+
self._visit_with_statement(node)
|
|
231
|
+
|
|
232
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
|
233
|
+
"""Visit an async with statement."""
|
|
234
|
+
self._visit_with_statement(node)
|
|
235
|
+
|
|
236
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
237
|
+
"""Visit a named expression (walrus operator :=)."""
|
|
238
|
+
# Visit RHS first
|
|
239
|
+
self.visit(node.value)
|
|
240
|
+
# Target creates binding (and it leaks to enclosing scope)
|
|
241
|
+
if isinstance(node.target, ast.Name):
|
|
242
|
+
assert self.current_scope is not None
|
|
243
|
+
self.current_scope.add_binding(node.target.id, node.target)
|
|
244
|
+
|
|
245
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
246
|
+
"""Visit a global statement."""
|
|
247
|
+
self._record_scope_declaration(self.global_vars, node.names)
|
|
248
|
+
|
|
249
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
250
|
+
"""Visit a nonlocal statement."""
|
|
251
|
+
self._record_scope_declaration(self.nonlocal_vars, node.names)
|
|
252
|
+
|
|
253
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
254
|
+
"""Visit a comprehension."""
|
|
255
|
+
# Target creates bindings (in comprehension scope)
|
|
256
|
+
self._add_assignment_bindings(node.target)
|
|
257
|
+
_visit_node_and_iterables(self, node.iter, node.ifs)
|
|
258
|
+
|
|
259
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
260
|
+
if node.type:
|
|
261
|
+
self.visit(node.type)
|
|
262
|
+
if node.name:
|
|
263
|
+
assert self.current_scope is not None
|
|
264
|
+
self.current_scope.add_binding(node.name, node)
|
|
265
|
+
for stmt in node.body:
|
|
266
|
+
self.visit(stmt)
|
|
267
|
+
|
|
268
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
269
|
+
"""Visit a name reference."""
|
|
270
|
+
# Record which binding this identifier refers to
|
|
271
|
+
assert self.current_scope is not None
|
|
272
|
+
binding = self.current_scope.lookup(node.id)
|
|
273
|
+
self.identifier_bindings[node] = binding
|
|
274
|
+
|
|
275
|
+
def _add_assignment_bindings(self, target: ast.AST) -> None:
|
|
276
|
+
"""Add bindings created by an assignment target."""
|
|
277
|
+
if isinstance(target, ast.Name):
|
|
278
|
+
# Check if this variable is declared global or nonlocal
|
|
279
|
+
scope_id = self.current_scope.scope_id if self.current_scope else -1
|
|
280
|
+
is_global = scope_id in self.global_vars and target.id in self.global_vars[scope_id]
|
|
281
|
+
is_nonlocal = (
|
|
282
|
+
scope_id in self.nonlocal_vars and target.id in self.nonlocal_vars[scope_id]
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Only add as local binding if not global/nonlocal
|
|
286
|
+
if not is_global and not is_nonlocal:
|
|
287
|
+
assert self.current_scope is not None
|
|
288
|
+
self.current_scope.add_binding(target.id, target)
|
|
289
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
290
|
+
for elt in target.elts:
|
|
291
|
+
self._add_assignment_bindings(elt)
|
|
292
|
+
# Other cases (subscript, attribute) don't create bindings
|
|
293
|
+
|
|
294
|
+
def get_free_variables(self, nodes: List[ast.AST]) -> Set[str]:
|
|
295
|
+
"""
|
|
296
|
+
Get free variables in a block of code.
|
|
297
|
+
|
|
298
|
+
Free variables are identifiers that are referenced but not bound
|
|
299
|
+
within the block.
|
|
300
|
+
|
|
301
|
+
CRITICAL: Does NOT descend into nested function/class definitions,
|
|
302
|
+
since those have separate scopes.
|
|
303
|
+
|
|
304
|
+
Excludes Python builtins.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
# Custom visitor that doesn't descend into nested functions
|
|
308
|
+
class ScopeRespectingWalker(ast.NodeVisitor):
|
|
309
|
+
def __init__(self) -> None:
|
|
310
|
+
self.uses: Set[str] = set()
|
|
311
|
+
self.bindings: Set[str] = set()
|
|
312
|
+
self.used_before_assigned: Set[str] = set() # Variables used before assignment
|
|
313
|
+
self.assigned_so_far: Set[str] = set() # Variables assigned so far in traversal
|
|
314
|
+
self.global_vars: Set[str] = set() # Variables declared global
|
|
315
|
+
self.nonlocal_vars: Set[str] = set() # Variables declared nonlocal
|
|
316
|
+
|
|
317
|
+
def _extract_binding_names(self, target: ast.AST) -> Set[str]:
|
|
318
|
+
"""Extract variable names from an assignment target."""
|
|
319
|
+
names: Set[str] = set()
|
|
320
|
+
if isinstance(target, ast.Name):
|
|
321
|
+
names.add(target.id)
|
|
322
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
323
|
+
for elt in ast.walk(target):
|
|
324
|
+
if isinstance(elt, ast.Name) and isinstance(elt.ctx, ast.Store):
|
|
325
|
+
names.add(elt.id)
|
|
326
|
+
return names
|
|
327
|
+
|
|
328
|
+
def _with_new_scope(
|
|
329
|
+
self, new_bindings: Set[str], body_visitor: Callable[[], None]
|
|
330
|
+
) -> None:
|
|
331
|
+
"""
|
|
332
|
+
Execute body_visitor with new bindings in a nested scope.
|
|
333
|
+
Bindings don't leak out, but uses of free variables are captured.
|
|
334
|
+
"""
|
|
335
|
+
saved_bindings = self.bindings.copy()
|
|
336
|
+
saved_assigned = self.assigned_so_far.copy()
|
|
337
|
+
|
|
338
|
+
self.bindings.update(new_bindings)
|
|
339
|
+
self.assigned_so_far.update(new_bindings)
|
|
340
|
+
|
|
341
|
+
body_visitor()
|
|
342
|
+
|
|
343
|
+
# Remove ALL variables bound in this scope from uses
|
|
344
|
+
# This includes both new_bindings and any added during body_visitor
|
|
345
|
+
scope_bindings = self.bindings - saved_bindings
|
|
346
|
+
self.uses -= scope_bindings
|
|
347
|
+
|
|
348
|
+
# Restore bindings (they don't leak out of nested scope)
|
|
349
|
+
self.bindings = saved_bindings
|
|
350
|
+
self.assigned_so_far = saved_assigned
|
|
351
|
+
|
|
352
|
+
def _add_current_scope_bindings(self, new_bindings: Set[str]) -> None:
|
|
353
|
+
"""Add bindings to the current scope (they persist)."""
|
|
354
|
+
self.bindings.update(new_bindings)
|
|
355
|
+
self.assigned_so_far.update(new_bindings)
|
|
356
|
+
|
|
357
|
+
def _bind_callable_parameters(self, args: ast.arguments) -> None:
|
|
358
|
+
"""Reused parameter binding logic from the DRY run (docs/DRY_RUN_2025-11-28.md)."""
|
|
359
|
+
for name in _iter_argument_names(args):
|
|
360
|
+
self._add_current_scope_bindings({name})
|
|
361
|
+
|
|
362
|
+
def _visit_loop_with_bindings(self, node: Union[ast.For, ast.AsyncFor]) -> None:
|
|
363
|
+
"""Shared traversal for sync/async loops (see docs/DRY_RUN_2025-11-28.md)."""
|
|
364
|
+
self.visit(node.iter)
|
|
365
|
+
loop_vars = self._extract_binding_names(node.target)
|
|
366
|
+
self._add_current_scope_bindings(loop_vars)
|
|
367
|
+
for stmt in node.body:
|
|
368
|
+
self.visit(stmt)
|
|
369
|
+
for stmt in node.orelse:
|
|
370
|
+
self.visit(stmt)
|
|
371
|
+
|
|
372
|
+
def _visit_with_like_block(self, node: Union[ast.With, ast.AsyncWith]) -> None:
|
|
373
|
+
"""Centralizes binding + visitation for with/async-with blocks (DRY run helper)."""
|
|
374
|
+
for item in node.items:
|
|
375
|
+
self.visit(item.context_expr)
|
|
376
|
+
if item.optional_vars:
|
|
377
|
+
with_vars = self._extract_binding_names(item.optional_vars)
|
|
378
|
+
self._add_current_scope_bindings(with_vars)
|
|
379
|
+
for stmt in node.body:
|
|
380
|
+
self.visit(stmt)
|
|
381
|
+
|
|
382
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
383
|
+
if isinstance(node.ctx, ast.Load):
|
|
384
|
+
self.uses.add(node.id)
|
|
385
|
+
# If variable used before any assignment, mark it
|
|
386
|
+
if node.id not in self.assigned_so_far:
|
|
387
|
+
self.used_before_assigned.add(node.id)
|
|
388
|
+
elif isinstance(node.ctx, ast.Store):
|
|
389
|
+
# Check if this variable is global or nonlocal
|
|
390
|
+
if node.id in self.global_vars or node.id in self.nonlocal_vars:
|
|
391
|
+
# Global/nonlocal assignments are uses, not local bindings
|
|
392
|
+
self.uses.add(node.id)
|
|
393
|
+
else:
|
|
394
|
+
# Normal local binding
|
|
395
|
+
self.bindings.add(node.id)
|
|
396
|
+
self.assigned_so_far.add(node.id)
|
|
397
|
+
# Continue visiting (though Name has no children)
|
|
398
|
+
self.generic_visit(node)
|
|
399
|
+
|
|
400
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
401
|
+
"""Track global declarations."""
|
|
402
|
+
self.global_vars.update(node.names)
|
|
403
|
+
|
|
404
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
405
|
+
"""Track nonlocal declarations."""
|
|
406
|
+
self.nonlocal_vars.update(node.names)
|
|
407
|
+
|
|
408
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
409
|
+
# CRITICAL: Visit RHS before LHS to correctly track used-before-assigned
|
|
410
|
+
# In "x = y + 1", we must see the use of 'y' before marking 'x' as assigned
|
|
411
|
+
self.visit(node.value) # Visit RHS first
|
|
412
|
+
for target in node.targets:
|
|
413
|
+
self.visit(target) # Then visit LHS targets
|
|
414
|
+
|
|
415
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
416
|
+
# CRITICAL: Augmented assignments (x += 1) READ the variable
|
|
417
|
+
# They don't DEFINE it, so target should be in uses, NOT bindings
|
|
418
|
+
# (The variable must already exist for the augmented assignment to work)
|
|
419
|
+
if isinstance(node.target, ast.Name):
|
|
420
|
+
self.uses.add(node.target.id)
|
|
421
|
+
else:
|
|
422
|
+
# For subscripts (metrics["count"] += 1) or attributes (obj.x += 1),
|
|
423
|
+
# visit the target to capture the base variable
|
|
424
|
+
self.visit(node.target)
|
|
425
|
+
# Visit the RHS value
|
|
426
|
+
self.visit(node.value)
|
|
427
|
+
|
|
428
|
+
def visit_For(self, node: ast.For) -> None:
|
|
429
|
+
# Visit iterable first (before loop variable is bound)
|
|
430
|
+
self._visit_loop_with_bindings(node)
|
|
431
|
+
|
|
432
|
+
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
|
433
|
+
# Same as regular for loop
|
|
434
|
+
self._visit_loop_with_bindings(node)
|
|
435
|
+
|
|
436
|
+
def visit_With(self, node: ast.With) -> None:
|
|
437
|
+
# Visit context expressions first
|
|
438
|
+
self._visit_with_like_block(node)
|
|
439
|
+
|
|
440
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
|
441
|
+
# Same as regular with
|
|
442
|
+
self._visit_with_like_block(node)
|
|
443
|
+
|
|
444
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
445
|
+
# Walrus operator: Visit RHS first, then add binding
|
|
446
|
+
self.visit(node.value)
|
|
447
|
+
if isinstance(node.target, ast.Name):
|
|
448
|
+
# Walrus bindings leak into the current scope
|
|
449
|
+
self.bindings.add(node.target.id)
|
|
450
|
+
self.assigned_so_far.add(node.target.id)
|
|
451
|
+
|
|
452
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
453
|
+
# Comprehension variable binds within comprehension scope
|
|
454
|
+
# This is called from _visit_comprehension_node which handles scoping
|
|
455
|
+
comp_vars = self._extract_binding_names(node.target)
|
|
456
|
+
self._add_current_scope_bindings(comp_vars)
|
|
457
|
+
# Visit the rest (iter, ifs)
|
|
458
|
+
self.generic_visit(node)
|
|
459
|
+
|
|
460
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
461
|
+
# Function name binds in outer scope
|
|
462
|
+
self._add_current_scope_bindings({node.name})
|
|
463
|
+
|
|
464
|
+
# Visit function body in a nested scope to capture free variables
|
|
465
|
+
# that the function closes over. This is important when the function
|
|
466
|
+
# definition itself is part of the extracted code.
|
|
467
|
+
def visit_func_body() -> None:
|
|
468
|
+
# Add function parameters as bindings in the nested scope
|
|
469
|
+
self._bind_callable_parameters(node.args)
|
|
470
|
+
|
|
471
|
+
# Visit function body
|
|
472
|
+
for stmt in node.body:
|
|
473
|
+
self.visit(stmt)
|
|
474
|
+
|
|
475
|
+
self._with_new_scope(set(), visit_func_body)
|
|
476
|
+
|
|
477
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
478
|
+
# Same as FunctionDef
|
|
479
|
+
# Type ignore needed because mypy doesn't recognize structural compatibility
|
|
480
|
+
self.visit_FunctionDef(node) # type: ignore[arg-type]
|
|
481
|
+
|
|
482
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
483
|
+
# Class name binds in outer scope
|
|
484
|
+
self._add_current_scope_bindings({node.name})
|
|
485
|
+
# DON'T visit body - it's a different scope!
|
|
486
|
+
|
|
487
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
488
|
+
# Lambda creates a nested scope
|
|
489
|
+
# Parameters bind within the lambda, but don't leak out
|
|
490
|
+
# Free variables used in body are captured from outer scope
|
|
491
|
+
def visit_body() -> None:
|
|
492
|
+
# Add lambda parameters as bindings
|
|
493
|
+
self._bind_callable_parameters(node.args)
|
|
494
|
+
|
|
495
|
+
# Visit lambda body
|
|
496
|
+
self.visit(node.body)
|
|
497
|
+
|
|
498
|
+
self._with_new_scope(set(), visit_body)
|
|
499
|
+
|
|
500
|
+
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
501
|
+
# List comprehensions have their own scope (Python 3+)
|
|
502
|
+
def visit_body() -> None:
|
|
503
|
+
_visit_comprehension_generators(self, node.generators)
|
|
504
|
+
self.visit(node.elt)
|
|
505
|
+
|
|
506
|
+
self._with_new_scope(set(), visit_body)
|
|
507
|
+
|
|
508
|
+
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
509
|
+
# Dict comprehensions have their own scope
|
|
510
|
+
def visit_body() -> None:
|
|
511
|
+
_visit_comprehension_generators(self, node.generators)
|
|
512
|
+
self.visit(node.key)
|
|
513
|
+
self.visit(node.value)
|
|
514
|
+
|
|
515
|
+
self._with_new_scope(set(), visit_body)
|
|
516
|
+
|
|
517
|
+
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
518
|
+
# Set comprehensions have their own scope
|
|
519
|
+
def visit_body() -> None:
|
|
520
|
+
_visit_comprehension_generators(self, node.generators)
|
|
521
|
+
self.visit(node.elt)
|
|
522
|
+
|
|
523
|
+
self._with_new_scope(set(), visit_body)
|
|
524
|
+
|
|
525
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
526
|
+
# Generator expressions have their own scope
|
|
527
|
+
def visit_body() -> None:
|
|
528
|
+
_visit_comprehension_generators(self, node.generators)
|
|
529
|
+
self.visit(node.elt)
|
|
530
|
+
|
|
531
|
+
self._with_new_scope(set(), visit_body)
|
|
532
|
+
|
|
533
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
534
|
+
# Exception variable binds in current scope
|
|
535
|
+
# In: except ValueError as e:
|
|
536
|
+
# The variable 'e' is bound here
|
|
537
|
+
if node.name:
|
|
538
|
+
self._add_current_scope_bindings({node.name})
|
|
539
|
+
# Visit the rest (type, body)
|
|
540
|
+
self.generic_visit(node)
|
|
541
|
+
|
|
542
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
543
|
+
# import foo, bar as baz
|
|
544
|
+
# Binds: foo, baz
|
|
545
|
+
imports: Set[str] = set()
|
|
546
|
+
for alias in node.names:
|
|
547
|
+
name = alias.asname if alias.asname else alias.name
|
|
548
|
+
imports.add(name)
|
|
549
|
+
self._add_current_scope_bindings(imports)
|
|
550
|
+
|
|
551
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
552
|
+
# from module import foo, bar as baz
|
|
553
|
+
# Binds: foo, baz
|
|
554
|
+
imports: Set[str] = set()
|
|
555
|
+
for alias in node.names:
|
|
556
|
+
if alias.name == "*":
|
|
557
|
+
# from module import * - skip, can't determine bindings
|
|
558
|
+
continue
|
|
559
|
+
name = alias.asname if alias.asname else alias.name
|
|
560
|
+
imports.add(name)
|
|
561
|
+
self._add_current_scope_bindings(imports)
|
|
562
|
+
|
|
563
|
+
cache_key = tuple(id(node) for node in nodes)
|
|
564
|
+
if cache_key:
|
|
565
|
+
cached = self._free_var_cache.get(cache_key)
|
|
566
|
+
if cached is not None:
|
|
567
|
+
return set(cached)
|
|
568
|
+
|
|
569
|
+
# Collect uses and bindings
|
|
570
|
+
walker = ScopeRespectingWalker()
|
|
571
|
+
for node in nodes:
|
|
572
|
+
walker.visit(node)
|
|
573
|
+
|
|
574
|
+
uses = walker.uses
|
|
575
|
+
bindings = walker.bindings
|
|
576
|
+
used_before_assigned = walker.used_before_assigned
|
|
577
|
+
|
|
578
|
+
# Free variables are:
|
|
579
|
+
# 1. Variables used but not bound (classic free variables)
|
|
580
|
+
# 2. Variables used before they're assigned (shadowing cases)
|
|
581
|
+
#
|
|
582
|
+
# For case 2: If a variable is both used and assigned, Python treats it
|
|
583
|
+
# as a local variable for the ENTIRE scope. If it's used before it's
|
|
584
|
+
# assigned, we get UnboundLocalError unless it's passed as a parameter.
|
|
585
|
+
# So we must include such variables in free_vars.
|
|
586
|
+
free_vars = (uses - bindings) | (used_before_assigned & bindings)
|
|
587
|
+
|
|
588
|
+
# Filter out Python builtins
|
|
589
|
+
free_vars = filter_builtins(free_vars)
|
|
590
|
+
|
|
591
|
+
if cache_key:
|
|
592
|
+
self._free_var_cache[cache_key] = set(free_vars)
|
|
593
|
+
return free_vars
|
|
594
|
+
|
|
595
|
+
def get_binding_for_name(self, name_node: ast.Name) -> Optional[Binding]:
|
|
596
|
+
"""Get the binding for a name node."""
|
|
597
|
+
return self.identifier_bindings.get(name_node)
|