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.
Files changed (62) hide show
  1. {redup-0.4.1 → redup-0.4.3}/PKG-INFO +2 -2
  2. {redup-0.4.1 → redup-0.4.3}/README.md +1 -1
  3. {redup-0.4.1 → redup-0.4.3}/pyproject.toml +2 -1
  4. {redup-0.4.1 → redup-0.4.3}/src/redup/__init__.py +1 -1
  5. {redup-0.4.1 → redup-0.4.3}/src/redup/core/scanner.py +16 -4
  6. redup-0.4.3/src/redup/core/utils/diff_helpers.py +162 -0
  7. redup-0.4.3/src/redup/mcp_server.py +675 -0
  8. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/PKG-INFO +2 -2
  9. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/SOURCES.txt +3 -0
  10. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/entry_points.txt +1 -0
  11. redup-0.4.3/tests/test_mcp_server.py +199 -0
  12. {redup-0.4.1 → redup-0.4.3}/LICENSE +0 -0
  13. {redup-0.4.1 → redup-0.4.3}/setup.cfg +0 -0
  14. {redup-0.4.1 → redup-0.4.3}/src/redup/__main__.py +0 -0
  15. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/__init__.py +0 -0
  16. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/config_builder.py +0 -0
  17. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/fuzzy_similarity.py +0 -0
  18. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/main.py +0 -0
  19. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/output_writer.py +0 -0
  20. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/scan_commands.py +0 -0
  21. {redup-0.4.1 → redup-0.4.3}/src/redup/cli_app/scan_helpers.py +0 -0
  22. {redup-0.4.1 → redup-0.4.3}/src/redup/core/__init__.py +0 -0
  23. {redup-0.4.1 → redup-0.4.3}/src/redup/core/cache.py +0 -0
  24. {redup-0.4.1 → redup-0.4.3}/src/redup/core/config.py +0 -0
  25. {redup-0.4.1 → redup-0.4.3}/src/redup/core/differ.py +0 -0
  26. {redup-0.4.1 → redup-0.4.3}/src/redup/core/fuzzy_similarity.py +0 -0
  27. {redup-0.4.1 → redup-0.4.3}/src/redup/core/hash_cache.py +0 -0
  28. {redup-0.4.1 → redup-0.4.3}/src/redup/core/hasher.py +0 -0
  29. {redup-0.4.1 → redup-0.4.3}/src/redup/core/lazy_grouper.py +0 -0
  30. {redup-0.4.1 → redup-0.4.3}/src/redup/core/lsh_matcher.py +0 -0
  31. {redup-0.4.1 → redup-0.4.3}/src/redup/core/matcher.py +0 -0
  32. {redup-0.4.1 → redup-0.4.3}/src/redup/core/models.py +0 -0
  33. {redup-0.4.1 → redup-0.4.3}/src/redup/core/pipeline.py +0 -0
  34. {redup-0.4.1 → redup-0.4.3}/src/redup/core/planner.py +0 -0
  35. {redup-0.4.1 → redup-0.4.3}/src/redup/core/python_parser.py +0 -0
  36. {redup-0.4.1 → redup-0.4.3}/src/redup/core/semantic.py +0 -0
  37. {redup-0.4.1 → redup-0.4.3}/src/redup/core/ts_extractor.py +0 -0
  38. {redup-0.4.1 → redup-0.4.3}/src/redup/core/universal_fuzzy.py +0 -0
  39. {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/__init__.py +0 -0
  40. {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/duplicate_finders.py +0 -0
  41. {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/function_extractor.py +0 -0
  42. {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/hash_utils.py +0 -0
  43. {redup-0.4.1 → redup-0.4.3}/src/redup/core/utils/language_dispatcher.py +0 -0
  44. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/__init__.py +0 -0
  45. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/code2llm_reporter.py +0 -0
  46. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/enhanced_reporter.py +0 -0
  47. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/json_reporter.py +0 -0
  48. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/markdown_reporter.py +0 -0
  49. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/toon_reporter.py +0 -0
  50. {redup-0.4.1 → redup-0.4.3}/src/redup/reporters/yaml_reporter.py +0 -0
  51. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/dependency_links.txt +0 -0
  52. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/requires.txt +0 -0
  53. {redup-0.4.1 → redup-0.4.3}/src/redup.egg-info/top_level.txt +0 -0
  54. {redup-0.4.1 → redup-0.4.3}/tests/test_e2e.py +0 -0
  55. {redup-0.4.1 → redup-0.4.3}/tests/test_hasher.py +0 -0
  56. {redup-0.4.1 → redup-0.4.3}/tests/test_matcher.py +0 -0
  57. {redup-0.4.1 → redup-0.4.3}/tests/test_models.py +0 -0
  58. {redup-0.4.1 → redup-0.4.3}/tests/test_pipeline.py +0 -0
  59. {redup-0.4.1 → redup-0.4.3}/tests/test_planner.py +0 -0
  60. {redup-0.4.1 → redup-0.4.3}/tests/test_reporters.py +0 -0
  61. {redup-0.4.1 → redup-0.4.3}/tests/test_scanner.py +0 -0
  62. {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.1
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
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
93
93
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
94
94
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
95
- [![Version](https://img.shields.io/badge/version-0.4.1-green.svg)](https://pypi.org/project/redup/)
95
+ [![Version](https://img.shields.io/badge/version-0.4.3-green.svg)](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
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
8
- [![Version](https://img.shields.io/badge/version-0.4.1-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.4.3-green.svg)](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.1"
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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.4.3"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -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(file_path, tuple(config.exclude_patterns)):
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(file_path):
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(file_path, tuple(default_patterns)):
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(file_path):
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
+ }