redup 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (35) hide show
  1. {redup-0.1.2 → redup-0.1.4}/PKG-INFO +1 -1
  2. {redup-0.1.2 → redup-0.1.4}/pyproject.toml +1 -1
  3. {redup-0.1.2 → redup-0.1.4}/src/redup/__init__.py +1 -1
  4. {redup-0.1.2 → redup-0.1.4}/src/redup/core/hasher.py +88 -58
  5. {redup-0.1.2 → redup-0.1.4}/src/redup/core/matcher.py +44 -28
  6. redup-0.1.4/src/redup/core/pipeline.py +266 -0
  7. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/PKG-INFO +1 -1
  8. redup-0.1.2/src/redup/core/pipeline.py +0 -183
  9. {redup-0.1.2 → redup-0.1.4}/LICENSE +0 -0
  10. {redup-0.1.2 → redup-0.1.4}/README.md +0 -0
  11. {redup-0.1.2 → redup-0.1.4}/setup.cfg +0 -0
  12. {redup-0.1.2 → redup-0.1.4}/src/redup/__main__.py +0 -0
  13. {redup-0.1.2 → redup-0.1.4}/src/redup/cli_app/__init__.py +0 -0
  14. {redup-0.1.2 → redup-0.1.4}/src/redup/cli_app/main.py +0 -0
  15. {redup-0.1.2 → redup-0.1.4}/src/redup/core/__init__.py +0 -0
  16. {redup-0.1.2 → redup-0.1.4}/src/redup/core/models.py +0 -0
  17. {redup-0.1.2 → redup-0.1.4}/src/redup/core/planner.py +0 -0
  18. {redup-0.1.2 → redup-0.1.4}/src/redup/core/scanner.py +0 -0
  19. {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/__init__.py +0 -0
  20. {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/json_reporter.py +0 -0
  21. {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/toon_reporter.py +0 -0
  22. {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/yaml_reporter.py +0 -0
  23. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/SOURCES.txt +0 -0
  24. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/dependency_links.txt +0 -0
  25. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/entry_points.txt +0 -0
  26. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/requires.txt +0 -0
  27. {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/top_level.txt +0 -0
  28. {redup-0.1.2 → redup-0.1.4}/tests/test_e2e.py +0 -0
  29. {redup-0.1.2 → redup-0.1.4}/tests/test_hasher.py +0 -0
  30. {redup-0.1.2 → redup-0.1.4}/tests/test_matcher.py +0 -0
  31. {redup-0.1.2 → redup-0.1.4}/tests/test_models.py +0 -0
  32. {redup-0.1.2 → redup-0.1.4}/tests/test_pipeline.py +0 -0
  33. {redup-0.1.2 → redup-0.1.4}/tests/test_planner.py +0 -0
  34. {redup-0.1.2 → redup-0.1.4}/tests/test_reporters.py +0 -0
  35. {redup-0.1.2 → redup-0.1.4}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Code duplication analyzer and refactoring planner for LLMs
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redup"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Code duplication analyzer and refactoring planner for LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.4"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
- import re
7
5
  from collections import defaultdict
6
+ from collections.abc import Callable
8
7
  from dataclasses import dataclass, field
8
+ import hashlib
9
+ import re
9
10
 
10
11
  from redup.core.scanner import CodeBlock
11
12
 
@@ -61,6 +62,60 @@ def _ast_to_normalized_string(tree: object) -> str:
61
62
  """
62
63
  import ast
63
64
 
65
+ name_map: dict[str, str] = {}
66
+ counter = [0]
67
+ parts: list[str] = []
68
+
69
+ for node in ast.walk(tree):
70
+ part = _process_ast_node(node, name_map, counter)
71
+ if part:
72
+ parts.append(part)
73
+
74
+ return " ".join(parts)
75
+
76
+
77
+ def _process_ast_node(
78
+ node,
79
+ name_map: dict[str, str],
80
+ counter: list[int]
81
+ ) -> str | None:
82
+ """Process a single AST node and return its normalized representation."""
83
+ import ast
84
+
85
+ if isinstance(node, ast.Name):
86
+ return _get_placeholder(node.id, name_map, counter)
87
+ elif isinstance(node, ast.arg):
88
+ return _get_placeholder(node.arg, name_map, counter)
89
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
90
+ return f"DEF({_get_placeholder(node.name, name_map, counter)})"
91
+ elif isinstance(node, ast.ClassDef):
92
+ return f"CLASS({_get_placeholder(node.name, name_map, counter)})"
93
+ elif isinstance(node, ast.Constant):
94
+ return _normalize_constant(node.value)
95
+ elif isinstance(node, ast.If):
96
+ return "IF"
97
+ elif isinstance(node, ast.For):
98
+ return "FOR"
99
+ elif isinstance(node, ast.While):
100
+ return "WHILE"
101
+ elif isinstance(node, ast.Return):
102
+ return "RETURN"
103
+ elif isinstance(node, ast.Compare):
104
+ ops = [type(op).__name__ for op in node.ops]
105
+ return f"CMP({','.join(ops)})"
106
+ elif isinstance(node, ast.BinOp):
107
+ return f"BINOP({type(node.op).__name__})"
108
+ elif isinstance(node, ast.Call):
109
+ return "CALL"
110
+ return None
111
+
112
+
113
+ def _get_placeholder(
114
+ name: str,
115
+ name_map: dict[str, str],
116
+ counter: list[int]
117
+ ) -> str:
118
+ """Get or create a placeholder for a name."""
64
119
  BUILTINS = {
65
120
  "print", "len", "range", "int", "float", "str", "list", "dict",
66
121
  "set", "tuple", "bool", "None", "True", "False", "type", "isinstance",
@@ -72,57 +127,28 @@ def _ast_to_normalized_string(tree: object) -> str:
72
127
  "class", "def", "pass", "break", "continue", "and", "or", "not",
73
128
  "in", "is", "lambda", "global", "nonlocal", "assert", "del",
74
129
  }
75
-
76
- name_map: dict[str, str] = {}
77
- counter = [0]
78
-
79
- def _get_placeholder(name: str) -> str:
80
- if name in BUILTINS:
81
- return name
82
- if name.startswith("__") and name.endswith("__"):
83
- return name
84
- if name not in name_map:
85
- name_map[name] = f"_V{counter[0]}"
86
- counter[0] += 1
87
- return name_map[name]
88
-
89
- parts: list[str] = []
90
- for node in ast.walk(tree):
91
- if isinstance(node, ast.Name):
92
- parts.append(_get_placeholder(node.id))
93
- elif isinstance(node, ast.arg):
94
- parts.append(_get_placeholder(node.arg))
95
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
96
- parts.append(f"DEF({_get_placeholder(node.name)})")
97
- elif isinstance(node, ast.ClassDef):
98
- parts.append(f"CLASS({_get_placeholder(node.name)})")
99
- elif isinstance(node, ast.Constant):
100
- if isinstance(node.value, str):
101
- parts.append("__STR__")
102
- elif isinstance(node.value, (int, float)):
103
- parts.append("__NUM__")
104
- else:
105
- parts.append(str(type(node.value).__name__))
106
- elif isinstance(node, ast.If):
107
- parts.append("IF")
108
- elif isinstance(node, ast.For):
109
- parts.append("FOR")
110
- elif isinstance(node, ast.While):
111
- parts.append("WHILE")
112
- elif isinstance(node, ast.Return):
113
- parts.append("RETURN")
114
- elif isinstance(node, ast.Compare):
115
- ops = [type(op).__name__ for op in node.ops]
116
- parts.append(f"CMP({','.join(ops)})")
117
- elif isinstance(node, ast.BinOp):
118
- parts.append(f"BINOP({type(node.op).__name__})")
119
- elif isinstance(node, ast.Call):
120
- parts.append("CALL")
121
-
122
- return " ".join(parts)
123
-
124
-
125
- def _hash_text(text: str, normalizer: callable) -> str:
130
+
131
+ if name in BUILTINS or (name.startswith("__") and name.endswith("__")):
132
+ return name
133
+
134
+ if name not in name_map:
135
+ name_map[name] = f"_V{counter[0]}"
136
+ counter[0] += 1
137
+
138
+ return name_map[name]
139
+
140
+
141
+ def _normalize_constant(value) -> str:
142
+ """Normalize constant values."""
143
+ if isinstance(value, str):
144
+ return "__STR__"
145
+ elif isinstance(value, (int, float)):
146
+ return "__NUM__"
147
+ else:
148
+ return str(type(value).__name__)
149
+
150
+
151
+ def _hash_text(text: str, normalizer: Callable[[str], str]) -> str:
126
152
  """Generic SHA-256 hash function with configurable normalizer."""
127
153
  normalized = normalizer(text)
128
154
  return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
@@ -138,6 +164,14 @@ def hash_block_structural(text: str) -> str:
138
164
  return _hash_text(text, _normalize_ast_text)
139
165
 
140
166
 
167
+ def _hashed_block(block: CodeBlock) -> HashedBlock:
168
+ return HashedBlock(
169
+ block=block,
170
+ exact_hash=hash_block(block.text),
171
+ structural_hash=hash_block_structural(block.text),
172
+ )
173
+
174
+
141
175
  @dataclass
142
176
  class HashedBlock:
143
177
  """A code block with its computed fingerprints."""
@@ -167,11 +201,7 @@ def build_hash_index(blocks: list[CodeBlock], min_lines: int = 3) -> HashIndex:
167
201
  if block.line_count < min_lines:
168
202
  continue
169
203
 
170
- hb = HashedBlock(
171
- block=block,
172
- exact_hash=hash_block(block.text),
173
- structural_hash=hash_block_structural(block.text),
174
- )
204
+ hb = _hashed_block(block)
175
205
 
176
206
  index.exact[hb.exact_hash].append(hb)
177
207
  index.structural[hb.structural_hash].append(hb)
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import difflib
5
+ from collections.abc import Callable
6
6
  from dataclasses import dataclass
7
+ import difflib
7
8
 
8
9
  from redup.core.hasher import HashedBlock, _normalize_text
9
10
 
@@ -39,14 +40,13 @@ def fuzzy_similarity(text_a: str, text_b: str) -> float:
39
40
  return sequence_similarity(text_a, text_b)
40
41
 
41
42
 
42
- def match_candidates(
43
+ def _compare_against_reference(
43
44
  candidates: list[HashedBlock],
44
- min_similarity: float = 0.85,
45
+ min_similarity: float,
46
+ similarity_fn: Callable[[str, str], float],
47
+ method_fn: Callable[[float], str],
48
+ skip_same_location: bool = False,
45
49
  ) -> list[MatchResult]:
46
- """Compare all pairs in a candidate group and return matches above threshold.
47
-
48
- Uses the first block as reference and compares all others against it.
49
- """
50
50
  if len(candidates) < 2:
51
51
  return []
52
52
 
@@ -54,16 +54,41 @@ def match_candidates(
54
54
  ref = candidates[0]
55
55
 
56
56
  for other in candidates[1:]:
57
- sim = fuzzy_similarity(ref.block.text, other.block.text)
57
+ if skip_same_location and (
58
+ ref.block.file == other.block.file and ref.block.line_start == other.block.line_start
59
+ ):
60
+ continue
61
+
62
+ sim = similarity_fn(ref.block.text, other.block.text)
58
63
  if sim >= min_similarity:
59
- method = "exact" if sim >= 0.999 else "fuzzy"
60
- results.append(MatchResult(
61
- block_a=ref, block_b=other, similarity=sim, method=method
62
- ))
64
+ results.append(
65
+ MatchResult(
66
+ block_a=ref,
67
+ block_b=other,
68
+ similarity=sim,
69
+ method=method_fn(sim),
70
+ )
71
+ )
63
72
 
64
73
  return results
65
74
 
66
75
 
76
+ def match_candidates(
77
+ candidates: list[HashedBlock],
78
+ min_similarity: float = 0.85,
79
+ ) -> list[MatchResult]:
80
+ """Compare all pairs in a candidate group and return matches above threshold.
81
+
82
+ Uses the first block as reference and compares all others against it.
83
+ """
84
+ return _compare_against_reference(
85
+ candidates,
86
+ min_similarity,
87
+ fuzzy_similarity,
88
+ lambda sim: "exact" if sim >= 0.999 else "fuzzy",
89
+ )
90
+
91
+
67
92
  def refine_structural_matches(
68
93
  candidates: list[HashedBlock],
69
94
  min_similarity: float = 0.80,
@@ -73,19 +98,10 @@ def refine_structural_matches(
73
98
  Structural hashes normalize variable names, so we use a lower threshold
74
99
  and verify the overall shape matches.
75
100
  """
76
- if len(candidates) < 2:
77
- return []
78
-
79
- results: list[MatchResult] = []
80
- ref = candidates[0]
81
-
82
- for other in candidates[1:]:
83
- if ref.block.file == other.block.file and ref.block.line_start == other.block.line_start:
84
- continue
85
- sim = sequence_similarity(ref.block.text, other.block.text)
86
- if sim >= min_similarity:
87
- results.append(MatchResult(
88
- block_a=ref, block_b=other, similarity=sim, method="structural"
89
- ))
90
-
91
- return results
101
+ return _compare_against_reference(
102
+ candidates,
103
+ min_similarity,
104
+ sequence_similarity,
105
+ lambda _sim: "structural",
106
+ skip_same_location=True,
107
+ )
@@ -0,0 +1,266 @@
1
+ """Pipeline — orchestrates scan → hash → match → group → plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from redup.core.hasher import (
6
+ HashIndex,
7
+ HashedBlock,
8
+ build_hash_index,
9
+ find_exact_duplicates,
10
+ find_structural_duplicates,
11
+ )
12
+ from redup.core.matcher import MatchResult, refine_structural_matches
13
+ from redup.core.models import (
14
+ DuplicateFragment,
15
+ DuplicateGroup,
16
+ DuplicateType,
17
+ DuplicationMap,
18
+ ScanConfig,
19
+ )
20
+ from redup.core.planner import generate_suggestions
21
+ from redup.core.scanner import CodeBlock, ScannedFile, ScanStats, scan_project
22
+
23
+
24
+ def _blocks_to_group(
25
+ group_id: str,
26
+ blocks: list[HashedBlock],
27
+ dup_type: DuplicateType,
28
+ similarity: float = 1.0,
29
+ normalized_hash: str = "",
30
+ ) -> DuplicateGroup:
31
+ """Convert a list of hashed blocks into a DuplicateGroup."""
32
+ # Deduplicate by file+line
33
+ seen: set[tuple[str, int]] = set()
34
+ fragments: list[DuplicateFragment] = []
35
+
36
+ for hb in blocks:
37
+ # Handle both HashedBlock and MatchResult objects
38
+ if hasattr(hb, 'block'):
39
+ # HashedBlock object
40
+ block = hb.block
41
+ elif hasattr(hb, 'block_a'):
42
+ # MatchResult object - use block_a
43
+ block = hb.block_a.block
44
+ else:
45
+ continue # Skip unknown object types
46
+
47
+ key = (block.file, block.line_start)
48
+ if key in seen:
49
+ continue
50
+ seen.add(key)
51
+ fragments.append(
52
+ DuplicateFragment(
53
+ file=block.file,
54
+ line_start=block.line_start,
55
+ line_end=block.line_end,
56
+ text=block.text,
57
+ function_name=block.function_name,
58
+ class_name=block.class_name,
59
+ )
60
+ )
61
+
62
+ if len(fragments) < 2:
63
+ return DuplicateGroup(id=group_id, duplicate_type=dup_type)
64
+
65
+ name = fragments[0].function_name if fragments[0].function_name else None
66
+
67
+ return DuplicateGroup(
68
+ id=group_id,
69
+ duplicate_type=dup_type,
70
+ fragments=fragments,
71
+ similarity_score=similarity,
72
+ normalized_hash=normalized_hash,
73
+ normalized_name=name,
74
+ )
75
+
76
+
77
+ def _deduplicate_groups(groups: list[DuplicateGroup]) -> list[DuplicateGroup]:
78
+ """Remove groups that are subsets of larger groups."""
79
+ if not groups:
80
+ return groups
81
+
82
+ # Sort by impact (largest first)
83
+ sorted_groups = sorted(groups, key=lambda g: g.impact_score, reverse=True)
84
+ kept: list[DuplicateGroup] = []
85
+ seen_locations: set[tuple[str, int, int]] = set()
86
+
87
+ for group in sorted_groups:
88
+ if group.occurrences < 2:
89
+ continue
90
+
91
+ # Check if this group's fragments are already covered
92
+ locations = {
93
+ (f.file, f.line_start, f.line_end)
94
+ for f in group.fragments
95
+ }
96
+ new_locations = locations - seen_locations
97
+
98
+ if len(new_locations) >= 2:
99
+ kept.append(group)
100
+ seen_locations.update(locations)
101
+
102
+ return kept
103
+
104
+
105
+ def analyze(
106
+ config: ScanConfig | None = None,
107
+ function_level_only: bool = False,
108
+ ) -> DuplicationMap:
109
+ """Run the full reDUP analysis pipeline.
110
+
111
+ Args:
112
+ config: Scan configuration. Defaults to current directory, .py files.
113
+ function_level_only: If True, only analyze function-level blocks
114
+ (skip sliding-window line blocks). Faster but misses inline duplicates.
115
+
116
+ Returns:
117
+ A DuplicationMap with all duplicate groups and refactoring suggestions.
118
+ """
119
+ config = _ensure_config(config)
120
+
121
+ # Phase 1: Scan
122
+ scanned_files, stats = _scan_phase(config)
123
+
124
+ # Phase 2: Process blocks
125
+ all_blocks = _process_blocks(scanned_files, function_level_only)
126
+
127
+ # Phase 3: Hash and find duplicates
128
+ groups = _find_duplicates_phase(all_blocks, config)
129
+
130
+ # Phase 4: Deduplicate and suggest
131
+ final_groups = _deduplicate_phase(groups)
132
+
133
+ dup_map = DuplicationMap(
134
+ project_path=config.root.as_posix(),
135
+ config=config,
136
+ stats=stats,
137
+ groups=final_groups,
138
+ )
139
+ dup_map.suggestions = generate_suggestions(dup_map)
140
+
141
+ return dup_map
142
+
143
+
144
+ def _ensure_config(config: ScanConfig | None) -> ScanConfig:
145
+ """Ensure we have a valid configuration."""
146
+ return config or ScanConfig()
147
+
148
+
149
+ def _scan_phase(config: ScanConfig) -> tuple[list[ScannedFile], ScanStats]:
150
+ """Phase 1: Scan project files."""
151
+ return scan_project(config)
152
+
153
+
154
+ def _process_blocks(
155
+ scanned_files: list[ScannedFile],
156
+ function_level_only: bool
157
+ ) -> list[CodeBlock]:
158
+ """Phase 2: Extract and filter code blocks."""
159
+ all_blocks: list[CodeBlock] = []
160
+ for sf in scanned_files:
161
+ for block in sf.blocks:
162
+ if function_level_only and block.function_name is None:
163
+ continue
164
+ all_blocks.append(block)
165
+ return all_blocks
166
+
167
+
168
+ def _find_duplicates_phase(
169
+ all_blocks: list[CodeBlock],
170
+ config: ScanConfig
171
+ ) -> list[DuplicateGroup]:
172
+ """Phase 3: Hash and find duplicate groups."""
173
+ index = build_hash_index(all_blocks, min_lines=config.min_block_lines)
174
+
175
+ # Find exact duplicates
176
+ exact_groups = _find_exact_groups(index)
177
+
178
+ # Find structural duplicates
179
+ structural_groups = _find_structural_groups(index, exact_groups)
180
+
181
+ return exact_groups + structural_groups
182
+
183
+
184
+ def _find_exact_groups(index: HashIndex) -> list[DuplicateGroup]:
185
+ """Find exact duplicate groups."""
186
+ groups = []
187
+ exact_groups = find_exact_duplicates(index)
188
+
189
+ for i, (h, blocks) in enumerate(exact_groups.items(), 1):
190
+ func_blocks = [b for b in blocks if b.block.function_name]
191
+ if len(func_blocks) >= 2:
192
+ g = _blocks_to_group(
193
+ group_id=f"E{i:04d}",
194
+ blocks=func_blocks,
195
+ dup_type=DuplicateType.EXACT,
196
+ normalized_hash=h,
197
+ )
198
+ if g.occurrences >= 2:
199
+ groups.append(g)
200
+
201
+ return groups
202
+
203
+
204
+ def _find_structural_groups(
205
+ index: HashIndex,
206
+ exact_groups_list: list[DuplicateGroup]
207
+ ) -> list[DuplicateGroup]:
208
+ """Find structural duplicate groups."""
209
+ groups = []
210
+ exact_hashes = set()
211
+ for group in exact_groups_list:
212
+ exact_hashes.add(group.normalized_hash)
213
+
214
+ structural_groups = find_structural_duplicates(index)
215
+
216
+ for i, (h, blocks) in enumerate(structural_groups.items(), 1):
217
+ if h in exact_hashes:
218
+ continue
219
+
220
+ func_blocks = [b for b in blocks if b.block.function_name]
221
+ if len(func_blocks) >= 2:
222
+ refined = refine_structural_matches(func_blocks)
223
+ refined_blocks = _match_results_to_blocks(refined)
224
+ if len(refined_blocks) >= 2:
225
+ g = _blocks_to_group(
226
+ group_id=f"S{i:04d}",
227
+ blocks=refined_blocks,
228
+ dup_type=DuplicateType.STRUCTURAL,
229
+ normalized_hash=h,
230
+ similarity=_calculate_similarity(refined),
231
+ )
232
+ if g.occurrences >= 2:
233
+ groups.append(g)
234
+
235
+ return groups
236
+
237
+
238
+ def _deduplicate_phase(groups: list[DuplicateGroup]) -> list[DuplicateGroup]:
239
+ """Phase 4: Remove overlapping groups."""
240
+ return _deduplicate_groups(groups)
241
+
242
+
243
+ def _match_results_to_blocks(matches: list[MatchResult]) -> list[HashedBlock]:
244
+ """Flatten a list of pairwise matches into unique hashed blocks."""
245
+ seen: set[tuple[str, int]] = set()
246
+ blocks: list[HashedBlock] = []
247
+
248
+ for match in matches:
249
+ for candidate in (match.block_a, match.block_b):
250
+ key = (candidate.block.file, candidate.block.line_start)
251
+ if key in seen:
252
+ continue
253
+ seen.add(key)
254
+ blocks.append(candidate)
255
+
256
+ return blocks
257
+
258
+
259
+ def _calculate_similarity(matches: list[MatchResult]) -> float:
260
+ """Calculate similarity score for a group of pairwise matches."""
261
+ if not matches:
262
+ return 1.0
263
+
264
+ return sum(match.similarity for match in matches) / len(matches)
265
+
266
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Code duplication analyzer and refactoring planner for LLMs
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -1,183 +0,0 @@
1
- """Pipeline — orchestrates scan → hash → match → group → plan."""
2
-
3
- from __future__ import annotations
4
-
5
- from redup.core.hasher import (
6
- HashedBlock,
7
- build_hash_index,
8
- find_exact_duplicates,
9
- find_structural_duplicates,
10
- )
11
- from redup.core.matcher import refine_structural_matches
12
- from redup.core.models import (
13
- DuplicateFragment,
14
- DuplicateGroup,
15
- DuplicateType,
16
- DuplicationMap,
17
- ScanConfig,
18
- )
19
- from redup.core.planner import generate_suggestions
20
- from redup.core.scanner import CodeBlock, scan_project
21
-
22
-
23
- def _blocks_to_group(
24
- group_id: str,
25
- blocks: list[HashedBlock],
26
- dup_type: DuplicateType,
27
- similarity: float = 1.0,
28
- normalized_hash: str = "",
29
- ) -> DuplicateGroup:
30
- """Convert a list of hashed blocks into a DuplicateGroup."""
31
- # Deduplicate by file+line
32
- seen: set[tuple[str, int]] = set()
33
- fragments: list[DuplicateFragment] = []
34
-
35
- for hb in blocks:
36
- key = (hb.block.file, hb.block.line_start)
37
- if key in seen:
38
- continue
39
- seen.add(key)
40
- fragments.append(
41
- DuplicateFragment(
42
- file=hb.block.file,
43
- line_start=hb.block.line_start,
44
- line_end=hb.block.line_end,
45
- text=hb.block.text,
46
- function_name=hb.block.function_name,
47
- class_name=hb.block.class_name,
48
- )
49
- )
50
-
51
- if len(fragments) < 2:
52
- return DuplicateGroup(id=group_id, duplicate_type=dup_type)
53
-
54
- name = fragments[0].function_name if fragments[0].function_name else None
55
-
56
- return DuplicateGroup(
57
- id=group_id,
58
- duplicate_type=dup_type,
59
- fragments=fragments,
60
- similarity_score=similarity,
61
- normalized_hash=normalized_hash,
62
- normalized_name=name,
63
- )
64
-
65
-
66
- def _deduplicate_groups(groups: list[DuplicateGroup]) -> list[DuplicateGroup]:
67
- """Remove groups that are subsets of larger groups."""
68
- if not groups:
69
- return groups
70
-
71
- # Sort by impact (largest first)
72
- sorted_groups = sorted(groups, key=lambda g: g.impact_score, reverse=True)
73
- kept: list[DuplicateGroup] = []
74
- seen_locations: set[tuple[str, int, int]] = set()
75
-
76
- for group in sorted_groups:
77
- if group.occurrences < 2:
78
- continue
79
-
80
- # Check if this group's fragments are already covered
81
- locations = {
82
- (f.file, f.line_start, f.line_end)
83
- for f in group.fragments
84
- }
85
- new_locations = locations - seen_locations
86
-
87
- if len(new_locations) >= 2:
88
- kept.append(group)
89
- seen_locations.update(locations)
90
-
91
- return kept
92
-
93
-
94
- def analyze(
95
- config: ScanConfig | None = None,
96
- function_level_only: bool = False,
97
- ) -> DuplicationMap:
98
- """Run the full reDUP analysis pipeline.
99
-
100
- Args:
101
- config: Scan configuration. Defaults to current directory, .py files.
102
- function_level_only: If True, only analyze function-level blocks
103
- (skip sliding-window line blocks). Faster but misses inline duplicates.
104
-
105
- Returns:
106
- A DuplicationMap with all duplicate groups and refactoring suggestions.
107
- """
108
- if config is None:
109
- config = ScanConfig()
110
-
111
- # Phase 1: Scan
112
- scanned_files, stats = scan_project(config)
113
-
114
- # Collect blocks
115
- all_blocks: list[CodeBlock] = []
116
- for sf in scanned_files:
117
- for block in sf.blocks:
118
- if function_level_only and block.function_name is None:
119
- continue
120
- all_blocks.append(block)
121
-
122
- # Phase 2: Hash
123
- index = build_hash_index(all_blocks, min_lines=config.min_block_lines)
124
-
125
- # Phase 3: Find candidates
126
- groups: list[DuplicateGroup] = []
127
- group_counter = 0
128
-
129
- # 3a: Exact duplicates
130
- exact_groups = find_exact_duplicates(index)
131
- for h, blocks in exact_groups.items():
132
- # Only keep function-level exact matches (skip noisy line blocks)
133
- func_blocks = [b for b in blocks if b.block.function_name]
134
- if len(func_blocks) >= 2:
135
- group_counter += 1
136
- g = _blocks_to_group(
137
- group_id=f"E{group_counter:04d}",
138
- blocks=func_blocks,
139
- dup_type=DuplicateType.EXACT,
140
- normalized_hash=h,
141
- )
142
- if g.occurrences >= 2:
143
- groups.append(g)
144
-
145
- # 3b: Structural duplicates (not already found as exact)
146
- exact_hashes = set(exact_groups.keys())
147
- structural_groups = find_structural_duplicates(index)
148
- for h, blocks in structural_groups.items():
149
- # Skip if all blocks are already in an exact group
150
- if all(b.exact_hash in exact_hashes for b in blocks):
151
- continue
152
-
153
- func_blocks = [b for b in blocks if b.block.function_name]
154
- if len(func_blocks) >= 2:
155
- matches = refine_structural_matches(func_blocks, config.min_similarity)
156
- if matches:
157
- group_counter += 1
158
- avg_sim = sum(m.similarity for m in matches) / len(matches)
159
- g = _blocks_to_group(
160
- group_id=f"S{group_counter:04d}",
161
- blocks=func_blocks,
162
- dup_type=DuplicateType.STRUCTURAL,
163
- similarity=avg_sim,
164
- normalized_hash=h,
165
- )
166
- if g.occurrences >= 2:
167
- groups.append(g)
168
-
169
- # Phase 4: Deduplicate overlapping groups
170
- groups = _deduplicate_groups(groups)
171
-
172
- # Phase 5: Build map and plan
173
- dup_map = DuplicationMap(
174
- project_path=str(config.root.resolve()),
175
- config=config,
176
- groups=groups,
177
- stats=stats,
178
- )
179
-
180
- # Phase 6: Generate suggestions
181
- dup_map.suggestions = generate_suggestions(dup_map)
182
-
183
- return dup_map
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes