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,187 @@
|
|
|
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
|
+
Detect orphaned variable references after code extraction.
|
|
17
|
+
|
|
18
|
+
An orphaned variable is one that is bound (assigned) in the extracted code
|
|
19
|
+
but referenced in code that remains after the extraction point.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from typing import List, Set, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _apply_visitor_to_nodes(
|
|
27
|
+
result_set: Set[str], visitor: ast.NodeVisitor, nodes: List[ast.AST]
|
|
28
|
+
) -> Set[str]:
|
|
29
|
+
"""
|
|
30
|
+
Apply an AST visitor to a sequence of nodes and return the collected results.
|
|
31
|
+
|
|
32
|
+
This helper function encapsulates the common pattern of visiting multiple AST nodes
|
|
33
|
+
with a NodeVisitor and collecting results in a set.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
result_set: The set where the visitor collects its results
|
|
37
|
+
visitor: The NodeVisitor instance to apply to each node
|
|
38
|
+
nodes: The AST nodes to visit
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The result_set after all nodes have been visited
|
|
42
|
+
|
|
43
|
+
Note:
|
|
44
|
+
This function was identified as a refactoring opportunity by Towel itself
|
|
45
|
+
during dog-fooding testing (October 2025). The common visitor pattern in
|
|
46
|
+
get_bound_variables() and get_used_variables() was successfully extracted,
|
|
47
|
+
validated with 100% test passage, and incorporated into the codebase.
|
|
48
|
+
"""
|
|
49
|
+
for node in nodes:
|
|
50
|
+
visitor.visit(node)
|
|
51
|
+
return result_set
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_bound_variables(nodes: List[ast.AST]) -> Set[str]:
|
|
55
|
+
"""
|
|
56
|
+
Get all variables bound (assigned) in a block of code.
|
|
57
|
+
|
|
58
|
+
This includes:
|
|
59
|
+
- Assignment targets (x = ...)
|
|
60
|
+
- For loop targets (for x in ...)
|
|
61
|
+
- Function/class definitions
|
|
62
|
+
- But NOT comprehension variables (they're local to the comprehension)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
class BindingCollector(ast.NodeVisitor):
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self.bindings: Set[str] = set()
|
|
68
|
+
self.in_comprehension: bool = False
|
|
69
|
+
|
|
70
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
71
|
+
for target in node.targets:
|
|
72
|
+
self._collect_names(target)
|
|
73
|
+
self.generic_visit(node)
|
|
74
|
+
|
|
75
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
76
|
+
if node.target:
|
|
77
|
+
self._collect_names(node.target)
|
|
78
|
+
self.generic_visit(node)
|
|
79
|
+
|
|
80
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
81
|
+
self._collect_names(node.target)
|
|
82
|
+
self.generic_visit(node)
|
|
83
|
+
|
|
84
|
+
def visit_For(self, node: ast.For) -> None:
|
|
85
|
+
self._collect_names(node.target)
|
|
86
|
+
self.generic_visit(node)
|
|
87
|
+
|
|
88
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
89
|
+
self.bindings.add(node.name)
|
|
90
|
+
# Don't visit inside nested functions
|
|
91
|
+
|
|
92
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
93
|
+
self.bindings.add(node.name)
|
|
94
|
+
# Don't visit inside nested functions
|
|
95
|
+
|
|
96
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
97
|
+
self.bindings.add(node.name)
|
|
98
|
+
# Don't visit inside nested classes
|
|
99
|
+
|
|
100
|
+
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
101
|
+
# Comprehension variables are local, don't collect them
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def _collect_names(self, node: ast.AST) -> None:
|
|
114
|
+
"""Collect all name nodes from a target."""
|
|
115
|
+
if isinstance(node, ast.Name):
|
|
116
|
+
self.bindings.add(node.id)
|
|
117
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
118
|
+
for elt in node.elts:
|
|
119
|
+
self._collect_names(elt)
|
|
120
|
+
elif isinstance(node, ast.Starred):
|
|
121
|
+
self._collect_names(node.value)
|
|
122
|
+
# Ignore subscripts and attributes (they don't create bindings)
|
|
123
|
+
|
|
124
|
+
collector = BindingCollector()
|
|
125
|
+
return _apply_visitor_to_nodes(collector.bindings, collector, nodes)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_used_variables(nodes: List[ast.AST]) -> Set[str]:
|
|
129
|
+
"""
|
|
130
|
+
Get all variables used (referenced) in a block of code.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
class UsageCollector(ast.NodeVisitor):
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
self.uses: Set[str] = set()
|
|
136
|
+
|
|
137
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
138
|
+
if isinstance(node.ctx, ast.Load):
|
|
139
|
+
self.uses.add(node.id)
|
|
140
|
+
self.generic_visit(node)
|
|
141
|
+
|
|
142
|
+
collector = UsageCollector()
|
|
143
|
+
return _apply_visitor_to_nodes(collector.uses, collector, nodes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def has_orphaned_variables(
|
|
147
|
+
function_body: List[ast.AST], extracted_block_range: Tuple[int, int]
|
|
148
|
+
) -> Tuple[bool, Set[str]]:
|
|
149
|
+
"""
|
|
150
|
+
Check if extracting a block would create orphaned variable references.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
function_body: All statements in the function
|
|
154
|
+
extracted_block_range: (start_index, end_index) of block to extract
|
|
155
|
+
These are 0-based indices into function_body
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
(has_orphans, orphaned_vars) where:
|
|
159
|
+
- has_orphans: True if there are orphaned variables
|
|
160
|
+
- orphaned_vars: Set of variable names that would be orphaned
|
|
161
|
+
"""
|
|
162
|
+
start_idx, end_idx = extracted_block_range
|
|
163
|
+
|
|
164
|
+
# Get the extracted block and remaining code
|
|
165
|
+
extracted_block = function_body[start_idx : end_idx + 1]
|
|
166
|
+
remaining_code = function_body[end_idx + 1 :]
|
|
167
|
+
|
|
168
|
+
if not remaining_code:
|
|
169
|
+
# Nothing after the extracted block, so no orphans possible
|
|
170
|
+
return False, set()
|
|
171
|
+
|
|
172
|
+
# Get variables bound in the extracted block
|
|
173
|
+
bound_in_extracted = get_bound_variables(extracted_block)
|
|
174
|
+
|
|
175
|
+
# Get variables used in the remaining code
|
|
176
|
+
used_in_remaining = get_used_variables(remaining_code)
|
|
177
|
+
|
|
178
|
+
# Get variables bound in the remaining code
|
|
179
|
+
bound_in_remaining = get_bound_variables(remaining_code)
|
|
180
|
+
|
|
181
|
+
# Orphaned variables are those that are:
|
|
182
|
+
# 1. Bound in the extracted block
|
|
183
|
+
# 2. Used in the remaining code
|
|
184
|
+
# 3. NOT bound in the remaining code (before use)
|
|
185
|
+
orphaned = bound_in_extracted & used_in_remaining - bound_in_remaining
|
|
186
|
+
|
|
187
|
+
return len(orphaned) > 0, orphaned
|
|
@@ -0,0 +1,524 @@
|
|
|
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
|
+
Compiler-style refactoring pipeline: sequential phases that transform ASTs and
|
|
17
|
+
carry auxiliary analysis artifacts forward.
|
|
18
|
+
|
|
19
|
+
Phases (in order):
|
|
20
|
+
1) parse_modules: str path -> ParsedModule (tree, source)
|
|
21
|
+
2) analyze_scopes: attach ScopeAnalyzer and root Scope to ParsedModule
|
|
22
|
+
3) collect_classes: build ClassInfo table per module
|
|
23
|
+
4) collect_functions: build FunctionArtifact list with enclosing class/function context
|
|
24
|
+
5) pair_blocks: enumerate CodeBlockPair candidates
|
|
25
|
+
6) unify_blocks: attempt unification and construct RefactoringProposal objects
|
|
26
|
+
7) filter_overlaps: remove overlapping/conflicting proposals
|
|
27
|
+
|
|
28
|
+
The top-level run_pipeline() wires these phases.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union, cast
|
|
34
|
+
import ast
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from .models import (
|
|
38
|
+
ParsedModule,
|
|
39
|
+
FunctionArtifact,
|
|
40
|
+
ClassInfo,
|
|
41
|
+
CodeBlockPair,
|
|
42
|
+
RefactoringProposal,
|
|
43
|
+
)
|
|
44
|
+
from .scope_analyzer import ScopeAnalyzer
|
|
45
|
+
from .visitors import FunctionCollector
|
|
46
|
+
from .ast_normalizer import normalize_assigns_to_augassigns, canonicalize_arithmetic
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
49
|
+
from .refactor_engine import UnificationRefactorEngine
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# The current engine already implements substantial logic. This module orchestrates
|
|
53
|
+
# a clean compiler-style sequence by delegating to the engine for heavy lifting while
|
|
54
|
+
# making explicit the inputs/outputs between phases. This keeps public API stable
|
|
55
|
+
# and allows future migration of inner logic into dedicated phase modules.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_modules(paths: Sequence[str]) -> List[ParsedModule]:
|
|
59
|
+
modules: List[ParsedModule] = []
|
|
60
|
+
for p in paths:
|
|
61
|
+
try:
|
|
62
|
+
src, tree = _read_and_normalize_module(p)
|
|
63
|
+
except Exception:
|
|
64
|
+
# Skip unreadable or syntactically invalid files
|
|
65
|
+
continue
|
|
66
|
+
modules.append(ParsedModule(file_path=p, source=src, tree=tree))
|
|
67
|
+
return modules
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def analyze_scopes(mods: Sequence[ParsedModule]) -> None:
|
|
71
|
+
for m in mods:
|
|
72
|
+
analyzer = cast("ScopeAnalyzer", cast(Any, ScopeAnalyzer)())
|
|
73
|
+
m.root_scope = analyzer.analyze(m.tree)
|
|
74
|
+
m.scope_analyzer = analyzer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def collect_classes(mods: Sequence[ParsedModule]) -> List[ClassInfo]:
|
|
78
|
+
infos: List[ClassInfo] = []
|
|
79
|
+
|
|
80
|
+
def resolve_base_name(expr: ast.expr) -> Optional[str]:
|
|
81
|
+
if isinstance(expr, ast.Name):
|
|
82
|
+
return expr.id
|
|
83
|
+
if isinstance(expr, ast.Attribute):
|
|
84
|
+
parts: List[str] = []
|
|
85
|
+
cur: ast.expr = expr
|
|
86
|
+
while isinstance(cur, ast.Attribute):
|
|
87
|
+
parts.append(cur.attr)
|
|
88
|
+
cur = cur.value
|
|
89
|
+
if isinstance(cur, ast.Name):
|
|
90
|
+
parts.append(cur.id)
|
|
91
|
+
return ".".join(reversed(parts))
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
class Collector(ast.NodeVisitor):
|
|
95
|
+
def __init__(self, file_path: str) -> None:
|
|
96
|
+
self.file_path = file_path
|
|
97
|
+
self.class_stack: List[str] = []
|
|
98
|
+
|
|
99
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802
|
|
100
|
+
qualname = ".".join(self.class_stack + [node.name]) if self.class_stack else node.name
|
|
101
|
+
bases: List[str] = []
|
|
102
|
+
for b in node.bases:
|
|
103
|
+
resolved = resolve_base_name(b)
|
|
104
|
+
if resolved:
|
|
105
|
+
bases.append(resolved)
|
|
106
|
+
infos.append(
|
|
107
|
+
ClassInfo(name=node.name, qualname=qualname, file_path=self.file_path, bases=bases)
|
|
108
|
+
)
|
|
109
|
+
self.class_stack.append(node.name)
|
|
110
|
+
self.generic_visit(node)
|
|
111
|
+
self.class_stack.pop()
|
|
112
|
+
|
|
113
|
+
for m in mods:
|
|
114
|
+
Collector(m.file_path).visit(m.tree)
|
|
115
|
+
return infos
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def collect_functions(mods: Sequence[ParsedModule]) -> List[FunctionArtifact]:
|
|
119
|
+
funcs: List[FunctionArtifact] = []
|
|
120
|
+
|
|
121
|
+
for mod in mods:
|
|
122
|
+
if mod.scope_analyzer is None or mod.root_scope is None:
|
|
123
|
+
raise RuntimeError("collect_functions requires analyze_scopes to run first")
|
|
124
|
+
|
|
125
|
+
analyzer = mod.scope_analyzer
|
|
126
|
+
root_scope = mod.root_scope
|
|
127
|
+
|
|
128
|
+
def sink(
|
|
129
|
+
node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
|
|
130
|
+
class_name: Optional[str],
|
|
131
|
+
enclosing_function: Optional[str],
|
|
132
|
+
ancestry: List[str],
|
|
133
|
+
) -> None:
|
|
134
|
+
funcs.append(
|
|
135
|
+
FunctionArtifact(
|
|
136
|
+
file_path=mod.file_path,
|
|
137
|
+
node=node,
|
|
138
|
+
source=mod.source,
|
|
139
|
+
scope_analyzer=analyzer,
|
|
140
|
+
root_scope=root_scope,
|
|
141
|
+
class_name=class_name,
|
|
142
|
+
enclosing_function=enclosing_function,
|
|
143
|
+
ancestry=ancestry,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
FunctionCollector(sink).visit(mod.tree)
|
|
148
|
+
return funcs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def pair_blocks(
|
|
152
|
+
engine: "UnificationRefactorEngine",
|
|
153
|
+
funcs: Sequence[FunctionArtifact],
|
|
154
|
+
*,
|
|
155
|
+
progress: str = "none",
|
|
156
|
+
) -> List[CodeBlockPair]:
|
|
157
|
+
"""Enumerate candidate block pairs with optional progress display.
|
|
158
|
+
|
|
159
|
+
Shows early progress because pair enumeration can be the longest pre-unification step.
|
|
160
|
+
We only enumerate function pairs here (O(n^2)) and defer actual block pairing to the
|
|
161
|
+
engine to avoid logic duplication. This still provides a responsive bar so users see
|
|
162
|
+
movement before proposals appear.
|
|
163
|
+
"""
|
|
164
|
+
packed = [
|
|
165
|
+
(
|
|
166
|
+
f.file_path,
|
|
167
|
+
f.node,
|
|
168
|
+
f.source,
|
|
169
|
+
f.scope_analyzer,
|
|
170
|
+
f.root_scope,
|
|
171
|
+
f.class_name,
|
|
172
|
+
f.enclosing_function,
|
|
173
|
+
f.ancestry,
|
|
174
|
+
)
|
|
175
|
+
for f in funcs
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
# Delegate to engine when progress disabled for minimal overhead
|
|
179
|
+
if progress not in {"tqdm", "auto"}:
|
|
180
|
+
return engine._find_block_pairs_multi_file(packed)
|
|
181
|
+
|
|
182
|
+
total_funcs = len(packed)
|
|
183
|
+
total_func_pairs = (total_funcs * (total_funcs - 1)) // 2 if total_funcs > 1 else 0
|
|
184
|
+
use_tqdm = False
|
|
185
|
+
tqdm_bar = None
|
|
186
|
+
if progress in {"tqdm", "auto"} and total_func_pairs > 0:
|
|
187
|
+
tqdm_cls = _get_tqdm_class()
|
|
188
|
+
if tqdm_cls is not None:
|
|
189
|
+
try:
|
|
190
|
+
tqdm_bar = tqdm_cls(
|
|
191
|
+
total=total_func_pairs,
|
|
192
|
+
desc="pairing",
|
|
193
|
+
unit="func-pair",
|
|
194
|
+
dynamic_ncols=True,
|
|
195
|
+
leave=False,
|
|
196
|
+
)
|
|
197
|
+
use_tqdm = True
|
|
198
|
+
except Exception:
|
|
199
|
+
tqdm_bar = None
|
|
200
|
+
|
|
201
|
+
# Inline fallback bar -------------------------------------------------
|
|
202
|
+
use_inline = (not use_tqdm) and progress in {"tqdm", "auto"} and total_func_pairs > 0
|
|
203
|
+
last_pct = -1
|
|
204
|
+
if use_inline:
|
|
205
|
+
print("Pairing function pairs:", end=" ", flush=True)
|
|
206
|
+
|
|
207
|
+
# Instrumented pairing: we use a light wrapper around the engine's implementation
|
|
208
|
+
# to stream progress metrics. We avoid deep changes inside the engine for stability.
|
|
209
|
+
func_pairs_examined = 0
|
|
210
|
+
for i in range(total_funcs):
|
|
211
|
+
file1, func1, source1, analyzer1, scope1, class1, encl1, anc1 = packed[i]
|
|
212
|
+
for j in range(i + 1, total_funcs):
|
|
213
|
+
_ = packed[j]
|
|
214
|
+
# We do not attempt to replicate pairing work here; this loop is only for progress.
|
|
215
|
+
func_pairs_examined += 1
|
|
216
|
+
if use_tqdm and tqdm_bar is not None:
|
|
217
|
+
try:
|
|
218
|
+
tqdm_bar.update(1)
|
|
219
|
+
# Keep postfix compact to avoid wrapping
|
|
220
|
+
if func_pairs_examined % 50 == 0 or func_pairs_examined == total_func_pairs:
|
|
221
|
+
tqdm_bar.set_postfix(
|
|
222
|
+
{"fp": f"{func_pairs_examined}/{total_func_pairs}"}, refresh=True
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
elif use_inline:
|
|
227
|
+
pct = int(100 * func_pairs_examined / max(total_func_pairs, 1))
|
|
228
|
+
if pct != last_pct:
|
|
229
|
+
last_pct = pct
|
|
230
|
+
bar = _render_inline_bar(pct)
|
|
231
|
+
print(
|
|
232
|
+
f"\rPairing function pairs: [{bar}] {pct:3d}% | scanned={func_pairs_examined}/{total_func_pairs}",
|
|
233
|
+
end="",
|
|
234
|
+
flush=True,
|
|
235
|
+
)
|
|
236
|
+
# We intentionally do not attempt partial block pairing here to avoid duplicating logic.
|
|
237
|
+
# Full construction performed once at end via engine call.
|
|
238
|
+
# Finish inline bar line
|
|
239
|
+
if use_inline:
|
|
240
|
+
print()
|
|
241
|
+
if use_tqdm and tqdm_bar is not None:
|
|
242
|
+
_close_progress_bar(tqdm_bar)
|
|
243
|
+
|
|
244
|
+
# Now perform actual pairing using engine logic (single call for correctness)
|
|
245
|
+
# Defer to engine pairing (now instrumented internally for progress)
|
|
246
|
+
return engine._find_block_pairs_multi_file(packed, progress=progress)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def unify_blocks(
|
|
250
|
+
engine: "UnificationRefactorEngine",
|
|
251
|
+
pairs: Sequence[CodeBlockPair],
|
|
252
|
+
funcs: Sequence[FunctionArtifact],
|
|
253
|
+
classes: Sequence[ClassInfo],
|
|
254
|
+
*,
|
|
255
|
+
verbose: bool = False,
|
|
256
|
+
progress: str = "auto",
|
|
257
|
+
) -> List[RefactoringProposal]:
|
|
258
|
+
packed = [
|
|
259
|
+
(
|
|
260
|
+
f.file_path,
|
|
261
|
+
f.node,
|
|
262
|
+
f.source,
|
|
263
|
+
f.scope_analyzer,
|
|
264
|
+
f.root_scope,
|
|
265
|
+
f.class_name,
|
|
266
|
+
f.enclosing_function,
|
|
267
|
+
f.ancestry,
|
|
268
|
+
)
|
|
269
|
+
for f in funcs
|
|
270
|
+
]
|
|
271
|
+
return engine._process_block_pairs(
|
|
272
|
+
list(pairs), packed, list(classes), verbose=verbose, progress=progress
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def filter_overlaps(proposals: List[RefactoringProposal]) -> List[RefactoringProposal]:
|
|
277
|
+
# Delegate to existing implementation for stability
|
|
278
|
+
from .refactor_engine import filter_overlapping_proposals as _filter
|
|
279
|
+
|
|
280
|
+
return _filter(proposals)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Simple analysis cache keyed by file path
|
|
284
|
+
_analysis_cache: Dict[str, Dict[str, Any]] = {}
|
|
285
|
+
_ANALYSIS_CACHE_VERSION = 2
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _read_and_normalize_module(path: str) -> Tuple[str, ast.AST]:
|
|
289
|
+
"""Read a module from disk and apply canonical AST normalizations."""
|
|
290
|
+
src = Path(path).read_text(encoding="utf-8")
|
|
291
|
+
tree: ast.AST = ast.parse(src)
|
|
292
|
+
tree = normalize_assigns_to_augassigns(tree)
|
|
293
|
+
tree = canonicalize_arithmetic(tree)
|
|
294
|
+
return src, tree
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _get_tqdm_class():
|
|
298
|
+
"""Dynamically import tqdm.auto.tqdm if available."""
|
|
299
|
+
try:
|
|
300
|
+
import importlib
|
|
301
|
+
|
|
302
|
+
tqdm_mod = importlib.import_module("tqdm.auto")
|
|
303
|
+
return getattr(tqdm_mod, "tqdm")
|
|
304
|
+
except Exception:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _create_progress_bar(use_progress: bool, total: int, desc: str, unit: str):
|
|
309
|
+
"""Return a tqdm-style bar if available (see docs/DRY_RUN_2025-11-28.md)."""
|
|
310
|
+
if not use_progress or total <= 0:
|
|
311
|
+
return None
|
|
312
|
+
tqdm_cls = _get_tqdm_class()
|
|
313
|
+
if tqdm_cls is None:
|
|
314
|
+
return None
|
|
315
|
+
try:
|
|
316
|
+
return tqdm_cls(total=total, desc=desc, unit=unit, dynamic_ncols=True, leave=False)
|
|
317
|
+
except Exception:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _render_inline_bar(pct: int, bar_len: int = 24) -> str:
|
|
322
|
+
pct = max(0, min(100, pct))
|
|
323
|
+
filled = (pct * bar_len) // 100
|
|
324
|
+
return "#" * filled + "-" * (bar_len - filled)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _close_progress_bar(bar) -> None:
|
|
328
|
+
if bar is None:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
bar.close()
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run_pipeline(
|
|
337
|
+
paths: Sequence[str],
|
|
338
|
+
*,
|
|
339
|
+
engine: Optional["UnificationRefactorEngine"] = None,
|
|
340
|
+
verbose: bool = False,
|
|
341
|
+
progress: str = "auto",
|
|
342
|
+
invalidate_paths: Optional[Sequence[str]] = None,
|
|
343
|
+
) -> List[RefactoringProposal]:
|
|
344
|
+
"""
|
|
345
|
+
Run the full compiler-style pipeline for the given file paths, using cached analysis results.
|
|
346
|
+
If invalidate_paths is provided, only those files are reparsed/reanalyzed; others use cached results.
|
|
347
|
+
"""
|
|
348
|
+
# Lazy import to avoid circular import at module load time
|
|
349
|
+
if engine is None:
|
|
350
|
+
from .refactor_engine import UnificationRefactorEngine as _Engine # local import
|
|
351
|
+
|
|
352
|
+
eng: "UnificationRefactorEngine" = _Engine()
|
|
353
|
+
else:
|
|
354
|
+
eng = engine
|
|
355
|
+
|
|
356
|
+
# Invalidate cache for changed files
|
|
357
|
+
if invalidate_paths:
|
|
358
|
+
for p in invalidate_paths:
|
|
359
|
+
_analysis_cache.pop(p, None)
|
|
360
|
+
|
|
361
|
+
use_progress = progress in {"tqdm", "auto"}
|
|
362
|
+
# --------------------- Phase 1: parse modules ---------------------
|
|
363
|
+
mods = []
|
|
364
|
+
parse_bar = _create_progress_bar(use_progress, len(paths), "parse", "file")
|
|
365
|
+
|
|
366
|
+
last_pct = -1
|
|
367
|
+
inline_parse = use_progress and parse_bar is None and len(paths) > 0
|
|
368
|
+
if inline_parse:
|
|
369
|
+
print("Parsing files:", end=" ", flush=True)
|
|
370
|
+
for idx, p in enumerate(paths, 1):
|
|
371
|
+
cache_entry = _analysis_cache.get(p)
|
|
372
|
+
if cache_entry and cache_entry.get("version") == _ANALYSIS_CACHE_VERSION:
|
|
373
|
+
mods.append(cache_entry["mod"])
|
|
374
|
+
else:
|
|
375
|
+
try:
|
|
376
|
+
src, tree = _read_and_normalize_module(p)
|
|
377
|
+
mod = ParsedModule(file_path=p, source=src, tree=tree)
|
|
378
|
+
_analysis_cache[p] = {"mod": mod, "version": _ANALYSIS_CACHE_VERSION}
|
|
379
|
+
mods.append(mod)
|
|
380
|
+
except Exception:
|
|
381
|
+
continue
|
|
382
|
+
if parse_bar is not None:
|
|
383
|
+
try:
|
|
384
|
+
parse_bar.update(1)
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
elif inline_parse:
|
|
388
|
+
pct = int(100 * idx / len(paths))
|
|
389
|
+
if pct != last_pct:
|
|
390
|
+
last_pct = pct
|
|
391
|
+
bar = _render_inline_bar(pct)
|
|
392
|
+
print(f"\rParsing files: [{bar}] {pct:3d}%", end="", flush=True)
|
|
393
|
+
_close_progress_bar(parse_bar)
|
|
394
|
+
if inline_parse:
|
|
395
|
+
print()
|
|
396
|
+
|
|
397
|
+
# --------------------- Phase 2: scope analysis ---------------------
|
|
398
|
+
scope_bar = _create_progress_bar(use_progress, len(mods), "scope", "mod")
|
|
399
|
+
inline_scope = use_progress and scope_bar is None and len(mods) > 0
|
|
400
|
+
last_pct = -1
|
|
401
|
+
if inline_scope:
|
|
402
|
+
print("Analyzing scopes:", end=" ", flush=True)
|
|
403
|
+
for idx, m in enumerate(mods, 1):
|
|
404
|
+
if "scoped" not in _analysis_cache[m.file_path]:
|
|
405
|
+
analyzer = cast("ScopeAnalyzer", cast(Any, ScopeAnalyzer)())
|
|
406
|
+
m.root_scope = analyzer.analyze(m.tree)
|
|
407
|
+
m.scope_analyzer = analyzer
|
|
408
|
+
_analysis_cache[m.file_path]["scoped"] = True
|
|
409
|
+
else:
|
|
410
|
+
# Reattach analyzer/root_scope from cache for downstream phases
|
|
411
|
+
cached_mod = _analysis_cache[m.file_path]["mod"]
|
|
412
|
+
m.root_scope = cached_mod.root_scope
|
|
413
|
+
m.scope_analyzer = cached_mod.scope_analyzer
|
|
414
|
+
if scope_bar is not None:
|
|
415
|
+
try:
|
|
416
|
+
scope_bar.update(1)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
elif inline_scope:
|
|
420
|
+
pct = int(100 * idx / len(mods))
|
|
421
|
+
if pct != last_pct:
|
|
422
|
+
last_pct = pct
|
|
423
|
+
bar = _render_inline_bar(pct)
|
|
424
|
+
print(f"\rAnalyzing scopes: [{bar}] {pct:3d}%", end="", flush=True)
|
|
425
|
+
if scope_bar is not None:
|
|
426
|
+
try:
|
|
427
|
+
scope_bar.close()
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
if inline_scope:
|
|
431
|
+
print()
|
|
432
|
+
|
|
433
|
+
# --------------------- Phase 3: class collection ---------------------
|
|
434
|
+
class_bar = _create_progress_bar(use_progress, len(mods), "class", "mod")
|
|
435
|
+
inline_class = use_progress and class_bar is None and len(mods) > 0
|
|
436
|
+
last_pct = -1
|
|
437
|
+
if inline_class:
|
|
438
|
+
print("Collecting classes:", end=" ", flush=True)
|
|
439
|
+
for idx, m in enumerate(mods, 1):
|
|
440
|
+
if "classes" not in _analysis_cache[m.file_path]:
|
|
441
|
+
infos = collect_classes([m])
|
|
442
|
+
m.class_infos = infos
|
|
443
|
+
_analysis_cache[m.file_path]["classes"] = infos
|
|
444
|
+
else:
|
|
445
|
+
m.class_infos = _analysis_cache[m.file_path]["classes"]
|
|
446
|
+
if class_bar is not None:
|
|
447
|
+
try:
|
|
448
|
+
class_bar.update(1)
|
|
449
|
+
except Exception:
|
|
450
|
+
pass
|
|
451
|
+
elif inline_class:
|
|
452
|
+
pct = int(100 * idx / len(mods))
|
|
453
|
+
if pct != last_pct:
|
|
454
|
+
last_pct = pct
|
|
455
|
+
bar = _render_inline_bar(pct)
|
|
456
|
+
print(f"\rCollecting classes: [{bar}] {pct:3d}%", end="", flush=True)
|
|
457
|
+
if class_bar is not None:
|
|
458
|
+
try:
|
|
459
|
+
class_bar.close()
|
|
460
|
+
except Exception:
|
|
461
|
+
pass
|
|
462
|
+
if inline_class:
|
|
463
|
+
print()
|
|
464
|
+
|
|
465
|
+
classes: List[ClassInfo] = []
|
|
466
|
+
for m in mods:
|
|
467
|
+
classes.extend(m.class_infos or [])
|
|
468
|
+
|
|
469
|
+
# --------------------- Phase 4: function collection ---------------------
|
|
470
|
+
func_bar = _create_progress_bar(use_progress, len(mods), "func", "mod")
|
|
471
|
+
inline_func = use_progress and func_bar is None and len(mods) > 0
|
|
472
|
+
last_pct = -1
|
|
473
|
+
if inline_func:
|
|
474
|
+
print("Collecting functions:", end=" ", flush=True)
|
|
475
|
+
for idx, m in enumerate(mods, 1):
|
|
476
|
+
if "funcs" not in _analysis_cache[m.file_path]:
|
|
477
|
+
funcs_list = collect_functions([m])
|
|
478
|
+
_analysis_cache[m.file_path]["funcs"] = funcs_list
|
|
479
|
+
else:
|
|
480
|
+
funcs_list = _analysis_cache[m.file_path]["funcs"]
|
|
481
|
+
if func_bar is not None:
|
|
482
|
+
try:
|
|
483
|
+
func_bar.update(1)
|
|
484
|
+
if idx == len(mods):
|
|
485
|
+
func_bar.set_postfix(
|
|
486
|
+
{
|
|
487
|
+
"total": sum(
|
|
488
|
+
len(_analysis_cache[x]["funcs"])
|
|
489
|
+
for x in _analysis_cache
|
|
490
|
+
if "funcs" in _analysis_cache[x]
|
|
491
|
+
)
|
|
492
|
+
},
|
|
493
|
+
refresh=True,
|
|
494
|
+
)
|
|
495
|
+
except Exception:
|
|
496
|
+
pass
|
|
497
|
+
elif inline_func:
|
|
498
|
+
pct = int(100 * idx / len(mods))
|
|
499
|
+
if pct != last_pct:
|
|
500
|
+
last_pct = pct
|
|
501
|
+
bar_len = 24
|
|
502
|
+
filled = (pct * bar_len) // 100
|
|
503
|
+
bar = "#" * filled + "-" * (bar_len - filled)
|
|
504
|
+
print(f"\rCollecting functions: [{bar}] {pct:3d}%", end="", flush=True)
|
|
505
|
+
if func_bar is not None:
|
|
506
|
+
try:
|
|
507
|
+
func_bar.close()
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
if inline_func:
|
|
511
|
+
print()
|
|
512
|
+
|
|
513
|
+
funcs: List[FunctionArtifact] = []
|
|
514
|
+
for m in mods:
|
|
515
|
+
funcs.extend(_analysis_cache[m.file_path]["funcs"])
|
|
516
|
+
|
|
517
|
+
# Phase 5: candidate pairing (not cached; depends on all funcs)
|
|
518
|
+
pairs = pair_blocks(eng, funcs, progress=progress)
|
|
519
|
+
|
|
520
|
+
# Phase 6: unify to proposals (not cached; depends on all pairs/classes)
|
|
521
|
+
props = unify_blocks(eng, pairs, funcs, classes, verbose=verbose, progress=progress)
|
|
522
|
+
|
|
523
|
+
# Phase 7: de-overlap
|
|
524
|
+
return filter_overlaps(props)
|