redup 0.2.1__tar.gz → 0.2.4__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 (39) hide show
  1. {redup-0.2.1 → redup-0.2.4}/PKG-INFO +3 -3
  2. {redup-0.2.1 → redup-0.2.4}/README.md +1 -1
  3. {redup-0.2.1 → redup-0.2.4}/pyproject.toml +2 -2
  4. {redup-0.2.1 → redup-0.2.4}/src/redup/__init__.py +1 -1
  5. {redup-0.2.1 → redup-0.2.4}/src/redup/cli_app/main.py +86 -16
  6. redup-0.2.4/src/redup/core/config.py +154 -0
  7. {redup-0.2.1 → redup-0.2.4}/src/redup/core/hasher.py +4 -4
  8. redup-0.2.4/src/redup/core/lsh_matcher.py +217 -0
  9. {redup-0.2.1 → redup-0.2.4}/src/redup/core/models.py +5 -0
  10. {redup-0.2.1 → redup-0.2.4}/src/redup/core/pipeline.py +76 -1
  11. redup-0.2.4/src/redup/reporters/markdown_reporter.py +90 -0
  12. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/PKG-INFO +3 -3
  13. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/SOURCES.txt +3 -0
  14. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/requires.txt +1 -1
  15. {redup-0.2.1 → redup-0.2.4}/LICENSE +0 -0
  16. {redup-0.2.1 → redup-0.2.4}/setup.cfg +0 -0
  17. {redup-0.2.1 → redup-0.2.4}/src/redup/__main__.py +0 -0
  18. {redup-0.2.1 → redup-0.2.4}/src/redup/cli_app/__init__.py +0 -0
  19. {redup-0.2.1 → redup-0.2.4}/src/redup/core/__init__.py +0 -0
  20. {redup-0.2.1 → redup-0.2.4}/src/redup/core/differ.py +0 -0
  21. {redup-0.2.1 → redup-0.2.4}/src/redup/core/matcher.py +0 -0
  22. {redup-0.2.1 → redup-0.2.4}/src/redup/core/planner.py +0 -0
  23. {redup-0.2.1 → redup-0.2.4}/src/redup/core/scanner.py +0 -0
  24. {redup-0.2.1 → redup-0.2.4}/src/redup/core/ts_extractor.py +0 -0
  25. {redup-0.2.1 → redup-0.2.4}/src/redup/reporters/__init__.py +0 -0
  26. {redup-0.2.1 → redup-0.2.4}/src/redup/reporters/json_reporter.py +0 -0
  27. {redup-0.2.1 → redup-0.2.4}/src/redup/reporters/toon_reporter.py +0 -0
  28. {redup-0.2.1 → redup-0.2.4}/src/redup/reporters/yaml_reporter.py +0 -0
  29. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/dependency_links.txt +0 -0
  30. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/entry_points.txt +0 -0
  31. {redup-0.2.1 → redup-0.2.4}/src/redup.egg-info/top_level.txt +0 -0
  32. {redup-0.2.1 → redup-0.2.4}/tests/test_e2e.py +0 -0
  33. {redup-0.2.1 → redup-0.2.4}/tests/test_hasher.py +0 -0
  34. {redup-0.2.1 → redup-0.2.4}/tests/test_matcher.py +0 -0
  35. {redup-0.2.1 → redup-0.2.4}/tests/test_models.py +0 -0
  36. {redup-0.2.1 → redup-0.2.4}/tests/test_pipeline.py +0 -0
  37. {redup-0.2.1 → redup-0.2.4}/tests/test_planner.py +0 -0
  38. {redup-0.2.1 → redup-0.2.4}/tests/test_reporters.py +0 -0
  39. {redup-0.2.1 → redup-0.2.4}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.2.1
3
+ Version: 0.2.4
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
@@ -23,7 +23,7 @@ Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: pyyaml>=6.0
26
- Requires-Dist: typer>=0.9.0
26
+ Requires-Dist: typer>=0.12.0
27
27
  Requires-Dist: rich>=13.0
28
28
  Requires-Dist: pydantic>=2.0
29
29
  Provides-Extra: all
@@ -57,7 +57,7 @@ Dynamic: license-file
57
57
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
58
58
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
59
59
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
60
- [![Version](https://img.shields.io/badge/version-0.2.0-green.svg)](https://pypi.org/project/redup/)
60
+ [![Version](https://img.shields.io/badge/version-0.2.4-green.svg)](https://pypi.org/project/redup/)
61
61
 
62
62
  reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
63
63
 
@@ -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.2.0-green.svg)](https://pypi.org/project/redup/)
8
+ [![Version](https://img.shields.io/badge/version-0.2.4-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.2.1"
7
+ version = "0.2.4"
8
8
  description = "Code duplication analyzer and refactoring planner for LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  "pyyaml>=6.0",
30
- "typer>=0.9.0",
30
+ "typer>=0.12.0",
31
31
  "rich>=13.0",
32
32
  "pydantic>=2.0",
33
33
  ]
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.10"
5
+ __version__ = "0.2.4"
6
6
 
7
7
  from redup.core.models import (
8
8
  DuplicateFragment,
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import warnings
6
6
  from collections.abc import Callable
7
- from enum import Enum
7
+ from typing import Literal
8
8
  from pathlib import Path
9
9
 
10
10
  import typer
@@ -13,9 +13,11 @@ import typer
13
13
  warnings.filterwarnings("ignore", category=SyntaxWarning)
14
14
 
15
15
  import redup # noqa: E402
16
+ from redup.core.config import config_to_scan_config, create_sample_redup_toml, load_config # noqa: E402
16
17
  from redup.core.models import DuplicationMap, ScanConfig # noqa: E402
17
18
  from redup.core.pipeline import analyze # noqa: E402
18
19
  from redup.reporters.json_reporter import to_json # noqa: E402
20
+ from redup.reporters.markdown_reporter import to_markdown # noqa: E402
19
21
  from redup.reporters.toon_reporter import to_toon # noqa: E402
20
22
  from redup.reporters.yaml_reporter import to_yaml # noqa: E402
21
23
 
@@ -26,15 +28,11 @@ app = typer.Typer(
26
28
  )
27
29
 
28
30
 
29
- class OutputFormat(str, Enum):
30
- json = "json"
31
- yaml = "yaml"
32
- toon = "toon"
33
- all = "all"
31
+ OutputFormat = Literal["json", "yaml", "toon", "all"]
34
32
 
35
33
 
36
34
  DEFAULT_PATH = Path(".")
37
- DEFAULT_FORMAT = OutputFormat.toon
35
+ DEFAULT_FORMAT: OutputFormat = "toon"
38
36
  DEFAULT_OUTPUT: Path | None = None
39
37
 
40
38
 
@@ -68,6 +66,30 @@ def _build_config(
68
66
  )
69
67
 
70
68
 
69
+ def _build_config_with_file_support(
70
+ path: Path,
71
+ extensions: str | None,
72
+ min_lines: int | None,
73
+ min_similarity: float | None,
74
+ include_tests: bool | None
75
+ ) -> ScanConfig:
76
+ """Build scan configuration supporting config files and CLI overrides."""
77
+ # Load configuration from files
78
+ file_config = load_config()
79
+
80
+ # Use CLI arguments to override file config (if provided)
81
+ if extensions is not None:
82
+ file_config["extensions"] = extensions
83
+ if min_lines is not None:
84
+ file_config["min_lines"] = min_lines
85
+ if min_similarity is not None:
86
+ file_config["min_similarity"] = min_similarity
87
+ if include_tests is not None:
88
+ file_config["include_tests"] = include_tests
89
+
90
+ return config_to_scan_config(file_config, path)
91
+
92
+
71
93
  def _print_scan_header(
72
94
  path: Path,
73
95
  ext_list: list[str],
@@ -94,14 +116,14 @@ def _print_scan_summary(dup_map: DuplicationMap) -> None:
94
116
 
95
117
  def _write_results(
96
118
  dup_map: DuplicationMap,
97
- format: OutputFormat,
119
+ format: str,
98
120
  output: Path | None,
99
121
  path: Path,
100
122
  ) -> None:
101
123
  """Write scan results to output files."""
102
124
  output_dir = output
103
125
 
104
- if format == OutputFormat.all:
126
+ if format == "all":
105
127
  if output_dir is None:
106
128
  output_dir = path / "redup_output"
107
129
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -110,18 +132,21 @@ def _write_results(
110
132
  ("JSON", to_json, "json"),
111
133
  ("YAML", to_yaml, "yaml"),
112
134
  ("TOON", to_toon, "toon"),
135
+ ("Markdown", to_markdown, "md"),
113
136
  ]
114
137
  for _fmt, renderer, suffix in renderers:
115
138
  content = renderer(dup_map)
116
139
  target = output_dir / f"duplication.{suffix}"
117
140
  target.write_text(content, encoding="utf-8")
118
141
  typer.echo(f" → {target}")
119
- elif format == OutputFormat.json:
142
+ elif format == "json":
120
143
  _write_output(to_json(dup_map), output_dir, "json")
121
- elif format == OutputFormat.yaml:
144
+ elif format == "yaml":
122
145
  _write_output(to_yaml(dup_map), output_dir, "yaml")
123
- elif format == OutputFormat.toon:
146
+ elif format == "toon":
124
147
  _write_output(to_toon(dup_map), output_dir, "toon")
148
+ elif format == "markdown":
149
+ _write_output(to_markdown(dup_map), output_dir, "md")
125
150
 
126
151
 
127
152
  @app.command()
@@ -133,10 +158,10 @@ def scan(
133
158
  dir_okay=True,
134
159
  file_okay=False,
135
160
  ),
136
- format: OutputFormat = typer.Option(
137
- DEFAULT_FORMAT,
161
+ format: str = typer.Option(
162
+ "toon",
138
163
  "--format", "-f",
139
- help="Output format.",
164
+ help="Output format (json, yaml, toon, markdown, all).",
140
165
  ),
141
166
  output: Path | None = typer.Option(
142
167
  DEFAULT_OUTPUT,
@@ -282,7 +307,52 @@ def check(
282
307
  typer.echo(f"✅ reDUP check PASSED")
283
308
  typer.echo(f" Duplicate groups: {dup_map.total_groups}/{max_groups}")
284
309
  typer.echo(f" Recoverable lines: {dup_map.total_saved_lines}/{max_saved_lines}")
285
- raise typer.Exit(0)
310
+ # Don't exit - just return normally (exit code 0)
311
+ return
312
+
313
+
314
+ @app.command()
315
+ def config(
316
+ init: bool = typer.Option(
317
+ False,
318
+ "--init",
319
+ help="Create a sample redup.toml configuration file.",
320
+ ),
321
+ show: bool = typer.Option(
322
+ False,
323
+ "--show",
324
+ help="Show current configuration (files + env vars).",
325
+ ),
326
+ ) -> None:
327
+ """Manage reDUP configuration."""
328
+ if init:
329
+ config_file = Path.cwd() / "redup.toml"
330
+ if config_file.exists():
331
+ typer.echo(f"❌ Configuration file already exists: {config_file}")
332
+ raise typer.Exit(1)
333
+
334
+ content = create_sample_redup_toml()
335
+ config_file.write_text(content, encoding="utf-8")
336
+ typer.echo(f"✅ Created sample configuration: {config_file}")
337
+ typer.echo("Edit the file to customize reDUP settings.")
338
+ return
339
+
340
+ if show:
341
+ config = load_config()
342
+ typer.echo("Current reDUP configuration:")
343
+ typer.echo("=" * 40)
344
+
345
+ if not config:
346
+ typer.echo("No configuration found (using defaults)")
347
+ return
348
+
349
+ for key, value in sorted(config.items()):
350
+ typer.echo(f" {key}: {value}")
351
+ return
352
+
353
+ # Default: show help
354
+ typer.echo("Use --init to create a sample configuration file.")
355
+ typer.echo("Use --show to display current configuration.")
286
356
 
287
357
 
288
358
  @app.command()
@@ -0,0 +1,154 @@
1
+ """Configuration management for reDUP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try:
10
+ import tomllib
11
+ except ImportError:
12
+ try:
13
+ import tomli as tomllib
14
+ except ImportError:
15
+ tomllib = None
16
+
17
+ from redup.core.models import ScanConfig
18
+
19
+
20
+ def _load_toml_file(file_path: Path) -> dict[str, Any]:
21
+ """Load TOML file and return as dictionary."""
22
+ if tomllib is None:
23
+ return {}
24
+
25
+ if not file_path.exists():
26
+ return {}
27
+
28
+ try:
29
+ with open(file_path, "rb") as f:
30
+ return tomllib.load(f)
31
+ except Exception:
32
+ return {}
33
+
34
+
35
+ def _get_config_from_pyproject() -> dict[str, Any]:
36
+ """Get reDUP configuration from pyproject.toml [tool.redup] section."""
37
+ pyproject_path = Path.cwd() / "pyproject.toml"
38
+ data = _load_toml_file(pyproject_path)
39
+ return data.get("tool", {}).get("redup", {})
40
+
41
+
42
+ def _get_config_from_redup_toml() -> dict[str, Any]:
43
+ """Get reDUP configuration from redup.toml file."""
44
+ redup_toml_path = Path.cwd() / "redup.toml"
45
+ return _load_toml_file(redup_toml_path)
46
+
47
+
48
+ def load_config() -> dict[str, Any]:
49
+ """Load reDUP configuration from available sources.
50
+
51
+ Priority order:
52
+ 1. Environment variables (REDUP_*)
53
+ 2. redup.toml in current directory
54
+ 3. [tool.redup] in pyproject.toml
55
+ 4. Defaults
56
+ """
57
+ config = {}
58
+
59
+ # Load from redup.toml
60
+ config.update(_get_config_from_redup_toml())
61
+
62
+ # Load from pyproject.toml (overwrites redup.toml)
63
+ config.update(_get_config_from_pyproject())
64
+
65
+ # Override with environment variables
66
+ env_mappings = {
67
+ "REDUP_EXTENSIONS": ("extensions", str),
68
+ "REDUP_MIN_LINES": ("min_lines", int),
69
+ "REDUP_MIN_SIMILARITY": ("min_similarity", float),
70
+ "REDUP_INCLUDE_TESTS": ("include_tests", bool),
71
+ "REDUP_OUTPUT": ("output", str),
72
+ "REDUP_FORMAT": ("format", str),
73
+ "REDUP_MAX_GROUPS": ("max_groups", int),
74
+ "REDUP_MAX_LINES": ("max_lines", int),
75
+ }
76
+
77
+ for env_var, (key, type_func) in env_mappings.items():
78
+ value = os.getenv(env_var)
79
+ if value is not None:
80
+ try:
81
+ if type_func == bool:
82
+ config[key] = value.lower() in ("true", "1", "yes", "on")
83
+ else:
84
+ config[key] = type_func(value)
85
+ except (ValueError, TypeError):
86
+ pass # Ignore invalid environment variables
87
+
88
+ return config
89
+
90
+
91
+ def config_to_scan_config(config: dict[str, Any], path: Path) -> ScanConfig:
92
+ """Convert configuration dict to ScanConfig object."""
93
+ extensions = config.get("extensions", ".py")
94
+ if isinstance(extensions, str):
95
+ ext_list = [e.strip() if e.startswith(".") else f".{e.strip()}"
96
+ for e in extensions.split(",")]
97
+ else:
98
+ ext_list = extensions
99
+
100
+ scan_config = config.get("scan", {})
101
+ lsh_config = config.get("lsh", {})
102
+
103
+ return ScanConfig(
104
+ root=path,
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),
109
+ lsh_enabled=lsh_config.get("enabled", True),
110
+ lsh_min_lines=lsh_config.get("min_lines", 50),
111
+ lsh_threshold=lsh_config.get("threshold", 0.8),
112
+ )
113
+
114
+
115
+ def create_sample_redup_toml() -> str:
116
+ """Create a sample redup.toml configuration file content."""
117
+ return """# reDUP Configuration File
118
+ # See https://github.com/semcod/redup for documentation
119
+
120
+ [scan]
121
+ # File extensions to scan (comma-separated)
122
+ extensions = ".py,.js,.ts,.go,.rs,.java"
123
+ # Minimum block size in lines to consider as duplicate
124
+ min_lines = 3
125
+ # Minimum similarity score (0.0-1.0) for fuzzy matches
126
+ min_similarity = 0.85
127
+ # Include test files in analysis
128
+ include_tests = false
129
+
130
+ [lsh]
131
+ # Enable LSH near-duplicate detection for large blocks
132
+ enabled = true
133
+ # Minimum block size for LSH analysis
134
+ min_lines = 50
135
+ # Similarity threshold for LSH (0.0-1.0)
136
+ threshold = 0.8
137
+
138
+ [check]
139
+ # CI gate thresholds
140
+ max_groups = 10
141
+ max_lines = 100
142
+
143
+ [output]
144
+ # Default output format
145
+ format = "toon"
146
+ # Default output directory (relative to project root)
147
+ output = "redup_output"
148
+
149
+ [reporting]
150
+ # Include code snippets in JSON output
151
+ include_snippets = false
152
+ # Generate suggestions for refactoring
153
+ generate_suggestions = true
154
+ """
@@ -149,12 +149,12 @@ 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: # nodup: accepted thin API wrapper
153
153
  """SHA-256 hash of normalized text."""
154
154
  return _hash_text(text, _normalize_text)
155
155
 
156
156
 
157
- def hash_block_structural(text: str) -> str:
157
+ def hash_block_structural(text: str) -> str: # nodup: accepted thin API wrapper
158
158
  """SHA-256 hash of deeply normalized text (variable names replaced)."""
159
159
  return _hash_text(text, _normalize_ast_text)
160
160
 
@@ -213,12 +213,12 @@ def _find_duplicates(hash_dict: dict[str, list[HashedBlock]]) -> dict[str, list[
213
213
  }
214
214
 
215
215
 
216
- def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
216
+ def find_exact_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
217
217
  """Find groups of blocks with identical normalized text."""
218
218
  return _find_duplicates(index.exact)
219
219
 
220
220
 
221
- def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]:
221
+ def find_structural_duplicates(index: HashIndex) -> dict[str, list[HashedBlock]]: # nodup: accepted thin API wrapper
222
222
  """Find groups of blocks with identical structure (names may differ)."""
223
223
  return _find_duplicates(index.structural)
224
224
 
@@ -0,0 +1,217 @@
1
+ """LSH-based near-duplicate detection for large codebases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ try:
9
+ from datasketch import MinHash, MinHashLSH
10
+ except ImportError:
11
+ MinHash = None
12
+ MinHashLSH = None
13
+
14
+ from redup.core.scanner import CodeBlock
15
+ from redup.core.hasher import _normalize_text
16
+
17
+
18
+ def _create_minhash(text: str, num_perm: int = 128) -> MinHash | None:
19
+ """Create MinHash from normalized text."""
20
+ if MinHash is None:
21
+ return None
22
+
23
+ # Normalize and tokenize text
24
+ normalized = _normalize_text(text)
25
+ tokens = normalized.split()
26
+
27
+ # Create MinHash
28
+ m = MinHash(num_perm=num_perm)
29
+ for token in tokens:
30
+ m.update(token.encode('utf-8'))
31
+
32
+ return m
33
+
34
+
35
+ def _text_to_minhash_features(text: str, num_features: int = 10) -> list[str]:
36
+ """Extract text features for MinHash without external dependencies."""
37
+ normalized = _normalize_text(text)
38
+
39
+ # Create n-gram features
40
+ features = []
41
+ words = normalized.split()
42
+
43
+ # Single words
44
+ features.extend(words[:num_features//2])
45
+
46
+ # 2-grams
47
+ for i in range(len(words) - 1):
48
+ if len(features) >= num_features:
49
+ break
50
+ features.append(f"{words[i]} {words[i+1]}")
51
+
52
+ # 3-grams
53
+ for i in range(len(words) - 2):
54
+ if len(features) >= num_features:
55
+ break
56
+ features.append(f"{words[i]} {words[i+1]} {words[i+2]}")
57
+
58
+ return features[:num_features]
59
+
60
+
61
+ def _create_simple_minhash(text: str, num_perm: int = 128) -> _SimpleMinHash:
62
+ """Create simple MinHash implementation without external dependencies."""
63
+ features = _text_to_minhash_features(text)
64
+ return _SimpleMinHash(features, num_perm)
65
+
66
+
67
+ class _SimpleMinHash:
68
+ """Simple MinHash implementation for fallback without datasketch."""
69
+
70
+ def __init__(self, features: list[str], num_perm: int = 128):
71
+ self.num_perm = num_perm
72
+ self.hash_values = []
73
+
74
+ # Generate hash values for each permutation
75
+ for i in range(num_perm):
76
+ seed = i + 1
77
+ min_hash = float('inf')
78
+
79
+ for feature in features:
80
+ # Combine feature with seed for different permutations
81
+ hash_input = f"{feature}_{seed}"
82
+ hash_val = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
83
+ min_hash = min(min_hash, hash_val)
84
+
85
+ self.hash_values.append(min_hash)
86
+
87
+ def jaccard(self, other: _SimpleMinHash) -> float:
88
+ """Estimate Jaccard similarity."""
89
+ if len(self.hash_values) != len(other.hash_values):
90
+ return 0.0
91
+
92
+ # Count matching hash values
93
+ matches = sum(1 for a, b in zip(self.hash_values, other.hash_values) if a == b)
94
+ return matches / len(self.hash_values)
95
+
96
+
97
+ class LSHIndex:
98
+ """LSH index for efficient near-duplicate detection."""
99
+
100
+ def __init__(self, threshold: float = 0.8, num_perm: int = 128):
101
+ self.threshold = threshold
102
+ self.num_perm = num_perm
103
+ self.blocks: list[CodeBlock] = []
104
+ self.minhashes: list[Any] = []
105
+ self.lsh = None
106
+
107
+ if MinHashLSH is not None:
108
+ self.lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
109
+
110
+ def add(self, block: CodeBlock) -> None:
111
+ """Add a code block to the LSH index."""
112
+ self.blocks.append(block)
113
+
114
+ # Create MinHash
115
+ if MinHash is not None:
116
+ minhash = _create_minhash(block.text, self.num_perm)
117
+ if minhash and self.lsh:
118
+ self.lsh.insert(str(len(self.blocks) - 1), minhash)
119
+ self.minhashes.append(minhash)
120
+ else:
121
+ # Fallback to simple implementation
122
+ minhash = _create_simple_minhash(block.text, self.num_perm)
123
+ self.minhashes.append(minhash)
124
+
125
+ def find_near_duplicates(self, block: CodeBlock) -> list[tuple[CodeBlock, float]]:
126
+ """Find near-duplicates of the given block."""
127
+ if MinHash is None or not self.minhashes:
128
+ return self._find_near_duplicates_simple(block)
129
+
130
+ # Create MinHash for query block
131
+ query_minhash = _create_minhash(block.text, self.num_perm)
132
+ if not query_minhash:
133
+ return []
134
+
135
+ # Use LSH for candidate selection
136
+ if self.lsh:
137
+ result = []
138
+ try:
139
+ # Find similar items
140
+ similar_indices = self.lsh.query(query_minhash)
141
+
142
+ for idx in similar_indices:
143
+ if idx < len(self.blocks):
144
+ candidate_block = self.blocks[int(idx)]
145
+ candidate_minhash = self.minhashes[int(idx)]
146
+
147
+ if candidate_minhash:
148
+ similarity = query_minhash.jaccard(candidate_minhash)
149
+ if similarity >= self.threshold:
150
+ result.append((candidate_block, similarity))
151
+
152
+ return sorted(result, key=lambda x: x[1], reverse=True)
153
+ except Exception:
154
+ # Fallback to simple comparison
155
+ pass
156
+
157
+ return []
158
+
159
+ def _find_near_duplicates_simple(self, block: CodeBlock) -> list[tuple[CodeBlock, float]]:
160
+ """Fallback near-duplicate detection without LSH."""
161
+ query_minhash = _create_simple_minhash(block.text, self.num_perm)
162
+ result = []
163
+
164
+ for i, stored_minhash in enumerate(self.minhashes):
165
+ if isinstance(stored_minhash, _SimpleMinHash):
166
+ similarity = query_minhash.jaccard(stored_minhash)
167
+ if similarity >= self.threshold:
168
+ result.append((self.blocks[i], similarity))
169
+
170
+ return sorted(result, key=lambda x: x[1], reverse=True)
171
+
172
+ def find_all_near_duplicates(self, min_lines: int = 10) -> dict[str, list[tuple[CodeBlock, float]]]:
173
+ """Find all near-duplicate pairs in the index."""
174
+ groups = {}
175
+ processed = set()
176
+
177
+ for i, block in enumerate(self.blocks):
178
+ if i in processed or block.line_count < min_lines:
179
+ continue
180
+
181
+ # Find near-duplicates
182
+ near_dups = self.find_near_duplicates(block)
183
+
184
+ if near_dups:
185
+ # Create group key
186
+ group_key = f"LSH_{i:04d}"
187
+ groups[group_key] = [(block, 1.0)] + near_dups
188
+
189
+ # Mark all as processed
190
+ processed.add(i)
191
+ for dup_block, _ in near_dups:
192
+ for j, stored_block in enumerate(self.blocks):
193
+ if stored_block == dup_block:
194
+ processed.add(j)
195
+ break
196
+
197
+ return groups
198
+
199
+
200
+ def build_lsh_index(blocks: list[CodeBlock], threshold: float = 0.8, min_lines: int = 10) -> LSHIndex:
201
+ """Build LSH index from code blocks."""
202
+ index = LSHIndex(threshold=threshold)
203
+
204
+ for block in blocks:
205
+ if block.line_count >= min_lines:
206
+ index.add(block)
207
+
208
+ return index
209
+
210
+
211
+ def find_near_duplicates(blocks: list[CodeBlock], threshold: float = 0.8, min_lines: int = 10) -> dict[str, list[tuple[CodeBlock, float]]]:
212
+ """Find near-duplicate code blocks using LSH."""
213
+ if not blocks:
214
+ return {}
215
+
216
+ index = build_lsh_index(blocks, threshold, min_lines)
217
+ return index.find_all_near_duplicates(min_lines)
@@ -13,6 +13,7 @@ class DuplicateType(str, Enum):
13
13
  EXACT = "exact"
14
14
  STRUCTURAL = "structural"
15
15
  FUZZY = "fuzzy"
16
+ NEAR_DUPLICATE = "near_duplicate"
16
17
 
17
18
 
18
19
  class RefactorAction(str, Enum):
@@ -48,6 +49,10 @@ class ScanConfig:
48
49
  min_similarity: float = 0.85
49
50
  max_file_size_kb: int = 1024
50
51
  include_tests: bool = False
52
+ # LSH configuration
53
+ lsh_enabled: bool = True
54
+ lsh_min_lines: int = 50
55
+ lsh_threshold: float = 0.8
51
56
 
52
57
 
53
58
  @dataclass
@@ -9,6 +9,7 @@ from redup.core.hasher import (
9
9
  find_exact_duplicates,
10
10
  find_structural_duplicates,
11
11
  )
12
+ from redup.core.lsh_matcher import find_near_duplicates
12
13
  from redup.core.matcher import MatchResult, refine_structural_matches
13
14
  from redup.core.models import (
14
15
  DuplicateFragment,
@@ -168,8 +169,11 @@ def _find_duplicates_phase(
168
169
 
169
170
  # Find structural duplicates
170
171
  structural_groups = _find_structural_groups(index, exact_groups)
172
+
173
+ # Find near-duplicates using LSH (for larger blocks)
174
+ near_duplicate_groups = _find_near_duplicate_groups(all_blocks, config)
171
175
 
172
- return exact_groups + structural_groups
176
+ return exact_groups + structural_groups + near_duplicate_groups
173
177
 
174
178
 
175
179
  def _find_exact_groups(index: HashIndex) -> list[DuplicateGroup]:
@@ -255,3 +259,74 @@ def _calculate_similarity(matches: list[MatchResult]) -> float:
255
259
  return sum(match.similarity for match in matches) / len(matches)
256
260
 
257
261
 
262
+ def _find_near_duplicate_groups(
263
+ all_blocks: list[CodeBlock],
264
+ config: ScanConfig
265
+ ) -> list[DuplicateGroup]:
266
+ """Find near-duplicate groups using LSH."""
267
+ groups: list[DuplicateGroup] = []
268
+
269
+ # Check if LSH is enabled
270
+ if not getattr(config, 'lsh_enabled', True):
271
+ return groups
272
+
273
+ # Only use LSH for blocks larger than configured threshold
274
+ lsh_min_lines = getattr(config, 'lsh_min_lines', 50)
275
+ lsh_threshold = getattr(config, 'lsh_threshold', 0.8)
276
+
277
+ # Filter blocks for LSH
278
+ lsh_blocks = [b for b in all_blocks if b.line_count >= lsh_min_lines]
279
+
280
+ if not lsh_blocks:
281
+ return groups
282
+
283
+ try:
284
+ # Find near-duplicates
285
+ near_dup_groups = find_near_duplicates(
286
+ lsh_blocks,
287
+ threshold=lsh_threshold,
288
+ min_lines=lsh_min_lines
289
+ )
290
+
291
+ for i, (group_id, block_similarities) in enumerate(near_dup_groups.items(), 1):
292
+ if len(block_similarities) < 2:
293
+ continue
294
+
295
+ # Convert to DuplicateGroup format
296
+ fragments = []
297
+ avg_similarity = 0.0
298
+
299
+ for block, similarity in block_similarities:
300
+ fragments.append(DuplicateFragment(
301
+ file=block.file,
302
+ line_start=block.line_start,
303
+ line_end=block.line_end,
304
+ text=block.text,
305
+ function_name=block.function_name,
306
+ class_name=block.class_name,
307
+ ))
308
+ avg_similarity += similarity
309
+
310
+ if len(fragments) >= 2:
311
+ avg_similarity /= len(fragments)
312
+
313
+ # Use function name if available
314
+ name = fragments[0].function_name if fragments[0].function_name else None
315
+
316
+ group = DuplicateGroup(
317
+ id=f"L{i:04d}",
318
+ duplicate_type=DuplicateType.NEAR_DUPLICATE,
319
+ fragments=fragments,
320
+ similarity_score=avg_similarity,
321
+ normalized_hash=f"lsh_{group_id}",
322
+ normalized_name=name,
323
+ )
324
+ groups.append(group)
325
+
326
+ except Exception:
327
+ # Silently fail if LSH is not available or has issues
328
+ pass
329
+
330
+ return groups
331
+
332
+
@@ -0,0 +1,90 @@
1
+ """Markdown reporter — human-readable duplication analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from redup.core.models import DuplicationMap
8
+
9
+
10
+ def to_markdown(dup_map: DuplicationMap) -> str:
11
+ """Serialize a DuplicationMap to Markdown format."""
12
+ lines = []
13
+
14
+ # Header
15
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
16
+ lines.append(f"# Code Duplication Analysis Report")
17
+ lines.append(f"**Generated:** {now}")
18
+ lines.append(f"**Project:** `{dup_map.project_path}`")
19
+ lines.append("")
20
+
21
+ # Summary
22
+ lines.append("## Summary")
23
+ lines.append("")
24
+ lines.append("| Metric | Value |")
25
+ lines.append("|--------|-------|")
26
+ lines.append(f"| Files scanned | {dup_map.stats.files_scanned} |")
27
+ lines.append(f"| Total lines | {dup_map.stats.total_lines:,} |")
28
+ lines.append(f"| Duplicate groups | {dup_map.total_groups} |")
29
+ lines.append(f"| Duplicate fragments | {dup_map.total_fragments} |")
30
+ lines.append(f"| Recoverable lines | {dup_map.total_saved_lines:,} |")
31
+ lines.append(f"| Scan time | {dup_map.stats.scan_time_ms:.0f}ms |")
32
+ lines.append("")
33
+
34
+ if dup_map.total_groups == 0:
35
+ lines.append("✅ **No duplicate code found!**")
36
+ lines.append("")
37
+ return "\n".join(lines)
38
+
39
+ # Duplicate groups
40
+ lines.append("## Duplicate Groups")
41
+ lines.append("")
42
+
43
+ ranked = dup_map.sorted_by_impact()
44
+ for i, group in enumerate(ranked, 1):
45
+ impact_emoji = "🔴" if group.impact_score > 100 else "🟡" if group.impact_score > 30 else "🟢"
46
+
47
+ lines.append(f"### {i}. {group.normalized_name or 'Unnamed'} {impact_emoji}")
48
+ lines.append("")
49
+ lines.append(f"- **Type:** `{group.duplicate_type.value.upper()}`")
50
+ lines.append(f"- **Similarity:** {group.similarity_score:.2f}")
51
+ lines.append(f"- **Occurrences:** {group.occurrences}")
52
+ lines.append(f"- **Total lines:** {group.total_lines}")
53
+ lines.append(f"- **Recoverable lines:** {group.saved_lines_potential}")
54
+ lines.append(f"- **Impact score:** {group.impact_score:.1f}")
55
+ lines.append("")
56
+
57
+ lines.append("**Locations:**")
58
+ lines.append("")
59
+ for frag in group.fragments:
60
+ fn_info = f" (`{frag.function_name}`)" if frag.function_name else ""
61
+ class_info = f" class `{frag.class_name}`" if frag.class_name else ""
62
+ lines.append(f"- `{frag.file}:{frag.line_start}-{frag.line_end}`{fn_info}{class_info}")
63
+ lines.append("")
64
+
65
+ # Refactoring suggestions
66
+ if dup_map.suggestions:
67
+ lines.append("## Refactoring Suggestions")
68
+ lines.append("")
69
+
70
+ for suggestion in dup_map.suggestions:
71
+ risk_emoji = {"low": "🟢", "medium": "🟡", "high": "🔴"}.get(suggestion.risk_level.value, "⚪")
72
+
73
+ lines.append(f"### Priority {suggestion.priority}: {suggestion.action.value} {risk_emoji}")
74
+ lines.append("")
75
+ lines.append(f"**Target module:** `{suggestion.new_module}`")
76
+ lines.append("")
77
+ lines.append(f"**Rationale:** {suggestion.rationale}")
78
+ lines.append("")
79
+
80
+ lines.append("**Affected files:**")
81
+ for file_path in suggestion.original_files:
82
+ lines.append(f"- `{file_path}`")
83
+ lines.append("")
84
+
85
+ # Footer
86
+ lines.append("---")
87
+ version = getattr(dup_map.stats, 'version', 'unknown')
88
+ lines.append(f"*Report generated by [reDUP](https://github.com/semcod/redup) v{version}*")
89
+
90
+ return "\n".join(lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redup
3
- Version: 0.2.1
3
+ Version: 0.2.4
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
@@ -23,7 +23,7 @@ Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: pyyaml>=6.0
26
- Requires-Dist: typer>=0.9.0
26
+ Requires-Dist: typer>=0.12.0
27
27
  Requires-Dist: rich>=13.0
28
28
  Requires-Dist: pydantic>=2.0
29
29
  Provides-Extra: all
@@ -57,7 +57,7 @@ Dynamic: license-file
57
57
  [![PyPI](https://img.shields.io/pypi/v/redup)](https://pypi.org/project/redup/)
58
58
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
59
59
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://python.org)
60
- [![Version](https://img.shields.io/badge/version-0.2.0-green.svg)](https://pypi.org/project/redup/)
60
+ [![Version](https://img.shields.io/badge/version-0.2.4-green.svg)](https://pypi.org/project/redup/)
61
61
 
62
62
  reDUP scans codebases for duplicated functions, blocks, and structural patterns — then builds a prioritized refactoring map that LLMs can consume to eliminate redundancy systematically.
63
63
 
@@ -12,8 +12,10 @@ src/redup.egg-info/top_level.txt
12
12
  src/redup/cli_app/__init__.py
13
13
  src/redup/cli_app/main.py
14
14
  src/redup/core/__init__.py
15
+ src/redup/core/config.py
15
16
  src/redup/core/differ.py
16
17
  src/redup/core/hasher.py
18
+ src/redup/core/lsh_matcher.py
17
19
  src/redup/core/matcher.py
18
20
  src/redup/core/models.py
19
21
  src/redup/core/pipeline.py
@@ -22,6 +24,7 @@ src/redup/core/scanner.py
22
24
  src/redup/core/ts_extractor.py
23
25
  src/redup/reporters/__init__.py
24
26
  src/redup/reporters/json_reporter.py
27
+ src/redup/reporters/markdown_reporter.py
25
28
  src/redup/reporters/toon_reporter.py
26
29
  src/redup/reporters/yaml_reporter.py
27
30
  tests/test_e2e.py
@@ -1,5 +1,5 @@
1
1
  pyyaml>=6.0
2
- typer>=0.9.0
2
+ typer>=0.12.0
3
3
  rich>=13.0
4
4
  pydantic>=2.0
5
5
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes