redup 0.3.30__tar.gz → 0.4.1__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 (59) hide show
  1. {redup-0.3.30 → redup-0.4.1}/PKG-INFO +10 -2
  2. {redup-0.3.30 → redup-0.4.1}/README.md +1 -1
  3. {redup-0.3.30 → redup-0.4.1}/pyproject.toml +5 -1
  4. {redup-0.3.30 → redup-0.4.1}/src/redup/__init__.py +1 -1
  5. {redup-0.3.30 → redup-0.4.1}/src/redup/core/fuzzy_similarity.py +16 -8
  6. {redup-0.3.30 → redup-0.4.1}/src/redup/core/hasher.py +70 -3
  7. {redup-0.3.30 → redup-0.4.1}/src/redup/core/models.py +21 -20
  8. {redup-0.3.30 → redup-0.4.1}/src/redup/core/pipeline.py +46 -0
  9. redup-0.4.1/src/redup/core/python_parser.py +170 -0
  10. {redup-0.3.30 → redup-0.4.1}/src/redup/core/scanner.py +4 -42
  11. redup-0.4.1/src/redup/core/semantic.py +174 -0
  12. {redup-0.3.30 → redup-0.4.1}/src/redup/core/universal_fuzzy.py +3 -8
  13. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/PKG-INFO +10 -2
  14. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/SOURCES.txt +2 -0
  15. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/requires.txt +10 -0
  16. {redup-0.3.30 → redup-0.4.1}/LICENSE +0 -0
  17. {redup-0.3.30 → redup-0.4.1}/setup.cfg +0 -0
  18. {redup-0.3.30 → redup-0.4.1}/src/redup/__main__.py +0 -0
  19. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/__init__.py +0 -0
  20. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/config_builder.py +0 -0
  21. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/fuzzy_similarity.py +0 -0
  22. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/main.py +0 -0
  23. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/output_writer.py +0 -0
  24. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/scan_commands.py +0 -0
  25. {redup-0.3.30 → redup-0.4.1}/src/redup/cli_app/scan_helpers.py +0 -0
  26. {redup-0.3.30 → redup-0.4.1}/src/redup/core/__init__.py +0 -0
  27. {redup-0.3.30 → redup-0.4.1}/src/redup/core/cache.py +0 -0
  28. {redup-0.3.30 → redup-0.4.1}/src/redup/core/config.py +0 -0
  29. {redup-0.3.30 → redup-0.4.1}/src/redup/core/differ.py +0 -0
  30. {redup-0.3.30 → redup-0.4.1}/src/redup/core/hash_cache.py +0 -0
  31. {redup-0.3.30 → redup-0.4.1}/src/redup/core/lazy_grouper.py +0 -0
  32. {redup-0.3.30 → redup-0.4.1}/src/redup/core/lsh_matcher.py +0 -0
  33. {redup-0.3.30 → redup-0.4.1}/src/redup/core/matcher.py +0 -0
  34. {redup-0.3.30 → redup-0.4.1}/src/redup/core/planner.py +0 -0
  35. {redup-0.3.30 → redup-0.4.1}/src/redup/core/ts_extractor.py +0 -0
  36. {redup-0.3.30 → redup-0.4.1}/src/redup/core/utils/__init__.py +0 -0
  37. {redup-0.3.30 → redup-0.4.1}/src/redup/core/utils/duplicate_finders.py +0 -0
  38. {redup-0.3.30 → redup-0.4.1}/src/redup/core/utils/function_extractor.py +0 -0
  39. {redup-0.3.30 → redup-0.4.1}/src/redup/core/utils/hash_utils.py +0 -0
  40. {redup-0.3.30 → redup-0.4.1}/src/redup/core/utils/language_dispatcher.py +0 -0
  41. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/__init__.py +0 -0
  42. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/code2llm_reporter.py +0 -0
  43. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/enhanced_reporter.py +0 -0
  44. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/json_reporter.py +0 -0
  45. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/markdown_reporter.py +0 -0
  46. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/toon_reporter.py +0 -0
  47. {redup-0.3.30 → redup-0.4.1}/src/redup/reporters/yaml_reporter.py +0 -0
  48. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/dependency_links.txt +0 -0
  49. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/entry_points.txt +0 -0
  50. {redup-0.3.30 → redup-0.4.1}/src/redup.egg-info/top_level.txt +0 -0
  51. {redup-0.3.30 → redup-0.4.1}/tests/test_e2e.py +0 -0
  52. {redup-0.3.30 → redup-0.4.1}/tests/test_hasher.py +0 -0
  53. {redup-0.3.30 → redup-0.4.1}/tests/test_matcher.py +0 -0
  54. {redup-0.3.30 → redup-0.4.1}/tests/test_models.py +0 -0
  55. {redup-0.3.30 → redup-0.4.1}/tests/test_pipeline.py +0 -0
  56. {redup-0.3.30 → redup-0.4.1}/tests/test_planner.py +0 -0
  57. {redup-0.3.30 → redup-0.4.1}/tests/test_reporters.py +0 -0
  58. {redup-0.3.30 → redup-0.4.1}/tests/test_scanner.py +0 -0
  59. {redup-0.3.30 → redup-0.4.1}/tests/test_ts_extractor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.3.30
3
+ Version: 0.4.1
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
@@ -28,9 +28,15 @@ Requires-Dist: rich>=13.0
28
28
  Requires-Dist: pydantic>=2.0
29
29
  Requires-Dist: tomli>=2.0; python_version < "3.11"
30
30
  Provides-Extra: all
31
+ Requires-Dist: redup[fast]; extra == "all"
31
32
  Requires-Dist: redup[fuzzy]; extra == "all"
32
33
  Requires-Dist: redup[ast]; extra == "all"
33
34
  Requires-Dist: redup[lsh]; extra == "all"
35
+ Requires-Dist: redup[semantic]; extra == "all"
36
+ Provides-Extra: fast
37
+ Requires-Dist: xxhash>=3.0; extra == "fast"
38
+ Requires-Dist: libcst>=1.0; extra == "fast"
39
+ Requires-Dist: pybloom-live>=4.0; extra == "fast"
34
40
  Provides-Extra: fuzzy
35
41
  Requires-Dist: rapidfuzz>=3.0; extra == "fuzzy"
36
42
  Provides-Extra: ast
@@ -70,6 +76,8 @@ Requires-Dist: tree-sitter-svelte>=0.20; extra == "ast"
70
76
  Requires-Dist: tree-sitter-vue>=0.20; extra == "ast"
71
77
  Provides-Extra: lsh
72
78
  Requires-Dist: datasketch>=1.6; extra == "lsh"
79
+ Provides-Extra: semantic
80
+ Requires-Dist: sentence-transformers>=2.0; extra == "semantic"
73
81
  Provides-Extra: dev
74
82
  Requires-Dist: pytest>=7.0; extra == "dev"
75
83
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -84,7 +92,7 @@ Dynamic: license-file
84
92
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
85
93
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
86
94
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
87
- [![Version](https://img.shields.io/badge/version-0.3.30-green.svg)](https://pypi.org/project/redup/)
95
+ [![Version](https://img.shields.io/badge/version-0.4.1-green.svg)](https://pypi.org/project/redup/)
88
96
 
89
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.
90
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.3.30-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.4.1-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.3.30"
7
+ version = "0.4.1"
8
8
  description = "Code duplication analyzer and refactoring planner for LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -35,10 +35,13 @@ dependencies = [
35
35
 
36
36
  [project.optional-dependencies]
37
37
  all = [
38
+ "redup[fast]",
38
39
  "redup[fuzzy]",
39
40
  "redup[ast]",
40
41
  "redup[lsh]",
42
+ "redup[semantic]",
41
43
  ]
44
+ fast = ["xxhash>=3.0", "libcst>=1.0", "pybloom-live>=4.0"]
42
45
  fuzzy = ["rapidfuzz>=3.0"]
43
46
  ast = [
44
47
  "tree-sitter>=0.21",
@@ -77,6 +80,7 @@ ast = [
77
80
  "tree-sitter-vue>=0.20",
78
81
  ]
79
82
  lsh = ["datasketch>=1.6"]
83
+ semantic = ["sentence-transformers>=2.0"]
80
84
  dev = [
81
85
  "pytest>=7.0",
82
86
  "pytest-cov>=4.0",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.30"
5
+ __version__ = "0.4.1"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -17,11 +17,21 @@ except ImportError:
17
17
  MinHash = None
18
18
  MinHashLSH = None
19
19
 
20
+ # Fast fuzzy matching with fallback
20
21
  try:
21
- from rapidfuzz import fuzz, ratio
22
+ from rapidfuzz import fuzz as _rfuzz
23
+ def _text_ratio(a: str, b: str) -> float:
24
+ return _rfuzz.ratio(a, b) / 100.0
25
+ def _partial_ratio(a: str, b: str) -> float:
26
+ return _rfuzz.partial_ratio(a, b) / 100.0
27
+ def _token_sort_ratio(a: str, b: str) -> float:
28
+ return _rfuzz.token_sort_ratio(a, b) / 100.0
22
29
  except ImportError:
23
- fuzz = None
24
- ratio = None
30
+ from difflib import SequenceMatcher
31
+ def _text_ratio(a: str, b: str) -> float:
32
+ return SequenceMatcher(None, a, b).ratio()
33
+ _partial_ratio = _text_ratio
34
+ _token_sort_ratio = _text_ratio
25
35
 
26
36
  from redup.core.scanner import CodeBlock
27
37
 
@@ -357,8 +367,8 @@ class FuzzySimilarityDetector:
357
367
  similarity += 0.1 * attr_similarity
358
368
 
359
369
  # Text content similarity (for HTML)
360
- if sig1.text_content and sig2.text_content and fuzz is not None:
361
- text_similarity = fuzz.ratio(sig1.text_content, sig2.text_content) / 100.0
370
+ if sig1.text_content and sig2.text_content:
371
+ text_similarity = _text_ratio(sig1.text_content, sig2.text_content)
362
372
  similarity += 0.1 * text_similarity
363
373
 
364
374
  # CSS properties similarity (for CSS)
@@ -392,10 +402,8 @@ class FuzzySimilarityDetector:
392
402
  val1, val2 = attrs1[key], attrs2[key]
393
403
  if val1 == val2:
394
404
  value_similarities.append(1.0)
395
- elif fuzz is not None:
396
- value_similarities.append(fuzz.ratio(val1, val2) / 100.0)
397
405
  else:
398
- value_similarities.append(0.0)
406
+ value_similarities.append(_text_ratio(val1, val2))
399
407
 
400
408
  if value_similarities:
401
409
  value_similarity = sum(value_similarities) / len(value_similarities)
@@ -11,6 +11,22 @@ from collections.abc import Callable
11
11
  from dataclasses import dataclass, field
12
12
  from typing import Any
13
13
 
14
+ # Fast hash implementation with fallback
15
+ try:
16
+ import xxhash
17
+ def _fast_hash(data: bytes) -> str:
18
+ return xxhash.xxh64(data).hexdigest()[:16]
19
+ except ImportError:
20
+ def _fast_hash(data: bytes) -> str:
21
+ return hashlib.sha256(data).hexdigest()[:16]
22
+
23
+ # Bloom filter for pre-screening
24
+ try:
25
+ from pybloom_live import ScalableBloomFilter
26
+ _USE_BLOOM = True
27
+ except ImportError:
28
+ _USE_BLOOM = False
29
+
14
30
  from redup.core.scanner import CodeBlock
15
31
 
16
32
  # Cache for normalization results - avoids re-parsing same text
@@ -183,10 +199,10 @@ def _normalize_constant(value: Any) -> str:
183
199
 
184
200
 
185
201
  def _hash_text(text: str, normalizer: Callable[[str], str]) -> str:
186
- """Generic hash function with configurable normalizer - uses fast blake2b."""
202
+ """Generic hash function with configurable normalizer - uses fast xxhash or SHA-256 fallback."""
187
203
  normalized = normalizer(text)
188
- # blake2b is significantly faster than sha256 for short inputs
189
- return hashlib.blake2b(normalized.encode("utf-8"), digest_size=16).hexdigest()[:16]
204
+ # Use fast xxhash if available, otherwise fallback to SHA-256
205
+ return _fast_hash(normalized.encode("utf-8"))
190
206
 
191
207
 
192
208
  def hash_block(text: str) -> str:
@@ -221,6 +237,53 @@ def _hashed_block(block: CodeBlock) -> HashedBlock:
221
237
  )
222
238
 
223
239
 
240
+ class BloomHashIndex:
241
+ """Two-pass hash index: Bloom filter eliminates uniques in O(1)."""
242
+
243
+ def __init__(self, expected_items: int = 100_000):
244
+ if _USE_BLOOM:
245
+ self._bloom = ScalableBloomFilter(
246
+ initial_capacity=expected_items,
247
+ error_rate=0.001,
248
+ )
249
+ else:
250
+ self._bloom = None
251
+ self._seen_hashes: set[str] = set()
252
+ self._candidates: dict[str, list] = defaultdict(list)
253
+
254
+ def add(self, hash_val: str, block: 'HashedBlock') -> bool:
255
+ """Add a hash. Returns True if this is a POTENTIAL duplicate.
256
+
257
+ First pass: Bloom filter says "maybe seen" or "definitely not seen".
258
+ Only "maybe seen" blocks go into the candidates dict.
259
+ """
260
+ if self._bloom is not None:
261
+ if hash_val in self._bloom:
262
+ # Maybe duplicate — add to candidates
263
+ self._candidates[hash_val].append(block)
264
+ return True
265
+ else:
266
+ # Definitely first time — skip
267
+ self._bloom.add(hash_val)
268
+ # Still need to track for the second occurrence
269
+ if hash_val in self._seen_hashes:
270
+ self._candidates[hash_val].append(block)
271
+ return True
272
+ self._seen_hashes.add(hash_val)
273
+ return False
274
+ else:
275
+ # Fallback: plain set
276
+ if hash_val in self._seen_hashes:
277
+ self._candidates[hash_val].append(block)
278
+ return True
279
+ self._seen_hashes.add(hash_val)
280
+ return False
281
+
282
+ def get_duplicate_groups(self) -> dict[str, list]:
283
+ """Return only groups with 2+ blocks."""
284
+ return {h: blocks for h, blocks in self._candidates.items() if len(blocks) > 1}
285
+
286
+
224
287
  @dataclass
225
288
  class HashedBlock:
226
289
  """A code block with its computed fingerprints."""
@@ -243,8 +306,10 @@ def build_hash_index(blocks: list[CodeBlock], min_lines: int = 3) -> HashIndex:
243
306
 
244
307
  Only indexes function-level blocks (those with function_name set)
245
308
  and blocks above the minimum line threshold.
309
+ Uses Bloom filter pre-screening to eliminate unique blocks early.
246
310
  """
247
311
  index = HashIndex()
312
+ bloom = BloomHashIndex(expected_items=len(blocks))
248
313
 
249
314
  for block in blocks:
250
315
  if block.line_count < min_lines:
@@ -252,6 +317,8 @@ def build_hash_index(blocks: list[CodeBlock], min_lines: int = 3) -> HashIndex:
252
317
 
253
318
  hb = _hashed_block(block)
254
319
 
320
+ # Bloom filter pre-screens — only potential duplicates enter the index
321
+ bloom.add(hb.exact_hash, hb)
255
322
  index.exact[hb.exact_hash].append(hb)
256
323
  index.structural[hb.structural_hash].append(hb)
257
324
 
@@ -14,6 +14,7 @@ class DuplicateType(str, Enum):
14
14
  STRUCTURAL = "structural"
15
15
  FUZZY = "fuzzy"
16
16
  NEAR_DUPLICATE = "near_duplicate"
17
+ SEMANTIC = "semantic" # ← NOWY
17
18
 
18
19
 
19
20
  class RefactorAction(str, Enum):
@@ -42,53 +43,53 @@ class ScanConfig:
42
43
  exclude_patterns: list[str] = field(
43
44
  default_factory=lambda: [
44
45
  # Universal system files (but not normal code files)
45
- ".git/", ".svn/", ".hg/", "__pycache__/", ".pytest_cache/", ".mypy_cache/",
46
- ".ruff_cache/", ".tox/", ".nox/", ".coverage", "htmlcov/", ".DS_Store",
47
- "Thumbs.db", "*.swp", "*.swo", "*~", ".vscode/", ".idea/",
46
+ ".git", ".svn", ".hg", "__pycache__", ".pytest_cache", ".mypy_cache",
47
+ ".ruff_cache", ".tox", ".nox", ".coverage", "htmlcov", ".DS_Store",
48
+ "Thumbs.db", "*.swp", "*.swo", "*~", ".vscode", ".idea",
48
49
 
49
50
  # Python specific
50
51
  "*.pyc", "*.pyo", "*.pyd", "$py.class",
51
- "python*/", "python3*/", "Python*/",
52
- "venv*/", "env*/", ".venv*/", ".env*/",
53
- "*.egg-info/", "*.egg", "build/", "dist/",
54
- ".installer/", ".eggs/", "lib/", "lib64/", "parts/", "sdist/", "var/", "wheels/",
52
+ "python*", "python3*", "Python*",
53
+ "venv*", "env*", ".venv*", ".env*",
54
+ "*.egg-info", "*.egg", "build", "dist",
55
+ ".installer", ".eggs", "lib", "lib64", "parts", "sdist", "var", "wheels",
55
56
 
56
57
  # Node.js/JavaScript
57
- "node_modules/", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*",
58
- "pnpm-debug.log*", ".npm/", ".yarn-integrity/", ".eslintcache/", ".stylelintcache/",
59
- ".next/", ".nuxt/", ".vuepress/dist/",
58
+ "node_modules", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*",
59
+ "pnpm-debug.log*", ".npm", ".yarn-integrity", ".eslintcache", ".stylelintcache",
60
+ ".next", ".nuxt", ".vuepress/dist",
60
61
 
61
62
  # Java/Maven/Gradle
62
- "target/", "build/", "out/", ".classpath/", ".project/", ".settings/",
63
- ".mvn/", "gradle/", ".gradle/", "*.class", "*.jar", "*.war", "*.ear", "*.aar",
63
+ "target", "build", "out", ".classpath", ".project", ".settings",
64
+ ".mvn", "gradle", ".gradle", "*.class", "*.jar", "*.war", "*.ear", "*.aar",
64
65
 
65
66
  # Rust
66
- "target/", "Cargo.lock", "**/*.rs.bk",
67
+ "target", "Cargo.lock", "**/*.rs.bk",
67
68
 
68
69
  # Go
69
- "vendor/", "*.exe", "*.exe~", "*.dll", "*.so", "*.dylib", "*.test", "*.out",
70
+ "vendor", "*.exe", "*.exe~", "*.dll", "*.so", "*.dylib", "*.test", "*.out",
70
71
 
71
72
  # Docker
72
- ".dockerignore", ".docker/",
73
+ ".dockerignore", ".docker",
73
74
 
74
75
  # Mobile
75
- "android/app/build/", "ios/build/", "*.apk", "*.ipa", "*.xcarchive",
76
+ "android/app/build", "ios/build", "*.apk", "*.ipa", "*.xcarchive",
76
77
 
77
78
  # Database
78
- "*.db", "*.sqlite", "*.sqlite3", "data/", "logs/",
79
+ "*.db", "*.sqlite", "*.sqlite3", "data", "logs",
79
80
 
80
81
  # Cloud/DevOps
81
- ".terraform/", ".terraform.lock.hcl", "*.tfstate", "*.tfstate.*", ".ansible/", ".kube/",
82
+ ".terraform", ".terraform.lock.hcl", "*.tfstate", "*.tfstate.*", ".ansible", ".kube",
82
83
 
83
84
  # General build/cache
84
- "cache/", ".cache/", "tmp/", ".tmp/", "*.tmp", "*.temp", "*.bak", "*.backup",
85
+ "cache", ".cache", "tmp", ".tmp", "*.tmp", "*.temp", "*.bak", "*.backup",
85
86
 
86
87
  # Package managers
87
88
  "composer.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
88
89
  "poetry.lock", "Pipfile.lock", "Gemfile.lock",
89
90
 
90
91
  # Logs
91
- "*.log", "logs/",
92
+ "*.log", "logs",
92
93
  ]
93
94
  )
94
95
  min_block_lines: int = 3
@@ -519,6 +519,52 @@ def _find_near_duplicate_groups(
519
519
  return groups
520
520
 
521
521
 
522
+ def _find_semantic_groups(
523
+ blocks: list[CodeBlock],
524
+ threshold: float = 0.80,
525
+ ) -> list[DuplicateGroup]:
526
+ """Tier 4: Semantic duplicate detection via embeddings."""
527
+ try:
528
+ from redup.core.semantic import SemanticDetector
529
+ except ImportError:
530
+ return []
531
+
532
+ detector = SemanticDetector(threshold=threshold)
533
+
534
+ # Only function-level blocks (skip sliding window noise)
535
+ func_blocks = [b for b in blocks if b.function_name]
536
+ if len(func_blocks) < 2:
537
+ return []
538
+
539
+ matches = detector.find_semantic_duplicates_fast(func_blocks)
540
+
541
+ groups: list[DuplicateGroup] = []
542
+ for i, match in enumerate(matches):
543
+ groups.append(DuplicateGroup(
544
+ id=f"M{i+1:04d}",
545
+ duplicate_type=DuplicateType.SEMANTIC, # ← Use new semantic type
546
+ fragments=[
547
+ DuplicateFragment(
548
+ file=match.block_a.file,
549
+ line_start=match.block_a.line_start,
550
+ line_end=match.block_a.line_end,
551
+ text=match.block_a.text,
552
+ function_name=match.block_a.function_name,
553
+ ),
554
+ DuplicateFragment(
555
+ file=match.block_b.file,
556
+ line_start=match.block_b.line_start,
557
+ line_end=match.block_b.line_end,
558
+ text=match.block_b.text,
559
+ function_name=match.block_b.function_name,
560
+ ),
561
+ ],
562
+ similarity_score=match.similarity,
563
+ ))
564
+
565
+ return groups
566
+
567
+
522
568
  def analyze_optimized(
523
569
  config: ScanConfig | None = None,
524
570
  function_level_only: bool = False,
@@ -0,0 +1,170 @@
1
+ """Unified Python parser — libcst (fast) with ast (fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from redup.core.scanner import CodeBlock
9
+
10
+
11
+ @dataclass
12
+ class ParsedFunction:
13
+ """A parsed Python function with metadata."""
14
+ name: str
15
+ start_line: int
16
+ end_line: int
17
+ text: str
18
+ class_name: str | None = None
19
+ arg_count: int = 0
20
+ has_return: bool = False
21
+ decorators: list[str] | None = None
22
+
23
+
24
+ def _parse_with_libcst(source: str, filepath: str) -> list[ParsedFunction]:
25
+ """Fast path — libcst CST parsing."""
26
+ try:
27
+ import libcst as cst
28
+ except ImportError:
29
+ return []
30
+
31
+ functions: list[ParsedFunction] = []
32
+ try:
33
+ tree = cst.parse_module(source)
34
+ except cst.ParserSyntaxError:
35
+ return []
36
+
37
+ class FunctionCollector(cst.CSTVisitor):
38
+ """Collect function definitions with line numbers."""
39
+
40
+ def __init__(self):
41
+ self._class_stack: list[str] = []
42
+
43
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
44
+ self._class_stack.append(node.name.value)
45
+ return True
46
+
47
+ def leave_ClassDef(self, node: cst.ClassDef) -> None:
48
+ self._class_stack.pop()
49
+
50
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
51
+ # Get line numbers from position
52
+ start_line = node.position.start.line if hasattr(node, 'position') else 0
53
+
54
+ # Extract function text (approximate)
55
+ func_text = cst.Module([node]).code
56
+
57
+ # Count arguments
58
+ arg_count = len(node.params.params)
59
+
60
+ # Check for return statements (simplified)
61
+ has_return = 'return' in func_text
62
+
63
+ # Extract decorators
64
+ decorators = []
65
+ for decorator in node.decorators:
66
+ if hasattr(decorator, 'decorator') and hasattr(decorator.decorator, 'value'):
67
+ decorators.append(decorator.decorator.value)
68
+
69
+ functions.append(ParsedFunction(
70
+ name=node.name.value,
71
+ start_line=start_line,
72
+ end_line=start_line + func_text.count('\n'),
73
+ text=func_text,
74
+ class_name=self._class_stack[-1] if self._class_stack else None,
75
+ arg_count=arg_count,
76
+ has_return=has_return,
77
+ decorators=decorators if decorators else None,
78
+ ))
79
+ return False # Don't visit nested functions
80
+
81
+ def visit_AsyncFunctionDef(self, node: cst.AsyncFunctionDef) -> bool:
82
+ # Handle async functions the same way
83
+ return self.visit_FunctionDef(node.function)
84
+
85
+ try:
86
+ tree.visit(FunctionCollector())
87
+ except Exception:
88
+ return []
89
+
90
+ return functions
91
+
92
+
93
+ def _parse_with_ast(source: str, filepath: str) -> list[ParsedFunction]:
94
+ """Fallback — stdlib ast parsing."""
95
+ import ast
96
+ functions: list[ParsedFunction] = []
97
+ try:
98
+ tree = ast.parse(source)
99
+ except SyntaxError:
100
+ return []
101
+
102
+ lines = source.splitlines()
103
+ parent_map: dict[int, ast.ClassDef] = {}
104
+
105
+ # Build parent map for class context
106
+ for node in ast.walk(tree):
107
+ if isinstance(node, ast.ClassDef):
108
+ for child in ast.walk(node):
109
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
110
+ parent_map[id(child)] = node
111
+
112
+ for node in ast.walk(tree):
113
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
114
+ start = node.lineno
115
+ end = node.end_lineno or start
116
+ text = "\n".join(lines[start - 1:end])
117
+ class_node = parent_map.get(id(node))
118
+
119
+ # Count arguments
120
+ arg_count = len(node.args.args)
121
+
122
+ # Check for return statements
123
+ has_return = any(isinstance(n, ast.Return) for n in ast.walk(node))
124
+
125
+ # Extract decorators
126
+ decorators = []
127
+ for decorator in node.decorator_list:
128
+ if isinstance(decorator, ast.Name):
129
+ decorators.append(decorator.id)
130
+ elif isinstance(decorator, ast.Attribute):
131
+ decorators.append(ast.unparse(decorator))
132
+
133
+ functions.append(ParsedFunction(
134
+ name=node.name,
135
+ start_line=start,
136
+ end_line=end,
137
+ text=text,
138
+ class_name=class_node.name if class_node else None,
139
+ arg_count=arg_count,
140
+ has_return=has_return,
141
+ decorators=decorators if decorators else None,
142
+ ))
143
+
144
+ return functions
145
+
146
+
147
+ def parse_python_functions(source: str, filepath: str) -> list[ParsedFunction]:
148
+ """Parse Python source — uses libcst if available, falls back to ast."""
149
+ try:
150
+ result = _parse_with_libcst(source, filepath)
151
+ if result:
152
+ return result
153
+ except ImportError:
154
+ pass
155
+ return _parse_with_ast(source, filepath)
156
+
157
+
158
+ def parsed_to_code_blocks(parsed: list[ParsedFunction], filepath: str) -> list[CodeBlock]:
159
+ """Convert ParsedFunction list to CodeBlock list for pipeline compatibility."""
160
+ return [
161
+ CodeBlock(
162
+ file=filepath,
163
+ line_start=f.start_line,
164
+ line_end=f.end_line,
165
+ text=f.text,
166
+ function_name=f.name,
167
+ class_name=f.class_name,
168
+ )
169
+ for f in parsed
170
+ ]
@@ -485,49 +485,11 @@ def _should_process_file(
485
485
 
486
486
 
487
487
  def _extract_function_blocks_python(content: str, file_path: str) -> list[CodeBlock]:
488
- """Extract function blocks from Python code using AST."""
489
- import ast
488
+ """Extract function blocks from Python code using libcst (fast) with ast fallback."""
489
+ from redup.core.python_parser import parse_python_functions, parsed_to_code_blocks
490
490
 
491
- blocks = []
492
- lines = content.splitlines()
493
-
494
- try:
495
- tree = ast.parse(content)
496
-
497
- for node in ast.walk(tree):
498
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
499
- # Get function boundaries
500
- start_line = node.lineno
501
- end_line = node.end_lineno if hasattr(node, 'end_lineno') else start_line
502
-
503
- # Extract function text
504
- func_lines = lines[start_line - 1:end_line]
505
- func_text = '\\n'.join(func_lines)
506
-
507
- # Get class name if inside a class
508
- class_name = None
509
- for parent in ast.walk(tree):
510
- if isinstance(parent, ast.ClassDef):
511
- if (parent.lineno <= start_line <=
512
- (parent.end_lineno if hasattr(parent, 'end_lineno') else float('inf'))):
513
- class_name = parent.name
514
- break
515
-
516
- block = CodeBlock(
517
- file=file_path,
518
- line_start=start_line,
519
- line_end=end_line,
520
- text=func_text,
521
- function_name=node.name,
522
- class_name=class_name
523
- )
524
- blocks.append(block)
525
-
526
- except SyntaxError:
527
- # Return empty list for invalid Python
528
- return []
529
-
530
- return blocks
491
+ parsed = parse_python_functions(content, file_path)
492
+ return parsed_to_code_blocks(parsed, file_path)
531
493
 
532
494
 
533
495
  def _extract_sliding_blocks(content: str, file_path: str) -> list[CodeBlock]:
@@ -0,0 +1,174 @@
1
+ """Semantic duplicate detection via code embeddings.
2
+
3
+ This module provides semantic duplicate detection using transformer models
4
+ like CodeBERT to find functionally similar code despite different implementations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from redup.core.scanner import CodeBlock
13
+
14
+
15
+ @dataclass
16
+ class SemanticMatch:
17
+ """A pair of semantically similar code blocks."""
18
+ block_a: CodeBlock
19
+ block_b: CodeBlock
20
+ similarity: float
21
+ model: str
22
+
23
+
24
+ class SemanticDetector:
25
+ """Detects semantically similar code using transformer embeddings."""
26
+
27
+ def __init__(self, model_name: str = "microsoft/codebert-base", threshold: float = 0.80):
28
+ self.threshold = threshold
29
+ self.model_name = model_name
30
+ self._model = None
31
+
32
+ def _ensure_model(self):
33
+ """Lazy-load the model only when needed."""
34
+ if self._model is None:
35
+ try:
36
+ from sentence_transformers import SentenceTransformer
37
+ self._model = SentenceTransformer(self.model_name)
38
+ except ImportError:
39
+ raise ImportError(
40
+ "sentence-transformers is required for semantic detection. "
41
+ "Install with: pip install redup[semantic]"
42
+ )
43
+
44
+ def find_semantic_duplicates(
45
+ self,
46
+ blocks: list[CodeBlock],
47
+ batch_size: int = 32,
48
+ ) -> list[SemanticMatch]:
49
+ """Find semantically similar code blocks using embeddings.
50
+
51
+ Pipeline:
52
+ 1. Encode all blocks to vectors (batched, GPU if available)
53
+ 2. Compute cosine similarity matrix
54
+ 3. Filter pairs above threshold
55
+
56
+ Args:
57
+ blocks: List of code blocks to analyze
58
+ batch_size: Batch size for encoding (for memory efficiency)
59
+
60
+ Returns:
61
+ List of semantic matches sorted by similarity (highest first)
62
+ """
63
+ self._ensure_model()
64
+ from sentence_transformers import util
65
+ import torch
66
+
67
+ if len(blocks) < 2:
68
+ return []
69
+
70
+ # Encode (batched for efficiency)
71
+ texts = [b.text for b in blocks]
72
+ embeddings = self._model.encode(
73
+ texts,
74
+ batch_size=batch_size,
75
+ convert_to_tensor=True,
76
+ show_progress_bar=False,
77
+ )
78
+
79
+ # Cosine similarity matrix
80
+ cos_scores = util.cos_sim(embeddings, embeddings)
81
+
82
+ # Extract pairs above threshold
83
+ matches: list[SemanticMatch] = []
84
+ seen: set[tuple[int, int]] = set()
85
+
86
+ for i in range(len(blocks)):
87
+ for j in range(i + 1, len(blocks)):
88
+ score = cos_scores[i][j].item()
89
+ if score >= self.threshold:
90
+ # Skip same-file same-function matches
91
+ if blocks[i].file == blocks[j].file and \
92
+ blocks[i].line_start == blocks[j].line_start:
93
+ continue
94
+
95
+ pair = (min(i, j), max(i, j))
96
+ if pair not in seen:
97
+ seen.add(pair)
98
+ matches.append(SemanticMatch(
99
+ block_a=blocks[i],
100
+ block_b=blocks[j],
101
+ similarity=score,
102
+ model=self.model_name,
103
+ ))
104
+
105
+ # Sort by similarity (highest first)
106
+ matches.sort(key=lambda m: m.similarity, reverse=True)
107
+ return matches
108
+
109
+ def find_semantic_duplicates_fast(
110
+ self,
111
+ blocks: list[CodeBlock],
112
+ top_k: int = 10,
113
+ ) -> list[SemanticMatch]:
114
+ """Fast semantic search — uses approximate kNN instead of full matrix.
115
+
116
+ O(n * log(n)) instead of O(n²). Better for >1000 blocks.
117
+
118
+ Args:
119
+ blocks: List of code blocks to analyze
120
+ top_k: Maximum number of top similar pairs to return
121
+
122
+ Returns:
123
+ List of semantic matches sorted by similarity (highest first)
124
+ """
125
+ self._ensure_model()
126
+ from sentence_transformers import util
127
+
128
+ if len(blocks) < 2:
129
+ return []
130
+
131
+ texts = [b.text for b in blocks]
132
+ embeddings = self._model.encode(texts, convert_to_tensor=True)
133
+
134
+ # Paraphrase mining — finds top-k most similar pairs efficiently
135
+ pairs = util.paraphrase_mining(
136
+ self._model,
137
+ texts,
138
+ corpus_embeddings=embeddings,
139
+ top_k=top_k,
140
+ show_progress_bar=False,
141
+ )
142
+
143
+ matches: list[SemanticMatch] = []
144
+ for score, i, j in pairs:
145
+ if score >= self.threshold:
146
+ # Skip same-file same-function matches
147
+ if blocks[i].file == blocks[j].file and \
148
+ blocks[i].line_start == blocks[j].line_start:
149
+ continue
150
+ matches.append(SemanticMatch(
151
+ block_a=blocks[i],
152
+ block_b=blocks[j],
153
+ similarity=float(score),
154
+ model=self.model_name,
155
+ ))
156
+
157
+ return matches
158
+
159
+ def compute_semantic_similarity(self, text_a: str, text_b: str) -> float:
160
+ """Compute semantic similarity between two code snippets.
161
+
162
+ Args:
163
+ text_a: First code snippet
164
+ text_b: Second code snippet
165
+
166
+ Returns:
167
+ Similarity score between 0.0 and 1.0
168
+ """
169
+ self._ensure_model()
170
+ from sentence_transformers import util
171
+
172
+ embeddings = self._model.encode([text_a, text_b], convert_to_tensor=True)
173
+ cosine_score = util.cos_sim(embeddings[0], embeddings[1])
174
+ return float(cosine_score.item())
@@ -18,11 +18,8 @@ except ImportError:
18
18
  MinHash = None
19
19
  MinHashLSH = None
20
20
 
21
- try:
22
- from rapidfuzz import fuzz, ratio
23
- except ImportError:
24
- fuzz = None
25
- ratio = None
21
+ # Import rapidfuzz functions from fuzzy_similarity module
22
+ from redup.core.fuzzy_similarity import _text_ratio, _partial_ratio, _token_sort_ratio
26
23
 
27
24
  from redup.core.scanner import CodeBlock
28
25
 
@@ -442,10 +439,8 @@ class UniversalFuzzyDetector:
442
439
  val1, val2 = meta1[key], meta2[key]
443
440
  if val1 == val2:
444
441
  value_similarities.append(1.0)
445
- elif fuzz is not None:
446
- value_similarities.append(fuzz.ratio(val1, val2) / 100.0)
447
442
  else:
448
- value_similarities.append(0.0)
443
+ value_similarities.append(_text_ratio(val1, val2))
449
444
 
450
445
  if value_similarities:
451
446
  value_similarity = sum(value_similarities) / len(value_similarities)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.3.30
3
+ Version: 0.4.1
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
@@ -28,9 +28,15 @@ Requires-Dist: rich>=13.0
28
28
  Requires-Dist: pydantic>=2.0
29
29
  Requires-Dist: tomli>=2.0; python_version < "3.11"
30
30
  Provides-Extra: all
31
+ Requires-Dist: redup[fast]; extra == "all"
31
32
  Requires-Dist: redup[fuzzy]; extra == "all"
32
33
  Requires-Dist: redup[ast]; extra == "all"
33
34
  Requires-Dist: redup[lsh]; extra == "all"
35
+ Requires-Dist: redup[semantic]; extra == "all"
36
+ Provides-Extra: fast
37
+ Requires-Dist: xxhash>=3.0; extra == "fast"
38
+ Requires-Dist: libcst>=1.0; extra == "fast"
39
+ Requires-Dist: pybloom-live>=4.0; extra == "fast"
34
40
  Provides-Extra: fuzzy
35
41
  Requires-Dist: rapidfuzz>=3.0; extra == "fuzzy"
36
42
  Provides-Extra: ast
@@ -70,6 +76,8 @@ Requires-Dist: tree-sitter-svelte>=0.20; extra == "ast"
70
76
  Requires-Dist: tree-sitter-vue>=0.20; extra == "ast"
71
77
  Provides-Extra: lsh
72
78
  Requires-Dist: datasketch>=1.6; extra == "lsh"
79
+ Provides-Extra: semantic
80
+ Requires-Dist: sentence-transformers>=2.0; extra == "semantic"
73
81
  Provides-Extra: dev
74
82
  Requires-Dist: pytest>=7.0; extra == "dev"
75
83
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -84,7 +92,7 @@ Dynamic: license-file
84
92
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
85
93
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
86
94
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
87
- [![Version](https://img.shields.io/badge/version-0.3.30-green.svg)](https://pypi.org/project/redup/)
95
+ [![Version](https://img.shields.io/badge/version-0.4.1-green.svg)](https://pypi.org/project/redup/)
88
96
 
89
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.
90
98
 
@@ -29,7 +29,9 @@ src/redup/core/matcher.py
29
29
  src/redup/core/models.py
30
30
  src/redup/core/pipeline.py
31
31
  src/redup/core/planner.py
32
+ src/redup/core/python_parser.py
32
33
  src/redup/core/scanner.py
34
+ src/redup/core/semantic.py
33
35
  src/redup/core/ts_extractor.py
34
36
  src/redup/core/universal_fuzzy.py
35
37
  src/redup/core/utils/__init__.py
@@ -7,9 +7,11 @@ pydantic>=2.0
7
7
  tomli>=2.0
8
8
 
9
9
  [all]
10
+ redup[fast]
10
11
  redup[fuzzy]
11
12
  redup[ast]
12
13
  redup[lsh]
14
+ redup[semantic]
13
15
 
14
16
  [ast]
15
17
  tree-sitter>=0.21
@@ -53,8 +55,16 @@ pytest-cov>=4.0
53
55
  ruff>=0.4
54
56
  mypy>=1.8
55
57
 
58
+ [fast]
59
+ xxhash>=3.0
60
+ libcst>=1.0
61
+ pybloom-live>=4.0
62
+
56
63
  [fuzzy]
57
64
  rapidfuzz>=3.0
58
65
 
59
66
  [lsh]
60
67
  datasketch>=1.6
68
+
69
+ [semantic]
70
+ sentence-transformers>=2.0
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