redup 0.4.1__tar.gz → 0.4.3__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.4.1 → redup-0.4.3}/PKG-INFO +2 -2
- {redup-0.4.1 → redup-0.4.3}/README.md +1 -1
- {redup-0.4.1 → redup-0.4.3}/pyproject.toml +2 -1
- {redup-0.4.1 → redup-0.4.3}/src/redup/__init__.py +1 -1
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/scanner.py +16 -4
- redup-0.4.3/src/redup/core/utils/diff_helpers.py +162 -0
- redup-0.4.3/src/redup/mcp_server.py +675 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/PKG-INFO +2 -2
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/SOURCES.txt +3 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/entry_points.txt +1 -0
- redup-0.4.3/tests/test_mcp_server.py +199 -0
- {redup-0.4.1 → redup-0.4.3}/LICENSE +0 -0
- {redup-0.4.1 → redup-0.4.3}/setup.cfg +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/__main__.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/config_builder.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/fuzzy_similarity.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/main.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/output_writer.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/scan_commands.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/scan_helpers.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/__init__.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/cache.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/config.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/differ.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/fuzzy_similarity.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/hash_cache.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/hasher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/lazy_grouper.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/lsh_matcher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/matcher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/models.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/pipeline.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/planner.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/python_parser.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/semantic.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/ts_extractor.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/universal_fuzzy.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/__init__.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/duplicate_finders.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/function_extractor.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/hash_utils.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/language_dispatcher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/__init__.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/code2llm_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/enhanced_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/markdown_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/requires.txt +0 -0
- {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_e2e.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_hasher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_matcher.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_models.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_pipeline.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_planner.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_reporters.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_scanner.py +0 -0
- {redup-0.4.1 → redup-0.4.3}/tests/test_ts_extractor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redup
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
|
@@ -92,7 +92,7 @@ Dynamic: license-file
|
|
|
92
92
|
[](https://pypi.org/project/redup/)
|
|
93
93
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
94
94
|
[](https://python.org)
|
|
95
|
-
[](https://pypi.org/project/redup/)
|
|
96
96
|
|
|
97
97
|
reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
|
|
98
98
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://pypi.org/project/redup/)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://python.org)
|
|
8
|
-
[](https://pypi.org/project/redup/)
|
|
9
9
|
|
|
10
10
|
reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
|
|
11
11
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "redup"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.3"
|
|
8
8
|
description = "Code duplication analyzer and refactoring planner for LLMs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -90,6 +90,7 @@ dev = [
|
|
|
90
90
|
|
|
91
91
|
[project.scripts]
|
|
92
92
|
redup = "redup.cli_app.main:app"
|
|
93
|
+
redup-mcp = "redup.mcp_server:run_server"
|
|
93
94
|
|
|
94
95
|
[project.urls]
|
|
95
96
|
Homepage = "https://github.com/semcod/redup"
|
|
@@ -140,22 +140,32 @@ def _should_exclude(path: Path, patterns: tuple[str, ...]) -> bool:
|
|
|
140
140
|
return False # Not excluded by any pattern
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _project_relative_path(file_path: Path, project_root: Path) -> Path:
|
|
144
|
+
"""Return a path relative to the project root when possible."""
|
|
145
|
+
try:
|
|
146
|
+
return file_path.relative_to(project_root)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return file_path
|
|
149
|
+
|
|
150
|
+
|
|
143
151
|
def _collect_files(config: ScanConfig) -> list[Path]:
|
|
144
152
|
"""Collect all files to scan based on configuration."""
|
|
145
153
|
files = []
|
|
146
154
|
|
|
147
155
|
for file_path in config.root.rglob("*"):
|
|
148
156
|
if file_path.is_file():
|
|
157
|
+
relative_path = _project_relative_path(file_path, config.root)
|
|
158
|
+
|
|
149
159
|
# Check extension
|
|
150
160
|
if file_path.suffix not in config.extensions:
|
|
151
161
|
continue
|
|
152
162
|
|
|
153
163
|
# Check exclusions
|
|
154
|
-
if _should_exclude(
|
|
164
|
+
if _should_exclude(relative_path, tuple(config.exclude_patterns)):
|
|
155
165
|
continue
|
|
156
166
|
|
|
157
167
|
# Check if test file
|
|
158
|
-
if not config.include_tests and _is_test_file(
|
|
168
|
+
if not config.include_tests and _is_test_file(relative_path):
|
|
159
169
|
continue
|
|
160
170
|
|
|
161
171
|
# Check file size
|
|
@@ -458,6 +468,8 @@ def _should_process_file(
|
|
|
458
468
|
max_file_size: int
|
|
459
469
|
) -> bool:
|
|
460
470
|
"""Check if file should be processed in parallel scan."""
|
|
471
|
+
relative_path = _project_relative_path(file_path, Path(project_root))
|
|
472
|
+
|
|
461
473
|
# Check extension
|
|
462
474
|
if file_path.suffix not in extensions:
|
|
463
475
|
return False
|
|
@@ -467,11 +479,11 @@ def _should_process_file(
|
|
|
467
479
|
"__pycache__", ".git", ".venv", "venv", "node_modules",
|
|
468
480
|
".tox", ".mypy_cache", ".pytest_cache", "*.egg-info", "dist", "build",
|
|
469
481
|
]
|
|
470
|
-
if _should_exclude(
|
|
482
|
+
if _should_exclude(relative_path, tuple(default_patterns)):
|
|
471
483
|
return False
|
|
472
484
|
|
|
473
485
|
# Check if test file
|
|
474
|
-
if not include_tests and _is_test_file(
|
|
486
|
+
if not include_tests and _is_test_file(relative_path):
|
|
475
487
|
return False
|
|
476
488
|
|
|
477
489
|
# Check file size (convert KB to bytes)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Helpers for comparing reDUP scan results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from redup.core.models import DuplicateGroup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _group_files(group: DuplicateGroup) -> set[str]:
|
|
12
|
+
return {fragment.file for fragment in group.fragments}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _groups_match(group1: DuplicateGroup, group2: DuplicateGroup) -> bool:
|
|
16
|
+
"""Check whether two duplicate groups likely represent the same finding."""
|
|
17
|
+
if group1.duplicate_type != group2.duplicate_type:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if abs(group1.similarity_score - group2.similarity_score) > 0.1:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
files1 = _group_files(group1)
|
|
24
|
+
files2 = _group_files(group2)
|
|
25
|
+
if not files1 or not files2:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
overlap = len(files1.intersection(files2))
|
|
29
|
+
union = len(files1.union(files2))
|
|
30
|
+
if union == 0:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
return overlap / union >= 0.5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class _MatchResult:
|
|
38
|
+
before: DuplicateGroup
|
|
39
|
+
after: DuplicateGroup
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GroupMatcher:
|
|
43
|
+
"""Match duplicate groups between two scan results."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, before_groups: dict[str, DuplicateGroup], after_groups: dict[str, DuplicateGroup]) -> None:
|
|
46
|
+
self.before_groups = before_groups
|
|
47
|
+
self.after_groups = after_groups
|
|
48
|
+
self._matches: list[_MatchResult] | None = None
|
|
49
|
+
|
|
50
|
+
def _ensure_matches(self) -> None:
|
|
51
|
+
if self._matches is not None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
matches: list[_MatchResult] = []
|
|
55
|
+
matched_before: set[str] = set()
|
|
56
|
+
matched_after: set[str] = set()
|
|
57
|
+
|
|
58
|
+
# First, prefer exact id matches when the groups are otherwise compatible.
|
|
59
|
+
for group_id, before_group in self.before_groups.items():
|
|
60
|
+
after_group = self.after_groups.get(group_id)
|
|
61
|
+
if after_group is None:
|
|
62
|
+
continue
|
|
63
|
+
if _groups_match(before_group, after_group):
|
|
64
|
+
matches.append(_MatchResult(before=before_group, after=after_group))
|
|
65
|
+
matched_before.add(group_id)
|
|
66
|
+
matched_after.add(group_id)
|
|
67
|
+
|
|
68
|
+
# Then, match remaining groups by structural similarity.
|
|
69
|
+
remaining_before = [
|
|
70
|
+
group
|
|
71
|
+
for group_id, group in self.before_groups.items()
|
|
72
|
+
if group_id not in matched_before
|
|
73
|
+
]
|
|
74
|
+
remaining_after = [
|
|
75
|
+
group
|
|
76
|
+
for group_id, group in self.after_groups.items()
|
|
77
|
+
if group_id not in matched_after
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
remaining_before.sort(key=lambda group: group.impact_score, reverse=True)
|
|
81
|
+
remaining_after.sort(key=lambda group: group.impact_score, reverse=True)
|
|
82
|
+
|
|
83
|
+
for before_group in remaining_before:
|
|
84
|
+
best_index: int | None = None
|
|
85
|
+
best_score = -1.0
|
|
86
|
+
|
|
87
|
+
for index, after_group in enumerate(remaining_after):
|
|
88
|
+
if after_group.id in matched_after:
|
|
89
|
+
continue
|
|
90
|
+
if not _groups_match(before_group, after_group):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
score = self._match_score(before_group, after_group)
|
|
94
|
+
if score > best_score:
|
|
95
|
+
best_score = score
|
|
96
|
+
best_index = index
|
|
97
|
+
|
|
98
|
+
if best_index is None:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
after_group = remaining_after[best_index]
|
|
102
|
+
matched_before.add(before_group.id)
|
|
103
|
+
matched_after.add(after_group.id)
|
|
104
|
+
matches.append(_MatchResult(before=before_group, after=after_group))
|
|
105
|
+
|
|
106
|
+
self._matches = matches
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _match_score(before_group: DuplicateGroup, after_group: DuplicateGroup) -> float:
|
|
110
|
+
files_before = _group_files(before_group)
|
|
111
|
+
files_after = _group_files(after_group)
|
|
112
|
+
overlap = len(files_before.intersection(files_after))
|
|
113
|
+
union = len(files_before.union(files_after))
|
|
114
|
+
file_score = overlap / union if union else 0.0
|
|
115
|
+
|
|
116
|
+
score = file_score
|
|
117
|
+
if before_group.normalized_hash and before_group.normalized_hash == after_group.normalized_hash:
|
|
118
|
+
score += 1.0
|
|
119
|
+
if before_group.normalized_name and before_group.normalized_name == after_group.normalized_name:
|
|
120
|
+
score += 0.5
|
|
121
|
+
score += max(0.0, 1.0 - abs(before_group.similarity_score - after_group.similarity_score))
|
|
122
|
+
return score
|
|
123
|
+
|
|
124
|
+
def get_resolved_groups(self) -> list[DuplicateGroup]:
|
|
125
|
+
"""Groups that disappeared between scans."""
|
|
126
|
+
self._ensure_matches()
|
|
127
|
+
matched_before = {match.before.id for match in self._matches or []}
|
|
128
|
+
return [group for group_id, group in self.before_groups.items() if group_id not in matched_before]
|
|
129
|
+
|
|
130
|
+
def get_new_groups(self) -> list[DuplicateGroup]:
|
|
131
|
+
"""Groups that appeared in the later scan."""
|
|
132
|
+
self._ensure_matches()
|
|
133
|
+
matched_after = {match.after.id for match in self._matches or []}
|
|
134
|
+
return [group for group_id, group in self.after_groups.items() if group_id not in matched_after]
|
|
135
|
+
|
|
136
|
+
def get_unchanged_groups(self) -> list[DuplicateGroup]:
|
|
137
|
+
"""Groups that are present in both scans."""
|
|
138
|
+
self._ensure_matches()
|
|
139
|
+
return [match.after for match in self._matches or []]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DiffCalculator:
|
|
143
|
+
"""Aggregate statistics for scan diffs."""
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def calculate_diff_stats(
|
|
147
|
+
resolved_groups: Iterable[DuplicateGroup],
|
|
148
|
+
new_groups: Iterable[DuplicateGroup],
|
|
149
|
+
unchanged_groups: Iterable[DuplicateGroup],
|
|
150
|
+
) -> dict[str, int]:
|
|
151
|
+
resolved_groups = list(resolved_groups)
|
|
152
|
+
new_groups = list(new_groups)
|
|
153
|
+
unchanged_groups = list(unchanged_groups)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"resolved_count": len(resolved_groups),
|
|
157
|
+
"new_count": len(new_groups),
|
|
158
|
+
"unchanged_count": len(unchanged_groups),
|
|
159
|
+
"resolved_lines": sum(group.saved_lines_potential for group in resolved_groups),
|
|
160
|
+
"new_lines": sum(group.saved_lines_potential for group in new_groups),
|
|
161
|
+
"unchanged_lines": sum(group.saved_lines_potential for group in unchanged_groups),
|
|
162
|
+
}
|