redup 0.3.31__tar.gz → 0.4.2__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.3.31 → redup-0.4.2}/PKG-INFO +10 -2
  2. {redup-0.3.31 → redup-0.4.2}/README.md +1 -1
  3. {redup-0.3.31 → redup-0.4.2}/pyproject.toml +6 -1
  4. {redup-0.3.31 → redup-0.4.2}/src/redup/__init__.py +1 -1
  5. {redup-0.3.31 → redup-0.4.2}/src/redup/core/fuzzy_similarity.py +16 -8
  6. {redup-0.3.31 → redup-0.4.2}/src/redup/core/hasher.py +70 -3
  7. {redup-0.3.31 → redup-0.4.2}/src/redup/core/models.py +1 -0
  8. {redup-0.3.31 → redup-0.4.2}/src/redup/core/pipeline.py +46 -0
  9. redup-0.4.2/src/redup/core/python_parser.py +170 -0
  10. {redup-0.3.31 → redup-0.4.2}/src/redup/core/scanner.py +20 -46
  11. redup-0.4.2/src/redup/core/semantic.py +174 -0
  12. {redup-0.3.31 → redup-0.4.2}/src/redup/core/universal_fuzzy.py +3 -8
  13. redup-0.4.2/src/redup/core/utils/diff_helpers.py +162 -0
  14. redup-0.4.2/src/redup/mcp_server.py +675 -0
  15. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/PKG-INFO +10 -2
  16. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/SOURCES.txt +5 -0
  17. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/entry_points.txt +1 -0
  18. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/requires.txt +10 -0
  19. redup-0.4.2/tests/test_mcp_server.py +199 -0
  20. {redup-0.3.31 → redup-0.4.2}/LICENSE +0 -0
  21. {redup-0.3.31 → redup-0.4.2}/setup.cfg +0 -0
  22. {redup-0.3.31 → redup-0.4.2}/src/redup/__main__.py +0 -0
  23. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/__init__.py +0 -0
  24. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/config_builder.py +0 -0
  25. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/fuzzy_similarity.py +0 -0
  26. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/main.py +0 -0
  27. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/output_writer.py +0 -0
  28. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/scan_commands.py +0 -0
  29. {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/scan_helpers.py +0 -0
  30. {redup-0.3.31 → redup-0.4.2}/src/redup/core/__init__.py +0 -0
  31. {redup-0.3.31 → redup-0.4.2}/src/redup/core/cache.py +0 -0
  32. {redup-0.3.31 → redup-0.4.2}/src/redup/core/config.py +0 -0
  33. {redup-0.3.31 → redup-0.4.2}/src/redup/core/differ.py +0 -0
  34. {redup-0.3.31 → redup-0.4.2}/src/redup/core/hash_cache.py +0 -0
  35. {redup-0.3.31 → redup-0.4.2}/src/redup/core/lazy_grouper.py +0 -0
  36. {redup-0.3.31 → redup-0.4.2}/src/redup/core/lsh_matcher.py +0 -0
  37. {redup-0.3.31 → redup-0.4.2}/src/redup/core/matcher.py +0 -0
  38. {redup-0.3.31 → redup-0.4.2}/src/redup/core/planner.py +0 -0
  39. {redup-0.3.31 → redup-0.4.2}/src/redup/core/ts_extractor.py +0 -0
  40. {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/__init__.py +0 -0
  41. {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/duplicate_finders.py +0 -0
  42. {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/function_extractor.py +0 -0
  43. {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/hash_utils.py +0 -0
  44. {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/language_dispatcher.py +0 -0
  45. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/__init__.py +0 -0
  46. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/code2llm_reporter.py +0 -0
  47. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/enhanced_reporter.py +0 -0
  48. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/json_reporter.py +0 -0
  49. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/markdown_reporter.py +0 -0
  50. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/toon_reporter.py +0 -0
  51. {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/yaml_reporter.py +0 -0
  52. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/dependency_links.txt +0 -0
  53. {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/top_level.txt +0 -0
  54. {redup-0.3.31 → redup-0.4.2}/tests/test_e2e.py +0 -0
  55. {redup-0.3.31 → redup-0.4.2}/tests/test_hasher.py +0 -0
  56. {redup-0.3.31 → redup-0.4.2}/tests/test_matcher.py +0 -0
  57. {redup-0.3.31 → redup-0.4.2}/tests/test_models.py +0 -0
  58. {redup-0.3.31 → redup-0.4.2}/tests/test_pipeline.py +0 -0
  59. {redup-0.3.31 → redup-0.4.2}/tests/test_planner.py +0 -0
  60. {redup-0.3.31 → redup-0.4.2}/tests/test_reporters.py +0 -0
  61. {redup-0.3.31 → redup-0.4.2}/tests/test_scanner.py +0 -0
  62. {redup-0.3.31 → redup-0.4.2}/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.31
3
+ Version: 0.4.2
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.31-green.svg)](https://pypi.org/project/redup/)
95
+ [![Version](https://img.shields.io/badge/version-0.4.2-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.31-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.4.2-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.31"
7
+ version = "0.4.2"
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",
@@ -86,6 +90,7 @@ dev = [
86
90
 
87
91
  [project.scripts]
88
92
  redup = "redup.cli_app.main:app"
93
+ redup-mcp = "redup.mcp_server:run_server"
89
94
 
90
95
  [project.urls]
91
96
  Homepage = "https://github.com/semcod/redup"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.31"
5
+ __version__ = "0.4.2"
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):
@@ -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
+ ]
@@ -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)
@@ -485,49 +497,11 @@ def _should_process_file(
485
497
 
486
498
 
487
499
  def _extract_function_blocks_python(content: str, file_path: str) -> list[CodeBlock]:
488
- """Extract function blocks from Python code using AST."""
489
- import ast
490
-
491
- blocks = []
492
- lines = content.splitlines()
500
+ """Extract function blocks from Python code using libcst (fast) with ast fallback."""
501
+ from redup.core.python_parser import parse_python_functions, parsed_to_code_blocks
493
502
 
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
503
+ parsed = parse_python_functions(content, file_path)
504
+ return parsed_to_code_blocks(parsed, file_path)
531
505
 
532
506
 
533
507
  def _extract_sliding_blocks(content: str, file_path: str) -> list[CodeBlock]: