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.
@@ -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)