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.
- {redup-0.3.31 → redup-0.4.2}/PKG-INFO +10 -2
- {redup-0.3.31 → redup-0.4.2}/README.md +1 -1
- {redup-0.3.31 → redup-0.4.2}/pyproject.toml +6 -1
- {redup-0.3.31 → redup-0.4.2}/src/redup/__init__.py +1 -1
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/fuzzy_similarity.py +16 -8
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/hasher.py +70 -3
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/models.py +1 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/pipeline.py +46 -0
- redup-0.4.2/src/redup/core/python_parser.py +170 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/scanner.py +20 -46
- redup-0.4.2/src/redup/core/semantic.py +174 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/universal_fuzzy.py +3 -8
- redup-0.4.2/src/redup/core/utils/diff_helpers.py +162 -0
- redup-0.4.2/src/redup/mcp_server.py +675 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/PKG-INFO +10 -2
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/SOURCES.txt +5 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/entry_points.txt +1 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/requires.txt +10 -0
- redup-0.4.2/tests/test_mcp_server.py +199 -0
- {redup-0.3.31 → redup-0.4.2}/LICENSE +0 -0
- {redup-0.3.31 → redup-0.4.2}/setup.cfg +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/__main__.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/config_builder.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/fuzzy_similarity.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/main.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/output_writer.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/scan_commands.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/cli_app/scan_helpers.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/__init__.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/cache.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/config.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/differ.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/hash_cache.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/lazy_grouper.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/lsh_matcher.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/matcher.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/planner.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/ts_extractor.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/__init__.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/duplicate_finders.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/function_extractor.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/hash_utils.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/core/utils/language_dispatcher.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/__init__.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/code2llm_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/enhanced_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/markdown_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.3.31 → redup-0.4.2}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_e2e.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_hasher.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_matcher.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_models.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_pipeline.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_planner.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_reporters.py +0 -0
- {redup-0.3.31 → redup-0.4.2}/tests/test_scanner.py +0 -0
- {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
|
+
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
|
[](https://pypi.org/project/redup/)
|
|
85
93
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
86
94
|
[](https://python.org)
|
|
87
|
-
[](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
|
[](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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
361
|
-
text_similarity =
|
|
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(
|
|
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
|
|
202
|
+
"""Generic hash function with configurable normalizer - uses fast xxhash or SHA-256 fallback."""
|
|
187
203
|
normalized = normalizer(text)
|
|
188
|
-
#
|
|
189
|
-
return
|
|
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
|
|
|
@@ -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(
|
|
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)
|
|
@@ -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
|
|
489
|
-
import
|
|
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
|
-
|
|
495
|
-
|
|
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]:
|