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.
- {redup-0.1.2 → redup-0.1.4}/PKG-INFO +1 -1
- {redup-0.1.2 → redup-0.1.4}/pyproject.toml +1 -1
- {redup-0.1.2 → redup-0.1.4}/src/redup/__init__.py +1 -1
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/hasher.py +88 -58
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/matcher.py +44 -28
- redup-0.1.4/src/redup/core/pipeline.py +266 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/PKG-INFO +1 -1
- redup-0.1.2/src/redup/core/pipeline.py +0 -183
- {redup-0.1.2 → redup-0.1.4}/LICENSE +0 -0
- {redup-0.1.2 → redup-0.1.4}/README.md +0 -0
- {redup-0.1.2 → redup-0.1.4}/setup.cfg +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/__main__.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/cli_app/main.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/__init__.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/models.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/planner.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/core/scanner.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/__init__.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/SOURCES.txt +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/entry_points.txt +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/requires.txt +0 -0
- {redup-0.1.2 → redup-0.1.4}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_e2e.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_hasher.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_matcher.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_models.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_pipeline.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_planner.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_reporters.py +0 -0
- {redup-0.1.2 → redup-0.1.4}/tests/test_scanner.py +0 -0
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 =
|
|
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
|
|
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
|
|
43
|
+
def _compare_against_reference(
|
|
43
44
|
candidates: list[HashedBlock],
|
|
44
|
-
min_similarity: float
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,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
|
|
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
|