redup 0.2.2__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.
- {redup-0.2.2 → redup-0.2.4}/PKG-INFO +3 -3
- {redup-0.2.2 → redup-0.2.4}/README.md +1 -1
- {redup-0.2.2 → redup-0.2.4}/pyproject.toml +2 -2
- {redup-0.2.2 → redup-0.2.4}/src/redup/__init__.py +1 -1
- {redup-0.2.2 → redup-0.2.4}/src/redup/cli_app/main.py +84 -15
- redup-0.2.4/src/redup/core/config.py +154 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/hasher.py +4 -4
- redup-0.2.4/src/redup/core/lsh_matcher.py +217 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/models.py +5 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/pipeline.py +76 -1
- redup-0.2.4/src/redup/reporters/markdown_reporter.py +90 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/PKG-INFO +3 -3
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/SOURCES.txt +3 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/requires.txt +1 -1
- {redup-0.2.2 → redup-0.2.4}/LICENSE +0 -0
- {redup-0.2.2 → redup-0.2.4}/setup.cfg +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/__main__.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/cli_app/__init__.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/__init__.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/differ.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/matcher.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/planner.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/scanner.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/core/ts_extractor.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/reporters/__init__.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/reporters/json_reporter.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/reporters/toon_reporter.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup/reporters/yaml_reporter.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/dependency_links.txt +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/entry_points.txt +0 -0
- {redup-0.2.2 → redup-0.2.4}/src/redup.egg-info/top_level.txt +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_e2e.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_hasher.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_matcher.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_models.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_pipeline.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_planner.py +0 -0
- {redup-0.2.2 → redup-0.2.4}/tests/test_reporters.py +0 -0
- {redup-0.2.2 → 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.
|
|
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.
|
|
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
|
[](https://pypi.org/project/redup/)
|
|
58
58
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
59
59
|
[](https://python.org)
|
|
60
|
-
[](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
|
[](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.2.
|
|
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.
|
|
30
|
+
"typer>=0.12.0",
|
|
31
31
|
"rich>=13.0",
|
|
32
32
|
"pydantic>=2.0",
|
|
33
33
|
]
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import warnings
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
-
from
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 ==
|
|
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 ==
|
|
142
|
+
elif format == "json":
|
|
120
143
|
_write_output(to_json(dup_map), output_dir, "json")
|
|
121
|
-
elif format ==
|
|
144
|
+
elif format == "yaml":
|
|
122
145
|
_write_output(to_yaml(dup_map), output_dir, "yaml")
|
|
123
|
-
elif format ==
|
|
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:
|
|
137
|
-
|
|
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,
|
|
@@ -286,6 +311,50 @@ def check(
|
|
|
286
311
|
return
|
|
287
312
|
|
|
288
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.")
|
|
356
|
+
|
|
357
|
+
|
|
289
358
|
@app.command()
|
|
290
359
|
def info() -> None:
|
|
291
360
|
"""Show reDUP version and configuration info."""
|
|
@@ -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.
|
|
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.
|
|
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
|
[](https://pypi.org/project/redup/)
|
|
58
58
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
59
59
|
[](https://python.org)
|
|
60
|
-
[](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
|
|
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
|
|
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
|