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,133 @@
|
|
|
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
|
+
AST Pretty Printer
|
|
17
|
+
|
|
18
|
+
A utility for displaying Python AST structures in a human-readable format.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from typing import Any, Optional, List
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ASTPrettyPrinter:
|
|
26
|
+
"""Pretty-print Python AST nodes in a readable tree format."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, indent_size: int = 2, show_line_numbers: bool = False):
|
|
29
|
+
self.indent_size = indent_size
|
|
30
|
+
self.show_line_numbers = show_line_numbers
|
|
31
|
+
|
|
32
|
+
def format(self, node: ast.AST, indent: int = 0) -> str:
|
|
33
|
+
"""Format an AST node as a pretty-printed string."""
|
|
34
|
+
lines: List[str] = []
|
|
35
|
+
self._format_node(node, indent, lines)
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
def _format_node(self, node: Any, indent: int, lines: List[str]) -> None:
|
|
39
|
+
"""Recursively format an AST node."""
|
|
40
|
+
prefix = " " * indent
|
|
41
|
+
|
|
42
|
+
if isinstance(node, ast.AST):
|
|
43
|
+
# Format the node type
|
|
44
|
+
node_name = node.__class__.__name__
|
|
45
|
+
|
|
46
|
+
# Add line number if available and requested
|
|
47
|
+
if self.show_line_numbers and hasattr(node, "lineno"):
|
|
48
|
+
node_name = f"{node_name}@L{node.lineno}"
|
|
49
|
+
|
|
50
|
+
lines.append(f"{prefix}{node_name}(")
|
|
51
|
+
|
|
52
|
+
# Get all fields for this node
|
|
53
|
+
for field_name, field_value in ast.iter_fields(node):
|
|
54
|
+
field_prefix = " " * (indent + self.indent_size)
|
|
55
|
+
|
|
56
|
+
if field_value is None:
|
|
57
|
+
continue
|
|
58
|
+
elif isinstance(field_value, list):
|
|
59
|
+
if not field_value:
|
|
60
|
+
continue
|
|
61
|
+
lines.append(f"{field_prefix}{field_name}=[")
|
|
62
|
+
for item in field_value:
|
|
63
|
+
self._format_node(item, indent + self.indent_size * 2, lines)
|
|
64
|
+
lines.append(f"{field_prefix}]")
|
|
65
|
+
elif isinstance(field_value, ast.AST):
|
|
66
|
+
lines.append(f"{field_prefix}{field_name}=")
|
|
67
|
+
self._format_node(field_value, indent + self.indent_size * 2, lines)
|
|
68
|
+
else:
|
|
69
|
+
# Primitive value (string, int, etc.)
|
|
70
|
+
lines.append(f"{field_prefix}{field_name}={self._format_value(field_value)}")
|
|
71
|
+
|
|
72
|
+
lines.append(f"{prefix})")
|
|
73
|
+
|
|
74
|
+
elif isinstance(node, list):
|
|
75
|
+
lines.append(f"{prefix}[")
|
|
76
|
+
for item in node:
|
|
77
|
+
self._format_node(item, indent + self.indent_size, lines)
|
|
78
|
+
lines.append(f"{prefix}]")
|
|
79
|
+
|
|
80
|
+
else:
|
|
81
|
+
# Primitive value
|
|
82
|
+
lines.append(f"{prefix}{self._format_value(node)}")
|
|
83
|
+
|
|
84
|
+
def _format_value(self, value: Any) -> str:
|
|
85
|
+
"""Format a primitive value for display."""
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
return repr(value)
|
|
88
|
+
else:
|
|
89
|
+
return str(value)
|
|
90
|
+
|
|
91
|
+
def print(self, node: ast.AST, title: Optional[str] = None) -> None:
|
|
92
|
+
"""Print an AST node with an optional title."""
|
|
93
|
+
if title:
|
|
94
|
+
print("=" * 80)
|
|
95
|
+
print(title)
|
|
96
|
+
print("=" * 80)
|
|
97
|
+
print(self.format(node))
|
|
98
|
+
print()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def print_ast(
|
|
102
|
+
node: ast.AST,
|
|
103
|
+
title: Optional[str] = None,
|
|
104
|
+
indent_size: int = 2,
|
|
105
|
+
show_line_numbers: bool = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Convenience function to pretty-print an AST node.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
node: The AST node to print
|
|
112
|
+
title: Optional title to display before the AST
|
|
113
|
+
indent_size: Number of spaces per indentation level
|
|
114
|
+
show_line_numbers: Whether to show line numbers in the output
|
|
115
|
+
"""
|
|
116
|
+
printer = ASTPrettyPrinter(indent_size=indent_size, show_line_numbers=show_line_numbers)
|
|
117
|
+
printer.print(node, title=title)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compare_asts(
|
|
121
|
+
node1: ast.AST, node2: ast.AST, title1: str = "AST 1", title2: str = "AST 2"
|
|
122
|
+
) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Print two ASTs side by side for comparison.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
node1: First AST node
|
|
128
|
+
node2: Second AST node
|
|
129
|
+
title1: Title for first AST
|
|
130
|
+
title2: Title for second AST
|
|
131
|
+
"""
|
|
132
|
+
print_ast(node1, title=title1)
|
|
133
|
+
print_ast(node2, title=title2)
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright 2025 Eric Allen
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
Binding Detector for Nominal Unification
|
|
18
|
+
|
|
19
|
+
This module identifies all Python constructs that bind variables, which is
|
|
20
|
+
essential for implementing nominal unification as described in:
|
|
21
|
+
https://arxiv.org/pdf/1012.4890
|
|
22
|
+
|
|
23
|
+
Nominal unification handles α-equivalence: code that differs only in the
|
|
24
|
+
names of bound variables. For example:
|
|
25
|
+
Block 1: user = {...}; validate(user)
|
|
26
|
+
Block 2: admin = {...}; validate(admin)
|
|
27
|
+
|
|
28
|
+
These blocks are α-equivalent if 'user' and 'admin' play the same structural
|
|
29
|
+
role (both are bound variables with identical usage patterns).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import ast
|
|
33
|
+
from typing import List, Optional, Set, Union
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import Enum
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BindingKind(Enum):
|
|
39
|
+
"""Types of variable bindings in Python."""
|
|
40
|
+
|
|
41
|
+
ASSIGNMENT = "assignment" # x = 1
|
|
42
|
+
AUG_ASSIGNMENT = "aug_assignment" # x += 1 (requires x to exist)
|
|
43
|
+
FOR_LOOP = "for_loop" # for x in iterable:
|
|
44
|
+
COMPREHENSION = "comprehension" # [x for x in ...]
|
|
45
|
+
EXCEPTION = "exception" # except E as e:
|
|
46
|
+
WITH_STMT = "with" # with ... as x:
|
|
47
|
+
FUNCTION_PARAM = "function_param" # def f(x): or lambda x:
|
|
48
|
+
NAMED_EXPR = "named_expr" # if (x := foo()):
|
|
49
|
+
MATCH_CASE = "match_case" # case pattern as x:
|
|
50
|
+
IMPORT = "import" # import x, from y import x
|
|
51
|
+
FUNCTION_DEF = "function_def" # def foo():
|
|
52
|
+
CLASS_DEF = "class_def" # class Foo:
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Binding:
|
|
57
|
+
"""Represents a variable binding."""
|
|
58
|
+
|
|
59
|
+
name: str # Variable name
|
|
60
|
+
kind: BindingKind # Type of binding
|
|
61
|
+
node: ast.AST # AST node where binding occurs
|
|
62
|
+
scope_node: Optional[ast.AST] # AST node defining the scope (function, class, module)
|
|
63
|
+
line_number: int # Line number for debugging
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BindingDetector(ast.NodeVisitor):
|
|
67
|
+
"""
|
|
68
|
+
Detects all variable bindings in a Python AST.
|
|
69
|
+
|
|
70
|
+
This visitor traverses the AST and identifies every construct that
|
|
71
|
+
introduces a new variable binding, tracking:
|
|
72
|
+
- The variable name
|
|
73
|
+
- The kind of binding (assignment, loop, exception, etc.)
|
|
74
|
+
- The AST node where the binding occurs
|
|
75
|
+
- The scope in which the binding is visible
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.bindings: List[Binding] = []
|
|
80
|
+
self.scope_stack: List[ast.AST] = [] # Track nested scopes
|
|
81
|
+
|
|
82
|
+
def _current_scope(self) -> Optional[ast.AST]:
|
|
83
|
+
"""Get the current scope node (function, class, or module)."""
|
|
84
|
+
return self.scope_stack[-1] if self.scope_stack else None
|
|
85
|
+
|
|
86
|
+
def _add_binding(self, name: str, kind: BindingKind, node: ast.AST) -> None:
|
|
87
|
+
"""Record a variable binding."""
|
|
88
|
+
self.bindings.append(
|
|
89
|
+
Binding(
|
|
90
|
+
name=name,
|
|
91
|
+
kind=kind,
|
|
92
|
+
node=node,
|
|
93
|
+
scope_node=self._current_scope(),
|
|
94
|
+
line_number=getattr(node, "lineno", -1),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _extract_names_from_target(self, target: ast.AST, kind: BindingKind) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Extract all variable names from an assignment target.
|
|
101
|
+
|
|
102
|
+
Handles complex targets like:
|
|
103
|
+
- Simple names: x
|
|
104
|
+
- Tuples: x, y = ...
|
|
105
|
+
- Lists: [x, y] = ...
|
|
106
|
+
- Starred: x, *rest = ...
|
|
107
|
+
- Nested: (x, (y, z)) = ...
|
|
108
|
+
|
|
109
|
+
Does NOT extract attribute assignments (x.y) or subscripts (x[i])
|
|
110
|
+
as these don't create new bindings.
|
|
111
|
+
"""
|
|
112
|
+
if isinstance(target, ast.Name):
|
|
113
|
+
self._add_binding(target.id, kind, target)
|
|
114
|
+
|
|
115
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
116
|
+
for elt in target.elts:
|
|
117
|
+
self._extract_names_from_target(elt, kind)
|
|
118
|
+
|
|
119
|
+
elif isinstance(target, ast.Starred):
|
|
120
|
+
self._extract_names_from_target(target.value, kind)
|
|
121
|
+
|
|
122
|
+
# Ignore ast.Subscript (x[i] = ...) and ast.Attribute (x.y = ...)
|
|
123
|
+
# as these modify existing objects rather than creating new bindings
|
|
124
|
+
|
|
125
|
+
# Assignment statements
|
|
126
|
+
|
|
127
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
128
|
+
"""Handle simple assignments: x = 1, x = y = 1, etc."""
|
|
129
|
+
for target in node.targets:
|
|
130
|
+
self._extract_names_from_target(target, BindingKind.ASSIGNMENT)
|
|
131
|
+
self.generic_visit(node)
|
|
132
|
+
|
|
133
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
134
|
+
"""Handle augmented assignments: x += 1, x *= 2, etc."""
|
|
135
|
+
# Note: Augmented assignment requires the variable to already exist,
|
|
136
|
+
# but it's still considered a binding for our purposes
|
|
137
|
+
self._extract_names_from_target(node.target, BindingKind.AUG_ASSIGNMENT)
|
|
138
|
+
self.generic_visit(node)
|
|
139
|
+
|
|
140
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
141
|
+
"""Handle annotated assignments: x: int = 1"""
|
|
142
|
+
if node.value is not None: # x: int (without assignment) doesn't bind
|
|
143
|
+
self._extract_names_from_target(node.target, BindingKind.ASSIGNMENT)
|
|
144
|
+
self.generic_visit(node)
|
|
145
|
+
|
|
146
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
147
|
+
"""Handle walrus operator: if (x := foo()):"""
|
|
148
|
+
self._extract_names_from_target(node.target, BindingKind.NAMED_EXPR)
|
|
149
|
+
self.generic_visit(node)
|
|
150
|
+
|
|
151
|
+
# Loop constructs
|
|
152
|
+
|
|
153
|
+
def visit_For(self, node: ast.For) -> None:
|
|
154
|
+
"""Handle for loops: for x in iterable:"""
|
|
155
|
+
self._extract_names_from_target(node.target, BindingKind.FOR_LOOP)
|
|
156
|
+
self.generic_visit(node)
|
|
157
|
+
|
|
158
|
+
def visit_comprehension(self, node: ast.comprehension) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Handle comprehension variables: [x for x in ...], {k: v for k, v in ...}
|
|
161
|
+
|
|
162
|
+
Note: This is called for list/dict/set comprehensions and generators.
|
|
163
|
+
"""
|
|
164
|
+
self._extract_names_from_target(node.target, BindingKind.COMPREHENSION)
|
|
165
|
+
self.generic_visit(node)
|
|
166
|
+
|
|
167
|
+
# Exception handling
|
|
168
|
+
|
|
169
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
170
|
+
"""Handle exception binding: except Exception as e:"""
|
|
171
|
+
self._add_optional_binding(node.name, BindingKind.EXCEPTION, node)
|
|
172
|
+
self.generic_visit(node)
|
|
173
|
+
|
|
174
|
+
# Context managers
|
|
175
|
+
|
|
176
|
+
def visit_With(self, node: ast.With) -> None:
|
|
177
|
+
"""Handle with statements: with open() as f:"""
|
|
178
|
+
for item in node.items:
|
|
179
|
+
if item.optional_vars:
|
|
180
|
+
self._extract_names_from_target(item.optional_vars, BindingKind.WITH_STMT)
|
|
181
|
+
self.generic_visit(node)
|
|
182
|
+
|
|
183
|
+
# Function and class definitions
|
|
184
|
+
|
|
185
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
186
|
+
"""Handle function definitions: def foo(x, y):"""
|
|
187
|
+
self._visit_function_definition(node)
|
|
188
|
+
|
|
189
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
190
|
+
"""Handle async function definitions: async def foo():"""
|
|
191
|
+
self._visit_function_definition(node)
|
|
192
|
+
|
|
193
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
194
|
+
"""Handle lambda expressions: lambda x, y: x + y"""
|
|
195
|
+
self._enter_function_like_scope(node)
|
|
196
|
+
self.visit(node.body)
|
|
197
|
+
self.scope_stack.pop()
|
|
198
|
+
|
|
199
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
200
|
+
"""Handle class definitions: class Foo:"""
|
|
201
|
+
# The class name is a binding in the enclosing scope
|
|
202
|
+
self._add_binding(node.name, BindingKind.CLASS_DEF, node)
|
|
203
|
+
|
|
204
|
+
# Enter class scope
|
|
205
|
+
self.scope_stack.append(node)
|
|
206
|
+
|
|
207
|
+
# Visit class body
|
|
208
|
+
self._visit_body_and_pop(node)
|
|
209
|
+
|
|
210
|
+
# Visit decorators and bases (in enclosing scope)
|
|
211
|
+
for decorator in node.decorator_list:
|
|
212
|
+
self.visit(decorator)
|
|
213
|
+
for base in node.bases:
|
|
214
|
+
self.visit(base)
|
|
215
|
+
for keyword in node.keywords:
|
|
216
|
+
self.visit(keyword.value)
|
|
217
|
+
|
|
218
|
+
# Import statements
|
|
219
|
+
|
|
220
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
221
|
+
"""Handle import statements: import x, import y as z"""
|
|
222
|
+
for alias in node.names:
|
|
223
|
+
# Use the alias if provided, otherwise the module name
|
|
224
|
+
name = alias.asname if alias.asname else alias.name
|
|
225
|
+
self._add_binding(name, BindingKind.IMPORT, node)
|
|
226
|
+
self.generic_visit(node)
|
|
227
|
+
|
|
228
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
229
|
+
"""Handle from-import statements: from x import y, from x import y as z"""
|
|
230
|
+
for alias in node.names:
|
|
231
|
+
resolved = _resolve_import_alias(alias)
|
|
232
|
+
if resolved:
|
|
233
|
+
self._add_binding(resolved, BindingKind.IMPORT, node)
|
|
234
|
+
self.generic_visit(node)
|
|
235
|
+
|
|
236
|
+
# Match statements (Python 3.10+)
|
|
237
|
+
|
|
238
|
+
def visit_Match(self, node: ast.Match) -> None:
|
|
239
|
+
"""Handle match statements: match x: case pattern:"""
|
|
240
|
+
# Visit the subject
|
|
241
|
+
self.visit(node.subject)
|
|
242
|
+
|
|
243
|
+
# Visit each case
|
|
244
|
+
for case in node.cases:
|
|
245
|
+
# Extract bindings from the pattern
|
|
246
|
+
self._extract_pattern_bindings(case.pattern)
|
|
247
|
+
|
|
248
|
+
# Visit guard and body
|
|
249
|
+
if case.guard:
|
|
250
|
+
self.visit(case.guard)
|
|
251
|
+
for stmt in case.body:
|
|
252
|
+
self.visit(stmt)
|
|
253
|
+
|
|
254
|
+
def _extract_pattern_bindings(self, pattern: ast.AST) -> None:
|
|
255
|
+
"""Extract variable bindings from match patterns."""
|
|
256
|
+
if isinstance(pattern, ast.MatchAs):
|
|
257
|
+
# case pattern as x:
|
|
258
|
+
self._add_optional_binding(pattern.name, BindingKind.MATCH_CASE, pattern)
|
|
259
|
+
if pattern.pattern:
|
|
260
|
+
self._extract_pattern_bindings(pattern.pattern)
|
|
261
|
+
|
|
262
|
+
elif isinstance(pattern, ast.MatchOr):
|
|
263
|
+
# case pattern1 | pattern2:
|
|
264
|
+
for subpattern in pattern.patterns:
|
|
265
|
+
self._extract_pattern_bindings(subpattern)
|
|
266
|
+
|
|
267
|
+
elif isinstance(pattern, ast.MatchSequence):
|
|
268
|
+
# case [x, y]:
|
|
269
|
+
for subpattern in pattern.patterns:
|
|
270
|
+
self._extract_pattern_bindings(subpattern)
|
|
271
|
+
|
|
272
|
+
elif isinstance(pattern, ast.MatchMapping):
|
|
273
|
+
# case {"key": value}:
|
|
274
|
+
for subpattern in pattern.patterns:
|
|
275
|
+
self._extract_pattern_bindings(subpattern)
|
|
276
|
+
if pattern.rest:
|
|
277
|
+
self._add_binding(pattern.rest, BindingKind.MATCH_CASE, pattern)
|
|
278
|
+
|
|
279
|
+
elif isinstance(pattern, ast.MatchClass):
|
|
280
|
+
# case ClassName(x, y):
|
|
281
|
+
for subpattern in pattern.patterns:
|
|
282
|
+
self._extract_pattern_bindings(subpattern)
|
|
283
|
+
for subpattern in pattern.kwd_patterns:
|
|
284
|
+
self._extract_pattern_bindings(subpattern)
|
|
285
|
+
|
|
286
|
+
elif isinstance(pattern, ast.MatchStar):
|
|
287
|
+
# case [*rest]:
|
|
288
|
+
if pattern.name:
|
|
289
|
+
self._add_binding(pattern.name, BindingKind.MATCH_CASE, pattern)
|
|
290
|
+
|
|
291
|
+
# Other patterns (MatchValue, MatchSingleton) don't bind variables
|
|
292
|
+
|
|
293
|
+
def _visit_function_definition(
|
|
294
|
+
self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
|
|
295
|
+
) -> None:
|
|
296
|
+
self._add_binding(node.name, BindingKind.FUNCTION_DEF, node)
|
|
297
|
+
self._enter_function_like_scope(node)
|
|
298
|
+
self._visit_body_and_pop(node)
|
|
299
|
+
for decorator in node.decorator_list:
|
|
300
|
+
self.visit(decorator)
|
|
301
|
+
|
|
302
|
+
def _enter_function_like_scope(
|
|
303
|
+
self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda]
|
|
304
|
+
) -> None:
|
|
305
|
+
self.scope_stack.append(node)
|
|
306
|
+
self._bind_function_parameters(node.args)
|
|
307
|
+
|
|
308
|
+
def _visit_body_and_pop(self, node: ast.AST) -> None:
|
|
309
|
+
for stmt in getattr(node, "body", []):
|
|
310
|
+
self.visit(stmt)
|
|
311
|
+
self.scope_stack.pop()
|
|
312
|
+
|
|
313
|
+
def _bind_function_parameters(self, args: ast.arguments) -> None:
|
|
314
|
+
for arg in (*args.posonlyargs, *args.args):
|
|
315
|
+
self._add_binding(arg.arg, BindingKind.FUNCTION_PARAM, arg)
|
|
316
|
+
if args.vararg:
|
|
317
|
+
self._add_binding(args.vararg.arg, BindingKind.FUNCTION_PARAM, args.vararg)
|
|
318
|
+
if args.kwarg:
|
|
319
|
+
self._add_binding(args.kwarg.arg, BindingKind.FUNCTION_PARAM, args.kwarg)
|
|
320
|
+
for arg in args.kwonlyargs:
|
|
321
|
+
self._add_binding(arg.arg, BindingKind.FUNCTION_PARAM, arg)
|
|
322
|
+
|
|
323
|
+
def _add_optional_binding(self, name: Optional[str], kind: BindingKind, node: ast.AST) -> None:
|
|
324
|
+
if name:
|
|
325
|
+
self._add_binding(name, kind, node)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_bindings(tree: ast.AST) -> List[Binding]:
|
|
329
|
+
"""
|
|
330
|
+
Detect all variable bindings in an AST.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
tree: The AST to analyze
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of all variable bindings found
|
|
337
|
+
"""
|
|
338
|
+
detector = BindingDetector()
|
|
339
|
+
detector.visit(tree)
|
|
340
|
+
return detector.bindings
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_bound_variables(tree: ast.AST, scope_node: Optional[ast.AST] = None) -> Set[str]:
|
|
344
|
+
"""
|
|
345
|
+
Get the set of all variable names bound in an AST.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
tree: The AST to analyze
|
|
349
|
+
scope_node: If provided, only return bindings in this scope
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Set of variable names
|
|
353
|
+
"""
|
|
354
|
+
bindings = detect_bindings(tree)
|
|
355
|
+
|
|
356
|
+
if scope_node is not None:
|
|
357
|
+
bindings = [b for b in bindings if b.scope_node == scope_node]
|
|
358
|
+
|
|
359
|
+
return {b.name for b in bindings}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_bindings_by_kind(tree: ast.AST, kind: BindingKind) -> List[Binding]:
|
|
363
|
+
"""
|
|
364
|
+
Get all bindings of a specific kind.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
tree: The AST to analyze
|
|
368
|
+
kind: The kind of binding to filter for
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of bindings of the specified kind
|
|
372
|
+
"""
|
|
373
|
+
bindings = detect_bindings(tree)
|
|
374
|
+
return [b for b in bindings if b.kind == kind]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _resolve_import_alias(alias: ast.alias) -> Optional[str]:
|
|
378
|
+
"""Return the binding name for an import alias, or None for ``*`` imports."""
|
|
379
|
+
if alias.name == "*":
|
|
380
|
+
return None
|
|
381
|
+
return alias.asname if alias.asname else alias.name
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
"""Conservative block signature utilities used for cheap pre-filtering.
|
|
16
|
+
|
|
17
|
+
This rollback version intentionally omits aggressive heuristics and telemetry.
|
|
18
|
+
Only very low-risk structural gates are applied:
|
|
19
|
+
1. Same statement count
|
|
20
|
+
2. try/with presence alignment
|
|
21
|
+
3. Matching first and last statement types
|
|
22
|
+
|
|
23
|
+
Anything passing these gates is considered for full unification. All other
|
|
24
|
+
behavior (ratio/histogram/simhash) has been removed to restore baseline
|
|
25
|
+
proposal counts and prevent false negatives introduced by over‑filtering.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import List, Tuple, Dict, Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
IDENT_COUNT_TOLERANCE = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BlockSignature:
|
|
40
|
+
"""Structural fingerprint of a code block for fast similarity pre-filtering.
|
|
41
|
+
|
|
42
|
+
Contains conservative metrics (statement count, control flow presence, name/call counts)
|
|
43
|
+
used to quickly reject obviously dissimilar blocks before expensive unification.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
stmt_count: int
|
|
47
|
+
stmt_seq: Tuple[str, ...]
|
|
48
|
+
has_with: bool
|
|
49
|
+
has_try: bool
|
|
50
|
+
name_load_count: int
|
|
51
|
+
name_store_count: int
|
|
52
|
+
call_count: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_block_signature(block: List[ast.AST]) -> BlockSignature:
|
|
56
|
+
stmt_seq = tuple(type(s).__name__ for s in block)
|
|
57
|
+
has_with = False
|
|
58
|
+
has_try = False
|
|
59
|
+
name_load_count = 0
|
|
60
|
+
name_store_count = 0
|
|
61
|
+
call_count = 0
|
|
62
|
+
|
|
63
|
+
skip_types = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
64
|
+
|
|
65
|
+
for stmt in block:
|
|
66
|
+
stack = [stmt]
|
|
67
|
+
while stack:
|
|
68
|
+
node = stack.pop()
|
|
69
|
+
|
|
70
|
+
if isinstance(node, skip_types):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if not has_with and isinstance(node, ast.With):
|
|
74
|
+
has_with = True
|
|
75
|
+
if not has_try and isinstance(node, ast.Try):
|
|
76
|
+
has_try = True
|
|
77
|
+
if isinstance(node, ast.Name):
|
|
78
|
+
if isinstance(node.ctx, ast.Load):
|
|
79
|
+
name_load_count += 1
|
|
80
|
+
elif isinstance(node.ctx, ast.Store):
|
|
81
|
+
name_store_count += 1
|
|
82
|
+
elif isinstance(node, ast.Call):
|
|
83
|
+
call_count += 1
|
|
84
|
+
|
|
85
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
86
|
+
|
|
87
|
+
return BlockSignature(
|
|
88
|
+
stmt_count=len(block),
|
|
89
|
+
stmt_seq=stmt_seq,
|
|
90
|
+
has_with=has_with,
|
|
91
|
+
has_try=has_try,
|
|
92
|
+
name_load_count=name_load_count,
|
|
93
|
+
name_store_count=name_store_count,
|
|
94
|
+
call_count=call_count,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def quick_filter(sig1: BlockSignature, sig2: BlockSignature) -> bool:
|
|
99
|
+
"""Return True if the pair should be considered for unification.
|
|
100
|
+
|
|
101
|
+
Extremely simple structural checks matching the original conservative
|
|
102
|
+
behavior prior to aggressive experimentation.
|
|
103
|
+
"""
|
|
104
|
+
if sig1.stmt_count != sig2.stmt_count:
|
|
105
|
+
return False
|
|
106
|
+
if sig1.has_with != sig2.has_with or sig1.has_try != sig2.has_try:
|
|
107
|
+
return False
|
|
108
|
+
if abs(sig1.name_load_count - sig2.name_load_count) > IDENT_COUNT_TOLERANCE:
|
|
109
|
+
return False
|
|
110
|
+
if abs(sig1.name_store_count - sig2.name_store_count) > IDENT_COUNT_TOLERANCE:
|
|
111
|
+
return False
|
|
112
|
+
if abs(sig1.call_count - sig2.call_count) > IDENT_COUNT_TOLERANCE:
|
|
113
|
+
return False
|
|
114
|
+
if sig1.stmt_seq and sig2.stmt_seq:
|
|
115
|
+
if sig1.stmt_seq[0] != sig2.stmt_seq[0] or sig1.stmt_seq[-1] != sig2.stmt_seq[-1]:
|
|
116
|
+
return False
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def evaluate_signature(sig1: BlockSignature, sig2: BlockSignature) -> Tuple[bool, Dict[str, Any]]:
|
|
121
|
+
"""Minimal compatibility wrapper retained so refactor_engine telemetry calls
|
|
122
|
+
do not break. Returns (decision, empty_info)."""
|
|
123
|
+
return quick_filter(sig1, sig2), {}
|