redup 0.3.1__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redup-0.3.1 → redup-0.3.3}/PKG-INFO +50 -2
- {redup-0.3.1 → redup-0.3.3}/README.md +49 -1
- {redup-0.3.1 → redup-0.3.3}/pyproject.toml +1 -1
- {redup-0.3.1 → redup-0.3.3}/src/redup/__init__.py +1 -1
- {redup-0.3.1 → redup-0.3.3}/src/redup/cli_app/main.py +66 -43
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/config.py +3 -3
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/hasher.py +18 -14
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/parallel_scanner.py +76 -27
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/pipeline.py +32 -15
- redup-0.3.3/src/redup/core/ts_extractor.py +757 -0
- redup-0.3.3/src/redup/core/utils/__init__.py +1 -0
- redup-0.3.3/src/redup/core/utils/duplicate_finders.py +36 -0
- redup-0.3.3/src/redup/core/utils/function_extractor.py +143 -0
- redup-0.3.3/src/redup/core/utils/hash_utils.py +37 -0
- redup-0.3.3/src/redup/reporters/enhanced_reporter.py +274 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/PKG-INFO +50 -2
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/SOURCES.txt +5 -0
- redup-0.3.1/src/redup/core/ts_extractor.py +0 -353
- {redup-0.3.1 → redup-0.3.3}/LICENSE +0 -0
- {redup-0.3.1 → redup-0.3.3}/setup.cfg +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/__main__.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/__init__.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/differ.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/lsh_matcher.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/matcher.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/models.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/planner.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/core/scanner.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/__init__.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/markdown_reporter.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/entry_points.txt +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/requires.txt +0 -0
- {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_e2e.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_hasher.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_matcher.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_models.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_pipeline.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_planner.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_reporters.py +0 -0
- {redup-0.3.1 → redup-0.3.3}/tests/test_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redup
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -75,7 +75,7 @@ Dynamic: license-file
|
|
|
75
75
|
[](https://pypi.org/project/redup/)
|
|
76
76
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
77
77
|
[](https://python.org)
|
|
78
|
-
[](https://pypi.org/project/redup/)
|
|
79
79
|
|
|
80
80
|
reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
|
|
81
81
|
|
|
@@ -122,6 +122,23 @@ redup scan .
|
|
|
122
122
|
# Scan with JSON output saved to file
|
|
123
123
|
redup scan ./src --format json --output ./reports/
|
|
124
124
|
|
|
125
|
+
# Parallel scanning for large projects
|
|
126
|
+
redup scan . --parallel --max-workers 4
|
|
127
|
+
|
|
128
|
+
# Multi-language scanning
|
|
129
|
+
redup scan . --ext ".py,.js,.ts,.go"
|
|
130
|
+
|
|
131
|
+
# CI gate with thresholds
|
|
132
|
+
redup check . --max-groups 10 --max-lines 100
|
|
133
|
+
|
|
134
|
+
# Compare two scans
|
|
135
|
+
redup diff before.json after.json
|
|
136
|
+
|
|
137
|
+
# Initialize configuration
|
|
138
|
+
redup config --init
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```bash
|
|
125
142
|
# Scan with all formats
|
|
126
143
|
redup scan . --format all --output ./redup_output/
|
|
127
144
|
|
|
@@ -135,6 +152,37 @@ redup scan . --min-lines 5 --min-sim 0.9
|
|
|
135
152
|
redup info
|
|
136
153
|
```
|
|
137
154
|
|
|
155
|
+
### Configuration
|
|
156
|
+
|
|
157
|
+
Create a `redup.toml` file:
|
|
158
|
+
|
|
159
|
+
```toml
|
|
160
|
+
[scan]
|
|
161
|
+
extensions = ".py,.js,.ts,.go,.rs,.java"
|
|
162
|
+
min_lines = 3
|
|
163
|
+
min_similarity = 0.85
|
|
164
|
+
include_tests = false
|
|
165
|
+
|
|
166
|
+
[lsh]
|
|
167
|
+
enabled = true
|
|
168
|
+
min_lines = 50
|
|
169
|
+
threshold = 0.8
|
|
170
|
+
|
|
171
|
+
[check]
|
|
172
|
+
max_groups = 10
|
|
173
|
+
max_lines = 100
|
|
174
|
+
|
|
175
|
+
[output]
|
|
176
|
+
format = "toon"
|
|
177
|
+
output = "redup_output"
|
|
178
|
+
|
|
179
|
+
[reporting]
|
|
180
|
+
include_snippets = true
|
|
181
|
+
generate_suggestions = true
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Or use `[tool.redup]` in `pyproject.toml`. Environment variables with `REDUP_` prefix override file settings.
|
|
185
|
+
|
|
138
186
|
### Python API
|
|
139
187
|
|
|
140
188
|
```python
|
|
@@ -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
|
|
|
@@ -52,6 +52,23 @@ redup scan .
|
|
|
52
52
|
# Scan with JSON output saved to file
|
|
53
53
|
redup scan ./src --format json --output ./reports/
|
|
54
54
|
|
|
55
|
+
# Parallel scanning for large projects
|
|
56
|
+
redup scan . --parallel --max-workers 4
|
|
57
|
+
|
|
58
|
+
# Multi-language scanning
|
|
59
|
+
redup scan . --ext ".py,.js,.ts,.go"
|
|
60
|
+
|
|
61
|
+
# CI gate with thresholds
|
|
62
|
+
redup check . --max-groups 10 --max-lines 100
|
|
63
|
+
|
|
64
|
+
# Compare two scans
|
|
65
|
+
redup diff before.json after.json
|
|
66
|
+
|
|
67
|
+
# Initialize configuration
|
|
68
|
+
redup config --init
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```bash
|
|
55
72
|
# Scan with all formats
|
|
56
73
|
redup scan . --format all --output ./redup_output/
|
|
57
74
|
|
|
@@ -65,6 +82,37 @@ redup scan . --min-lines 5 --min-sim 0.9
|
|
|
65
82
|
redup info
|
|
66
83
|
```
|
|
67
84
|
|
|
85
|
+
### Configuration
|
|
86
|
+
|
|
87
|
+
Create a `redup.toml` file:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[scan]
|
|
91
|
+
extensions = ".py,.js,.ts,.go,.rs,.java"
|
|
92
|
+
min_lines = 3
|
|
93
|
+
min_similarity = 0.85
|
|
94
|
+
include_tests = false
|
|
95
|
+
|
|
96
|
+
[lsh]
|
|
97
|
+
enabled = true
|
|
98
|
+
min_lines = 50
|
|
99
|
+
threshold = 0.8
|
|
100
|
+
|
|
101
|
+
[check]
|
|
102
|
+
max_groups = 10
|
|
103
|
+
max_lines = 100
|
|
104
|
+
|
|
105
|
+
[output]
|
|
106
|
+
format = "toon"
|
|
107
|
+
output = "redup_output"
|
|
108
|
+
|
|
109
|
+
[reporting]
|
|
110
|
+
include_snippets = true
|
|
111
|
+
generate_suggestions = true
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or use `[tool.redup]` in `pyproject.toml`. Environment variables with `REDUP_` prefix override file settings.
|
|
115
|
+
|
|
68
116
|
### Python API
|
|
69
117
|
|
|
70
118
|
```python
|
|
@@ -20,6 +20,7 @@ from redup.reporters.json_reporter import to_json # noqa: E402
|
|
|
20
20
|
from redup.reporters.markdown_reporter import to_markdown # noqa: E402
|
|
21
21
|
from redup.reporters.toon_reporter import to_toon # noqa: E402
|
|
22
22
|
from redup.reporters.yaml_reporter import to_yaml # noqa: E402
|
|
23
|
+
from redup.reporters.enhanced_reporter import EnhancedReporter # noqa: E402
|
|
23
24
|
|
|
24
25
|
app = typer.Typer(
|
|
25
26
|
name="redup",
|
|
@@ -28,7 +29,7 @@ app = typer.Typer(
|
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
OutputFormat = Literal["json", "yaml", "toon", "all"]
|
|
32
|
+
OutputFormat = Literal["json", "yaml", "toon", "markdown", "all", "enhanced"]
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
DEFAULT_PATH = Path(".")
|
|
@@ -151,6 +152,11 @@ def _write_results(
|
|
|
151
152
|
_write_output(to_toon(dup_map), output_dir, "toon")
|
|
152
153
|
elif format == "markdown":
|
|
153
154
|
_write_output(to_markdown(dup_map), output_dir, "md")
|
|
155
|
+
elif format == "enhanced":
|
|
156
|
+
reporter = EnhancedReporter(dup_map)
|
|
157
|
+
target = output_dir if output_dir and output_dir.suffix else output_dir / "duplication.enhanced.json"
|
|
158
|
+
reporter.save_enhanced_report(target)
|
|
159
|
+
typer.echo(f" → {target}")
|
|
154
160
|
|
|
155
161
|
|
|
156
162
|
@app.command()
|
|
@@ -165,32 +171,32 @@ def scan(
|
|
|
165
171
|
format: str = typer.Option(
|
|
166
172
|
"toon",
|
|
167
173
|
"--format", "-f",
|
|
168
|
-
help="Output format (json, yaml, toon, markdown, all).",
|
|
174
|
+
help="Output format (json, yaml, toon, markdown, all, enhanced).",
|
|
169
175
|
),
|
|
170
176
|
output: Path | None = typer.Option(
|
|
171
177
|
DEFAULT_OUTPUT,
|
|
172
178
|
"--output", "-o",
|
|
173
179
|
help="Output directory or file path. Defaults to stdout.",
|
|
174
180
|
),
|
|
175
|
-
extensions: str = typer.Option(
|
|
176
|
-
|
|
181
|
+
extensions: str | None = typer.Option(
|
|
182
|
+
None,
|
|
177
183
|
"--ext", "-e",
|
|
178
|
-
help="Comma-separated file extensions to scan.",
|
|
184
|
+
help="Comma-separated file extensions to scan. Overrides config.",
|
|
179
185
|
),
|
|
180
|
-
min_lines: int = typer.Option(
|
|
181
|
-
|
|
186
|
+
min_lines: int | None = typer.Option(
|
|
187
|
+
None,
|
|
182
188
|
"--min-lines",
|
|
183
|
-
help="Minimum block size (lines)
|
|
189
|
+
help="Minimum block size (lines). Overrides config.",
|
|
184
190
|
),
|
|
185
|
-
min_similarity: float = typer.Option(
|
|
186
|
-
|
|
191
|
+
min_similarity: float | None = typer.Option(
|
|
192
|
+
None,
|
|
187
193
|
"--min-sim",
|
|
188
|
-
help="Minimum similarity score (0.0-1.0)
|
|
194
|
+
help="Minimum similarity score (0.0-1.0). Overrides config.",
|
|
189
195
|
),
|
|
190
|
-
include_tests: bool = typer.Option(
|
|
191
|
-
|
|
196
|
+
include_tests: bool | None = typer.Option(
|
|
197
|
+
None,
|
|
192
198
|
"--include-tests",
|
|
193
|
-
help="Include test files
|
|
199
|
+
help="Include test files. Overrides config.",
|
|
194
200
|
),
|
|
195
201
|
functions_only: bool = typer.Option(
|
|
196
202
|
False,
|
|
@@ -209,9 +215,9 @@ def scan(
|
|
|
209
215
|
),
|
|
210
216
|
) -> None:
|
|
211
217
|
"""Scan a project for code duplicates and generate a refactoring map."""
|
|
212
|
-
config =
|
|
218
|
+
config = _build_config_with_file_support(path, extensions, min_lines, min_similarity, include_tests)
|
|
213
219
|
|
|
214
|
-
_print_scan_header(path, config.extensions,
|
|
220
|
+
_print_scan_header(path, config.extensions, config.min_block_lines, config.min_similarity)
|
|
215
221
|
|
|
216
222
|
# Choose analysis method
|
|
217
223
|
if parallel:
|
|
@@ -270,61 +276,78 @@ def check(
|
|
|
270
276
|
dir_okay=True,
|
|
271
277
|
file_okay=False,
|
|
272
278
|
),
|
|
273
|
-
max_groups: int = typer.Option(
|
|
274
|
-
|
|
279
|
+
max_groups: int | None = typer.Option(
|
|
280
|
+
None,
|
|
275
281
|
"--max-groups",
|
|
276
|
-
help="Maximum allowed duplicate groups
|
|
282
|
+
help="Maximum allowed duplicate groups. Overrides config.",
|
|
277
283
|
),
|
|
278
|
-
max_saved_lines: int = typer.Option(
|
|
279
|
-
|
|
284
|
+
max_saved_lines: int | None = typer.Option(
|
|
285
|
+
None,
|
|
280
286
|
"--max-lines",
|
|
281
|
-
help="Maximum allowed recoverable lines
|
|
287
|
+
help="Maximum allowed recoverable lines. Overrides config.",
|
|
282
288
|
),
|
|
283
|
-
extensions: str = typer.Option(
|
|
284
|
-
|
|
289
|
+
extensions: str | None = typer.Option(
|
|
290
|
+
None,
|
|
285
291
|
"--ext", "-e",
|
|
286
|
-
help="Comma-separated file extensions to scan.",
|
|
292
|
+
help="Comma-separated file extensions to scan. Overrides config.",
|
|
287
293
|
),
|
|
288
|
-
min_lines: int = typer.Option(
|
|
289
|
-
|
|
294
|
+
min_lines: int | None = typer.Option(
|
|
295
|
+
None,
|
|
290
296
|
"--min-lines",
|
|
291
|
-
help="Minimum block size (lines)
|
|
297
|
+
help="Minimum block size (lines). Overrides config.",
|
|
292
298
|
),
|
|
293
|
-
min_similarity: float = typer.Option(
|
|
294
|
-
|
|
299
|
+
min_similarity: float | None = typer.Option(
|
|
300
|
+
None,
|
|
295
301
|
"--min-sim",
|
|
296
|
-
help="Minimum similarity score (0.0-1.0)
|
|
302
|
+
help="Minimum similarity score (0.0-1.0). Overrides config.",
|
|
297
303
|
),
|
|
298
|
-
include_tests: bool = typer.Option(
|
|
299
|
-
|
|
304
|
+
include_tests: bool | None = typer.Option(
|
|
305
|
+
None,
|
|
300
306
|
"--include-tests",
|
|
301
|
-
help="Include test files
|
|
307
|
+
help="Include test files. Overrides config.",
|
|
302
308
|
),
|
|
303
309
|
) -> None:
|
|
304
310
|
"""Check project for duplicates and exit with non-zero code if thresholds exceeded."""
|
|
305
|
-
|
|
311
|
+
file_config = load_config()
|
|
312
|
+
|
|
313
|
+
# Override with CLI arguments
|
|
314
|
+
if extensions is not None:
|
|
315
|
+
file_config["extensions"] = extensions
|
|
316
|
+
if min_lines is not None:
|
|
317
|
+
file_config["min_lines"] = min_lines
|
|
318
|
+
if min_similarity is not None:
|
|
319
|
+
file_config["min_similarity"] = min_similarity
|
|
320
|
+
if include_tests is not None:
|
|
321
|
+
file_config["include_tests"] = include_tests
|
|
322
|
+
|
|
323
|
+
config = config_to_scan_config(file_config, path)
|
|
324
|
+
|
|
325
|
+
# Get thresholds from config or CLI
|
|
326
|
+
check_config = file_config.get("check", {})
|
|
327
|
+
threshold_groups = max_groups if max_groups is not None else check_config.get("max_groups", 10)
|
|
328
|
+
threshold_lines = max_saved_lines if max_saved_lines is not None else check_config.get("max_lines", 100)
|
|
306
329
|
|
|
307
330
|
dup_map = analyze(config=config, function_level_only=True)
|
|
308
331
|
|
|
309
332
|
# Check thresholds
|
|
310
|
-
groups_exceeded = dup_map.total_groups >
|
|
311
|
-
lines_exceeded = dup_map.total_saved_lines >
|
|
333
|
+
groups_exceeded = dup_map.total_groups > threshold_groups
|
|
334
|
+
lines_exceeded = dup_map.total_saved_lines > threshold_lines
|
|
312
335
|
|
|
313
336
|
if groups_exceeded or lines_exceeded:
|
|
314
337
|
typer.echo(f"❌ reDUP check FAILED - thresholds exceeded:", err=True)
|
|
315
|
-
typer.echo(f" Duplicate groups: {dup_map.total_groups} (max: {
|
|
316
|
-
typer.echo(f" Recoverable lines: {dup_map.total_saved_lines} (max: {
|
|
338
|
+
typer.echo(f" Duplicate groups: {dup_map.total_groups} (max: {threshold_groups})", err=True)
|
|
339
|
+
typer.echo(f" Recoverable lines: {dup_map.total_saved_lines} (max: {threshold_lines})", err=True)
|
|
317
340
|
|
|
318
341
|
if groups_exceeded:
|
|
319
|
-
typer.echo(f" ❌ Too many duplicate groups ({dup_map.total_groups} > {
|
|
342
|
+
typer.echo(f" ❌ Too many duplicate groups ({dup_map.total_groups} > {threshold_groups})", err=True)
|
|
320
343
|
if lines_exceeded:
|
|
321
|
-
typer.echo(f" ❌ Too many duplicate lines ({dup_map.total_saved_lines} > {
|
|
344
|
+
typer.echo(f" ❌ Too many duplicate lines ({dup_map.total_saved_lines} > {threshold_lines})", err=True)
|
|
322
345
|
|
|
323
346
|
raise typer.Exit(1)
|
|
324
347
|
else:
|
|
325
348
|
typer.echo(f"✅ reDUP check PASSED")
|
|
326
|
-
typer.echo(f" Duplicate groups: {dup_map.total_groups}/{
|
|
327
|
-
typer.echo(f" Recoverable lines: {dup_map.total_saved_lines}/{
|
|
349
|
+
typer.echo(f" Duplicate groups: {dup_map.total_groups}/{threshold_groups}")
|
|
350
|
+
typer.echo(f" Recoverable lines: {dup_map.total_saved_lines}/{threshold_lines}")
|
|
328
351
|
# Don't exit - just return normally (exit code 0)
|
|
329
352
|
return
|
|
330
353
|
|
|
@@ -103,9 +103,9 @@ def config_to_scan_config(config: dict[str, Any], path: Path) -> ScanConfig:
|
|
|
103
103
|
return ScanConfig(
|
|
104
104
|
root=path,
|
|
105
105
|
extensions=ext_list,
|
|
106
|
-
min_block_lines=scan_config.get("min_lines", 3),
|
|
107
|
-
min_similarity=scan_config.get("min_similarity", 0.85),
|
|
108
|
-
include_tests=scan_config.get("include_tests", False),
|
|
106
|
+
min_block_lines=config.get("min_lines", scan_config.get("min_lines", 3)),
|
|
107
|
+
min_similarity=config.get("min_similarity", scan_config.get("min_similarity", 0.85)),
|
|
108
|
+
include_tests=config.get("include_tests", scan_config.get("include_tests", False)),
|
|
109
109
|
lsh_enabled=lsh_config.get("enabled", True),
|
|
110
110
|
lsh_min_lines=lsh_config.get("min_lines", 50),
|
|
111
111
|
lsh_threshold=lsh_config.get("threshold", 0.8),
|
|
@@ -149,14 +149,28 @@ def _hash_text(text: str, normalizer: Callable[[str], str]) -> str:
|
|
|
149
149
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def hash_block(text: str) -> str:
|
|
152
|
+
def hash_block(text: str) -> str:
|
|
153
153
|
"""SHA-256 hash of normalized text."""
|
|
154
|
-
|
|
154
|
+
from .utils.hash_utils import hash_block as _hash_block
|
|
155
|
+
return _hash_block(text)
|
|
155
156
|
|
|
156
157
|
|
|
157
|
-
def hash_block_structural(text: str) -> str:
|
|
158
|
+
def hash_block_structural(text: str) -> str:
|
|
158
159
|
"""SHA-256 hash of deeply normalized text (variable names replaced)."""
|
|
159
|
-
|
|
160
|
+
from .utils.hash_utils import hash_block_structural as _hash_block_structural
|
|
161
|
+
return _hash_block_structural(text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
|
|
165
|
+
"""Find groups of blocks with identical normalized text."""
|
|
166
|
+
from .utils.duplicate_finders import find_exact_duplicates as _find_exact_duplicates
|
|
167
|
+
return _find_exact_duplicates(index)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
|
|
171
|
+
"""Find groups of blocks with identical structure (names may differ)."""
|
|
172
|
+
from .utils.duplicate_finders import find_structural_duplicates as _find_structural_duplicates
|
|
173
|
+
return _find_structural_duplicates(index)
|
|
160
174
|
|
|
161
175
|
|
|
162
176
|
def _hashed_block(block: CodeBlock) -> HashedBlock:
|
|
@@ -213,16 +227,6 @@ def _find_duplicates(hash_dict: dict[str, list[HashedBlock]]) -> dict[str, list[
|
|
|
213
227
|
}
|
|
214
228
|
|
|
215
229
|
|
|
216
|
-
def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
|
|
217
|
-
"""Find groups of blocks with identical normalized text."""
|
|
218
|
-
return _find_duplicates(index.exact)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
|
|
222
|
-
"""Find groups of blocks with identical structure (names may differ)."""
|
|
223
|
-
return _find_duplicates(index.structural)
|
|
224
|
-
|
|
225
|
-
|
|
226
230
|
def _blocks_from_different_locations(blocks: list[HashedBlock]) -> bool:
|
|
227
231
|
"""Check that at least two blocks are from different file:line locations."""
|
|
228
232
|
locations = {(b.block.file, b.block.line_start) for b in blocks}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
6
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
|
+
import time
|
|
10
|
+
from functools import lru_cache
|
|
9
11
|
|
|
10
12
|
from redup.core.scanner import CodeBlock, ScannedFile, scan_project
|
|
11
13
|
from redup.core.ts_extractor import extract_functions_treesitter
|
|
@@ -73,8 +75,11 @@ def _should_process_file(
|
|
|
73
75
|
return False
|
|
74
76
|
|
|
75
77
|
# Check if test file (and whether to include)
|
|
76
|
-
if not include_tests
|
|
77
|
-
|
|
78
|
+
if not include_tests:
|
|
79
|
+
relative_path = file_path.relative_to(project_root)
|
|
80
|
+
test_parts = frozenset(('test', 'tests', 'testing'))
|
|
81
|
+
if _is_test_file_cached(str(relative_path), test_parts):
|
|
82
|
+
return False
|
|
78
83
|
|
|
79
84
|
return True
|
|
80
85
|
|
|
@@ -105,7 +110,8 @@ def scan_project_parallel(
|
|
|
105
110
|
function_level_only: bool = False,
|
|
106
111
|
max_file_size: int = 1024,
|
|
107
112
|
max_workers: int | None = None,
|
|
108
|
-
min_files_for_parallel: int = 10
|
|
113
|
+
min_files_for_parallel: int = 10,
|
|
114
|
+
use_threading: bool = False
|
|
109
115
|
) -> tuple[list[ScannedFile], Any]:
|
|
110
116
|
"""Scan project files in parallel for better performance on large projects."""
|
|
111
117
|
from redup.core.models import ScanStats
|
|
@@ -113,27 +119,8 @@ def scan_project_parallel(
|
|
|
113
119
|
if max_workers is None:
|
|
114
120
|
max_workers = min(os.cpu_count() or 4, 8) # Cap at 8 workers
|
|
115
121
|
|
|
116
|
-
# Find all files to scan
|
|
117
|
-
all_files = []
|
|
118
|
-
exclude_patterns = exclude_patterns or []
|
|
119
|
-
|
|
120
|
-
for file_path in root.rglob('*'):
|
|
121
|
-
if not file_path.is_file():
|
|
122
|
-
continue
|
|
123
|
-
|
|
124
|
-
# Check exclude patterns
|
|
125
|
-
relative_path = file_path.relative_to(root)
|
|
126
|
-
should_exclude = False
|
|
127
|
-
|
|
128
|
-
for pattern in exclude_patterns:
|
|
129
|
-
if pattern in str(relative_path) or relative_path.match(pattern):
|
|
130
|
-
should_exclude = True
|
|
131
|
-
break
|
|
132
|
-
|
|
133
|
-
if should_exclude:
|
|
134
|
-
continue
|
|
135
|
-
|
|
136
|
-
all_files.append(file_path)
|
|
122
|
+
# Find all files to scan (optimized)
|
|
123
|
+
all_files = _find_files_optimized(root, extensions, exclude_patterns or [])
|
|
137
124
|
|
|
138
125
|
# Use parallel scanning only if we have enough files
|
|
139
126
|
if len(all_files) < min_files_for_parallel:
|
|
@@ -147,11 +134,14 @@ def scan_project_parallel(
|
|
|
147
134
|
]
|
|
148
135
|
|
|
149
136
|
# Scan files in parallel
|
|
137
|
+
start_time = time.time()
|
|
150
138
|
scanned_files = []
|
|
151
139
|
total_blocks = 0
|
|
152
140
|
total_lines = 0
|
|
153
141
|
|
|
154
|
-
|
|
142
|
+
executor_class = ThreadPoolExecutor if use_threading else ProcessPoolExecutor
|
|
143
|
+
|
|
144
|
+
with executor_class(max_workers=max_workers) as executor:
|
|
155
145
|
# Submit all jobs
|
|
156
146
|
future_to_file = {
|
|
157
147
|
executor.submit(_scan_file_worker, work_item): work_item[0]
|
|
@@ -174,12 +164,71 @@ def scan_project_parallel(
|
|
|
174
164
|
file_path = future_to_file[future]
|
|
175
165
|
print(f"Warning: Failed to scan {file_path}")
|
|
176
166
|
|
|
167
|
+
scan_time = (time.time() - start_time) * 1000
|
|
168
|
+
|
|
177
169
|
# Create stats
|
|
178
170
|
stats = ScanStats(
|
|
179
171
|
files_scanned=len(scanned_files),
|
|
180
172
|
total_lines=total_lines,
|
|
181
173
|
total_blocks=total_blocks,
|
|
182
|
-
scan_time_ms=
|
|
174
|
+
scan_time_ms=scan_time,
|
|
183
175
|
)
|
|
184
176
|
|
|
185
177
|
return scanned_files, stats
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_files_optimized(
|
|
181
|
+
root: Path,
|
|
182
|
+
extensions: set[str],
|
|
183
|
+
exclude_patterns: list[str]
|
|
184
|
+
) -> list[Path]:
|
|
185
|
+
"""Optimized file discovery using set lookups and compiled patterns."""
|
|
186
|
+
extensions_set = set(extensions) if not isinstance(extensions, set) else extensions
|
|
187
|
+
|
|
188
|
+
# Pre-compile exclude patterns for better performance
|
|
189
|
+
import fnmatch
|
|
190
|
+
compiled_patterns = [fnmatch.translate(pattern) for pattern in exclude_patterns]
|
|
191
|
+
import re
|
|
192
|
+
regex_patterns = [re.compile(pattern) for pattern in compiled_patterns]
|
|
193
|
+
|
|
194
|
+
all_files = []
|
|
195
|
+
|
|
196
|
+
for file_path in root.rglob('*'):
|
|
197
|
+
if not file_path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Fast extension check using set
|
|
201
|
+
if file_path.suffix not in extensions_set:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Check exclude patterns
|
|
205
|
+
relative_path = file_path.relative_to(root)
|
|
206
|
+
should_exclude = False
|
|
207
|
+
|
|
208
|
+
for regex_pattern in regex_patterns:
|
|
209
|
+
if regex_pattern.match(str(relative_path)):
|
|
210
|
+
should_exclude = True
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
if not should_exclude:
|
|
214
|
+
all_files.append(file_path)
|
|
215
|
+
|
|
216
|
+
return all_files
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@lru_cache(maxsize=1024)
|
|
220
|
+
def _is_test_file_cached(relative_path: str, test_parts: frozenset) -> bool:
|
|
221
|
+
"""Cached version of test file detection."""
|
|
222
|
+
parts = Path(relative_path).parts
|
|
223
|
+
|
|
224
|
+
# Check if in test directory
|
|
225
|
+
for part in parts:
|
|
226
|
+
if part in test_parts:
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
# Check filename patterns
|
|
230
|
+
filename = Path(relative_path).name
|
|
231
|
+
if filename.startswith('test_') or filename.endswith('_test.py'):
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
return False
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
import itertools
|
|
9
|
+
|
|
5
10
|
from redup.core.hasher import (
|
|
6
11
|
HashedBlock,
|
|
7
12
|
HashIndex,
|
|
@@ -161,13 +166,19 @@ def _process_blocks(
|
|
|
161
166
|
scanned_files: list[ScannedFile],
|
|
162
167
|
function_level_only: bool
|
|
163
168
|
) -> list[CodeBlock]:
|
|
164
|
-
"""Phase 2: Extract and filter code blocks."""
|
|
169
|
+
"""Phase 2: Extract and filter code blocks with memory optimization."""
|
|
165
170
|
all_blocks: list[CodeBlock] = []
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
|
|
172
|
+
# Batch process files to reduce memory overhead
|
|
173
|
+
batch_size = 100
|
|
174
|
+
for i in range(0, len(scanned_files), batch_size):
|
|
175
|
+
batch = scanned_files[i:i + batch_size]
|
|
176
|
+
for sf in batch:
|
|
177
|
+
for block in sf.blocks:
|
|
178
|
+
if function_level_only and block.function_name is None:
|
|
179
|
+
continue
|
|
180
|
+
all_blocks.append(block)
|
|
181
|
+
|
|
171
182
|
return all_blocks
|
|
172
183
|
|
|
173
184
|
|
|
@@ -175,19 +186,25 @@ def _find_duplicates_phase(
|
|
|
175
186
|
all_blocks: list[CodeBlock],
|
|
176
187
|
config: ScanConfig
|
|
177
188
|
) -> list[DuplicateGroup]:
|
|
178
|
-
"""Phase 3: Hash and find duplicate groups."""
|
|
189
|
+
"""Phase 3: Hash and find duplicate groups with performance optimizations."""
|
|
190
|
+
start_time = time.time()
|
|
191
|
+
|
|
192
|
+
# Build hash index with progress tracking
|
|
179
193
|
index = build_hash_index(all_blocks, min_lines=config.min_block_lines)
|
|
180
|
-
|
|
181
|
-
# Find
|
|
194
|
+
|
|
195
|
+
# Find duplicates in parallel where possible
|
|
182
196
|
exact_groups = _find_exact_groups(index)
|
|
183
|
-
|
|
184
|
-
# Find structural duplicates
|
|
185
197
|
structural_groups = _find_structural_groups(index, exact_groups)
|
|
186
|
-
|
|
187
|
-
# Find near-duplicates using LSH (for larger blocks)
|
|
188
198
|
near_duplicate_groups = _find_near_duplicate_groups(all_blocks, config)
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
|
|
200
|
+
# Combine and sort by impact
|
|
201
|
+
all_groups = exact_groups + structural_groups + near_duplicate_groups
|
|
202
|
+
all_groups.sort(key=lambda g: g.impact_score, reverse=True)
|
|
203
|
+
|
|
204
|
+
processing_time = (time.time() - start_time) * 1000
|
|
205
|
+
print(f"Duplicate finding completed in {processing_time:.1f}ms")
|
|
206
|
+
|
|
207
|
+
return all_groups
|
|
191
208
|
|
|
192
209
|
|
|
193
210
|
def _find_exact_groups(index: HashIndex) -> list[DuplicateGroup]:
|