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.
Files changed (46) hide show
  1. {redup-0.3.1 → redup-0.3.3}/PKG-INFO +50 -2
  2. {redup-0.3.1 → redup-0.3.3}/README.md +49 -1
  3. {redup-0.3.1 → redup-0.3.3}/pyproject.toml +1 -1
  4. {redup-0.3.1 → redup-0.3.3}/src/redup/__init__.py +1 -1
  5. {redup-0.3.1 → redup-0.3.3}/src/redup/cli_app/main.py +66 -43
  6. {redup-0.3.1 → redup-0.3.3}/src/redup/core/config.py +3 -3
  7. {redup-0.3.1 → redup-0.3.3}/src/redup/core/hasher.py +18 -14
  8. {redup-0.3.1 → redup-0.3.3}/src/redup/core/parallel_scanner.py +76 -27
  9. {redup-0.3.1 → redup-0.3.3}/src/redup/core/pipeline.py +32 -15
  10. redup-0.3.3/src/redup/core/ts_extractor.py +757 -0
  11. redup-0.3.3/src/redup/core/utils/__init__.py +1 -0
  12. redup-0.3.3/src/redup/core/utils/duplicate_finders.py +36 -0
  13. redup-0.3.3/src/redup/core/utils/function_extractor.py +143 -0
  14. redup-0.3.3/src/redup/core/utils/hash_utils.py +37 -0
  15. redup-0.3.3/src/redup/reporters/enhanced_reporter.py +274 -0
  16. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/PKG-INFO +50 -2
  17. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/SOURCES.txt +5 -0
  18. redup-0.3.1/src/redup/core/ts_extractor.py +0 -353
  19. {redup-0.3.1 → redup-0.3.3}/LICENSE +0 -0
  20. {redup-0.3.1 → redup-0.3.3}/setup.cfg +0 -0
  21. {redup-0.3.1 → redup-0.3.3}/src/redup/__main__.py +0 -0
  22. {redup-0.3.1 → redup-0.3.3}/src/redup/cli_app/__init__.py +0 -0
  23. {redup-0.3.1 → redup-0.3.3}/src/redup/core/__init__.py +0 -0
  24. {redup-0.3.1 → redup-0.3.3}/src/redup/core/differ.py +0 -0
  25. {redup-0.3.1 → redup-0.3.3}/src/redup/core/lsh_matcher.py +0 -0
  26. {redup-0.3.1 → redup-0.3.3}/src/redup/core/matcher.py +0 -0
  27. {redup-0.3.1 → redup-0.3.3}/src/redup/core/models.py +0 -0
  28. {redup-0.3.1 → redup-0.3.3}/src/redup/core/planner.py +0 -0
  29. {redup-0.3.1 → redup-0.3.3}/src/redup/core/scanner.py +0 -0
  30. {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/__init__.py +0 -0
  31. {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/json_reporter.py +0 -0
  32. {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/markdown_reporter.py +0 -0
  33. {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/toon_reporter.py +0 -0
  34. {redup-0.3.1 → redup-0.3.3}/src/redup/reporters/yaml_reporter.py +0 -0
  35. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/dependency_links.txt +0 -0
  36. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/entry_points.txt +0 -0
  37. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/requires.txt +0 -0
  38. {redup-0.3.1 → redup-0.3.3}/src/redup.egg-info/top_level.txt +0 -0
  39. {redup-0.3.1 → redup-0.3.3}/tests/test_e2e.py +0 -0
  40. {redup-0.3.1 → redup-0.3.3}/tests/test_hasher.py +0 -0
  41. {redup-0.3.1 → redup-0.3.3}/tests/test_matcher.py +0 -0
  42. {redup-0.3.1 → redup-0.3.3}/tests/test_models.py +0 -0
  43. {redup-0.3.1 → redup-0.3.3}/tests/test_pipeline.py +0 -0
  44. {redup-0.3.1 → redup-0.3.3}/tests/test_planner.py +0 -0
  45. {redup-0.3.1 → redup-0.3.3}/tests/test_reporters.py +0 -0
  46. {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.1
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
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
76
76
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
77
77
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
78
- [![Version](https://img.shields.io/badge/version-0.3.1-green.svg)](https://pypi.org/project/redup/)
78
+ [![Version](https://img.shields.io/badge/version-0.3.3-green.svg)](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
  [![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.1-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.3.3-green.svg)](https://pypi.org/project/redup/)
9
9
 
10
10
  reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
11
11
 
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redup"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Code duplication analyzer and refactoring planner for LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.3.3"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -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
- ".py",
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
- 3,
186
+ min_lines: int | None = typer.Option(
187
+ None,
182
188
  "--min-lines",
183
- help="Minimum block size (lines) to consider as duplicate.",
189
+ help="Minimum block size (lines). Overrides config.",
184
190
  ),
185
- min_similarity: float = typer.Option(
186
- 0.85,
191
+ min_similarity: float | None = typer.Option(
192
+ None,
187
193
  "--min-sim",
188
- help="Minimum similarity score (0.0-1.0) for fuzzy matches.",
194
+ help="Minimum similarity score (0.0-1.0). Overrides config.",
189
195
  ),
190
- include_tests: bool = typer.Option(
191
- False,
196
+ include_tests: bool | None = typer.Option(
197
+ None,
192
198
  "--include-tests",
193
- help="Include test files in analysis.",
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 = _build_config(path, extensions, min_lines, min_similarity, include_tests)
218
+ config = _build_config_with_file_support(path, extensions, min_lines, min_similarity, include_tests)
213
219
 
214
- _print_scan_header(path, config.extensions, min_lines, min_similarity)
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
- 10,
279
+ max_groups: int | None = typer.Option(
280
+ None,
275
281
  "--max-groups",
276
- help="Maximum allowed duplicate groups (default: 10).",
282
+ help="Maximum allowed duplicate groups. Overrides config.",
277
283
  ),
278
- max_saved_lines: int = typer.Option(
279
- 100,
284
+ max_saved_lines: int | None = typer.Option(
285
+ None,
280
286
  "--max-lines",
281
- help="Maximum allowed recoverable lines (default: 100).",
287
+ help="Maximum allowed recoverable lines. Overrides config.",
282
288
  ),
283
- extensions: str = typer.Option(
284
- ".py",
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
- 3,
294
+ min_lines: int | None = typer.Option(
295
+ None,
290
296
  "--min-lines",
291
- help="Minimum block size (lines) to consider as duplicate.",
297
+ help="Minimum block size (lines). Overrides config.",
292
298
  ),
293
- min_similarity: float = typer.Option(
294
- 0.85,
299
+ min_similarity: float | None = typer.Option(
300
+ None,
295
301
  "--min-sim",
296
- help="Minimum similarity score (0.0-1.0) for fuzzy matches.",
302
+ help="Minimum similarity score (0.0-1.0). Overrides config.",
297
303
  ),
298
- include_tests: bool = typer.Option(
299
- False,
304
+ include_tests: bool | None = typer.Option(
305
+ None,
300
306
  "--include-tests",
301
- help="Include test files in analysis.",
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
- config = _build_config(path, extensions, min_lines, min_similarity, include_tests)
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 > max_groups
311
- lines_exceeded = dup_map.total_saved_lines > max_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: {max_groups})", err=True)
316
- typer.echo(f" Recoverable lines: {dup_map.total_saved_lines} (max: {max_saved_lines})", err=True)
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} > {max_groups})", err=True)
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} > {max_saved_lines})", err=True)
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}/{max_groups}")
327
- typer.echo(f" Recoverable lines: {dup_map.total_saved_lines}/{max_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: # nodup: accepted thin API wrapper
152
+ def hash_block(text: str) -> str:
153
153
  """SHA-256 hash of normalized text."""
154
- return _hash_text(text, _normalize_text)
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: # nodup: accepted thin API wrapper
158
+ def hash_block_structural(text: str) -> str:
158
159
  """SHA-256 hash of deeply normalized text (variable names replaced)."""
159
- return _hash_text(text, _normalize_ast_text)
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 and _is_test_file(file_path, project_root):
77
- return False
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
- with ProcessPoolExecutor(max_workers=max_workers) as executor:
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=0.0, # Will be updated by caller
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
- for sf in scanned_files:
167
- for block in sf.blocks:
168
- if function_level_only and block.function_name is None:
169
- continue
170
- all_blocks.append(block)
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 exact duplicates
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
- return exact_groups + structural_groups + near_duplicate_groups
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]: