duplicatecode 0.3.1__tar.gz → 0.4.0__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.
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/Cargo.lock +3 -2
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/Cargo.toml +1 -1
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/PKG-INFO +25 -8
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/README.md +24 -7
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/bench.rs +2 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/main.rs +106 -34
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/review.rs +2 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/Cargo.toml +1 -0
- duplicatecode-0.4.0/crates/duplicatecode-engine/src/diff.rs +153 -0
- duplicatecode-0.4.0/crates/duplicatecode-engine/src/fingerprint.rs +23 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/fragments.rs +87 -12
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/index.rs +86 -23
- duplicatecode-0.4.0/crates/duplicatecode-engine/src/similarity.rs +229 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/units.rs +21 -3
- duplicatecode-0.3.1/crates/duplicatecode-engine/src/diff.rs +0 -74
- duplicatecode-0.3.1/crates/duplicatecode-engine/src/fingerprint.rs +0 -24
- duplicatecode-0.3.1/crates/duplicatecode-engine/src/similarity.rs +0 -117
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/Cargo.toml +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/groups.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/embed.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/explain.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lang.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lib.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/mutate.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/naming.rs +0 -0
- {duplicatecode-0.3.1 → duplicatecode-0.4.0}/pyproject.toml +0 -0
|
@@ -198,7 +198,7 @@ dependencies = [
|
|
|
198
198
|
|
|
199
199
|
[[package]]
|
|
200
200
|
name = "duplicatecode"
|
|
201
|
-
version = "0.
|
|
201
|
+
version = "0.4.0"
|
|
202
202
|
dependencies = [
|
|
203
203
|
"anyhow",
|
|
204
204
|
"clap",
|
|
@@ -212,9 +212,10 @@ dependencies = [
|
|
|
212
212
|
|
|
213
213
|
[[package]]
|
|
214
214
|
name = "duplicatecode-engine"
|
|
215
|
-
version = "0.
|
|
215
|
+
version = "0.4.0"
|
|
216
216
|
dependencies = [
|
|
217
217
|
"ignore",
|
|
218
|
+
"rayon",
|
|
218
219
|
"serde",
|
|
219
220
|
"serde_json",
|
|
220
221
|
"tree-sitter",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duplicatecode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Summary: Static (LLM-free) detection of duplicate/similar code in Python, TypeScript/JavaScript, SQL and C#
|
|
@@ -71,13 +71,28 @@ Two optional signals, both off by default (the detector stays static and offline
|
|
|
71
71
|
> **Privacy warning.** `--embed openai`, `--embed cohere`, and `--embed-code` / `--embeddings` pointed at any
|
|
72
72
|
> hosted endpoint (OpenAI, Cohere, Azure, ...) **upload the source text of your units** to that provider
|
|
73
73
|
> (`--embeddings` sends identifier names only). `find` embeds **every unit under the given paths**, so
|
|
74
|
-
> `find ... --embed openai` uploads all of it. The local presets (`minilm`, `qwen3`, `potion`) are meant for a
|
|
75
|
-
> server on loopback (
|
|
74
|
+
> `find ... --embed openai` uploads all of it. The local presets (`gemma`, `minilm`, `qwen3`, `potion`) are meant for a
|
|
75
|
+
> server on loopback (`gemma`: ollama on `127.0.0.1:11434`, the others `127.0.0.1:8099`; `DUPLICATECODE_EMBED_ENDPOINT` overrides it): pointing that
|
|
76
76
|
> variable at a remote host sends text there too. Do not use hosted embeddings on code you may not share.
|
|
77
77
|
|
|
78
78
|
- `--embeddings` embeds identifier *names* (cheap) as an extra name-similarity signal.
|
|
79
|
-
- `--embed
|
|
80
|
-
|
|
79
|
+
- `--embed` is the short form and implies `--embed-code`. A bare `--embed` uses **`gemma`** (embeddinggemma via a local
|
|
80
|
+
[ollama](https://ollama.com): `ollama pull embeddinggemma`), the best model in the codebase benchmark (issue #12: re-implementation
|
|
81
|
+
retrieval R@1 0.94 vs 0.89 for the static detector alone, unchanged when function names are masked). Other presets:
|
|
82
|
+
`--embed=minilm|qwen3|potion|openai|cohere`; MiniLM is smaller but clearly weaker (0.85) and leans on names. Model ids go into the cache key, so vectors of different models never mix.
|
|
83
|
+
**How much it helps, and what is not proven.** On the labeled holdout (`eval-groups`) the score goes from 0.76 to
|
|
84
|
+
0.91 with `--embed` (gemma): independently written solutions to the same task improve most (Python 0.66 to 0.95,
|
|
85
|
+
JavaScript 0.50 to 0.79, TypeScript functions 0.73 to 0.92); sets that were already easy (SQL, React) barely move.
|
|
86
|
+
Without `--embed` nothing changes. Not measured yet:
|
|
87
|
+
- the false-positive rate of a real `scan` on a real repository (the benchmark measures ranking, not how many wrong
|
|
88
|
+
pairs you see); the thresholds used with embeddings were tuned for MiniLM, so expect to adjust `--threshold`;
|
|
89
|
+
- the CodeNet holdout shares a few problems with the dev set (13 of 60 Python, 4 of 60 JavaScript), so those two
|
|
90
|
+
numbers are somewhat optimistic;
|
|
91
|
+
- the public-library part of the retrieval benchmark is probably inflated because the model that wrote the
|
|
92
|
+
re-implementations may have seen those libraries; the internal codebases are the cleaner signal.
|
|
93
|
+
It also needs ollama with the model pulled, and a GPU to be fast (about 5 ms per unit; much slower on CPU).
|
|
94
|
+
Details: issue #12.
|
|
95
|
+
|
|
81
96
|
- `--embed-code` embeds the *whole text of every unit* (function, class, file, SQL statement) and blends
|
|
82
97
|
the cosine into the score (`--embed-weight`, default 0.35, others scaled by 1 - weight). It finds
|
|
83
98
|
re-implementations that share no tokens. On function-level LLM re-implementations it lifted Python from
|
|
@@ -97,7 +112,8 @@ export OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=anything OPENAI_
|
|
|
97
112
|
export AZURE_AI_FOUNDRY_ENDPOINT=https://<res>.services.ai.azure.com
|
|
98
113
|
export AZURE_AI_FOUNDRY_API_KEY=... AZURE_AI_FOUNDRY_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
|
99
114
|
# Azure OpenAI: AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_API_KEY / AZURE_OPENAI_EMBEDDING_DEPLOYMENT
|
|
100
|
-
# Fully local, no key:
|
|
115
|
+
# Fully local, no key, recommended: ollama pull embeddinggemma && duplicatecode scan . --embed (GPU: ~5 ms per unit)
|
|
116
|
+
# Other local models: start the server once (CPU; it loads whichever model a request names)
|
|
101
117
|
# uv run --no-project --python 3.12 eval/embed_server.py
|
|
102
118
|
# duplicatecode scan . --embed minilm # or: qwen3, potion (default endpoint 127.0.0.1:8099)
|
|
103
119
|
# Hosted presets: --embed openai (OPENAI_API_KEY), --embed cohere (COHERE_API_KEY)
|
|
@@ -127,7 +143,7 @@ Shared options: `--profile copies|reimpl`, `--threshold`, `--min-tokens`, `--min
|
|
|
127
143
|
`--embed-weight`, `--embed-max-chars`, `--embeddings`, `--embed-cache`, `--embed-dims`.
|
|
128
144
|
|
|
129
145
|
```sh
|
|
130
|
-
duplicatecode scan . --profile reimpl --
|
|
146
|
+
duplicatecode scan . --profile reimpl --embed minilm --pairs --explain
|
|
131
147
|
duplicatecode fragments src/ --min-stmts 4 --min-tokens 30 --cross-file --json
|
|
132
148
|
duplicatecode find "retry with exponential backoff" src/ --top 5 --json
|
|
133
149
|
duplicatecode diff --repo . < change.diff --embed openai
|
|
@@ -220,7 +236,8 @@ Self-scans of three internal repositories (Python + TypeScript), 189 pairs judge
|
|
|
220
236
|
judged sample gives ~50% true duplicates and ~76% incl. partial while keeping ~70% of the true ones.
|
|
221
237
|
These numbers are partly in-sample (weights and filters were chosen on the same pairs) — re-judge a fresh sample
|
|
222
238
|
before trusting them. A structure-heavy `--profile reimpl` exists for renamed re-implementations
|
|
223
|
-
(
|
|
239
|
+
(implies `--min-name 0` and `--min-lines 12`; on a 11k-unit repo the name gate off adds many small wrappers, so the
|
|
240
|
+
line floor matters) but has no judged real-repo precision data yet.
|
|
224
241
|
|
|
225
242
|
### Fresh held-out check (copies profile, threshold 0.6 = the `copies` default for `scan`)
|
|
226
243
|
|
|
@@ -61,13 +61,28 @@ Two optional signals, both off by default (the detector stays static and offline
|
|
|
61
61
|
> **Privacy warning.** `--embed openai`, `--embed cohere`, and `--embed-code` / `--embeddings` pointed at any
|
|
62
62
|
> hosted endpoint (OpenAI, Cohere, Azure, ...) **upload the source text of your units** to that provider
|
|
63
63
|
> (`--embeddings` sends identifier names only). `find` embeds **every unit under the given paths**, so
|
|
64
|
-
> `find ... --embed openai` uploads all of it. The local presets (`minilm`, `qwen3`, `potion`) are meant for a
|
|
65
|
-
> server on loopback (
|
|
64
|
+
> `find ... --embed openai` uploads all of it. The local presets (`gemma`, `minilm`, `qwen3`, `potion`) are meant for a
|
|
65
|
+
> server on loopback (`gemma`: ollama on `127.0.0.1:11434`, the others `127.0.0.1:8099`; `DUPLICATECODE_EMBED_ENDPOINT` overrides it): pointing that
|
|
66
66
|
> variable at a remote host sends text there too. Do not use hosted embeddings on code you may not share.
|
|
67
67
|
|
|
68
68
|
- `--embeddings` embeds identifier *names* (cheap) as an extra name-similarity signal.
|
|
69
|
-
- `--embed
|
|
70
|
-
|
|
69
|
+
- `--embed` is the short form and implies `--embed-code`. A bare `--embed` uses **`gemma`** (embeddinggemma via a local
|
|
70
|
+
[ollama](https://ollama.com): `ollama pull embeddinggemma`), the best model in the codebase benchmark (issue #12: re-implementation
|
|
71
|
+
retrieval R@1 0.94 vs 0.89 for the static detector alone, unchanged when function names are masked). Other presets:
|
|
72
|
+
`--embed=minilm|qwen3|potion|openai|cohere`; MiniLM is smaller but clearly weaker (0.85) and leans on names. Model ids go into the cache key, so vectors of different models never mix.
|
|
73
|
+
**How much it helps, and what is not proven.** On the labeled holdout (`eval-groups`) the score goes from 0.76 to
|
|
74
|
+
0.91 with `--embed` (gemma): independently written solutions to the same task improve most (Python 0.66 to 0.95,
|
|
75
|
+
JavaScript 0.50 to 0.79, TypeScript functions 0.73 to 0.92); sets that were already easy (SQL, React) barely move.
|
|
76
|
+
Without `--embed` nothing changes. Not measured yet:
|
|
77
|
+
- the false-positive rate of a real `scan` on a real repository (the benchmark measures ranking, not how many wrong
|
|
78
|
+
pairs you see); the thresholds used with embeddings were tuned for MiniLM, so expect to adjust `--threshold`;
|
|
79
|
+
- the CodeNet holdout shares a few problems with the dev set (13 of 60 Python, 4 of 60 JavaScript), so those two
|
|
80
|
+
numbers are somewhat optimistic;
|
|
81
|
+
- the public-library part of the retrieval benchmark is probably inflated because the model that wrote the
|
|
82
|
+
re-implementations may have seen those libraries; the internal codebases are the cleaner signal.
|
|
83
|
+
It also needs ollama with the model pulled, and a GPU to be fast (about 5 ms per unit; much slower on CPU).
|
|
84
|
+
Details: issue #12.
|
|
85
|
+
|
|
71
86
|
- `--embed-code` embeds the *whole text of every unit* (function, class, file, SQL statement) and blends
|
|
72
87
|
the cosine into the score (`--embed-weight`, default 0.35, others scaled by 1 - weight). It finds
|
|
73
88
|
re-implementations that share no tokens. On function-level LLM re-implementations it lifted Python from
|
|
@@ -87,7 +102,8 @@ export OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=anything OPENAI_
|
|
|
87
102
|
export AZURE_AI_FOUNDRY_ENDPOINT=https://<res>.services.ai.azure.com
|
|
88
103
|
export AZURE_AI_FOUNDRY_API_KEY=... AZURE_AI_FOUNDRY_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
|
89
104
|
# Azure OpenAI: AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_API_KEY / AZURE_OPENAI_EMBEDDING_DEPLOYMENT
|
|
90
|
-
# Fully local, no key:
|
|
105
|
+
# Fully local, no key, recommended: ollama pull embeddinggemma && duplicatecode scan . --embed (GPU: ~5 ms per unit)
|
|
106
|
+
# Other local models: start the server once (CPU; it loads whichever model a request names)
|
|
91
107
|
# uv run --no-project --python 3.12 eval/embed_server.py
|
|
92
108
|
# duplicatecode scan . --embed minilm # or: qwen3, potion (default endpoint 127.0.0.1:8099)
|
|
93
109
|
# Hosted presets: --embed openai (OPENAI_API_KEY), --embed cohere (COHERE_API_KEY)
|
|
@@ -117,7 +133,7 @@ Shared options: `--profile copies|reimpl`, `--threshold`, `--min-tokens`, `--min
|
|
|
117
133
|
`--embed-weight`, `--embed-max-chars`, `--embeddings`, `--embed-cache`, `--embed-dims`.
|
|
118
134
|
|
|
119
135
|
```sh
|
|
120
|
-
duplicatecode scan . --profile reimpl --
|
|
136
|
+
duplicatecode scan . --profile reimpl --embed minilm --pairs --explain
|
|
121
137
|
duplicatecode fragments src/ --min-stmts 4 --min-tokens 30 --cross-file --json
|
|
122
138
|
duplicatecode find "retry with exponential backoff" src/ --top 5 --json
|
|
123
139
|
duplicatecode diff --repo . < change.diff --embed openai
|
|
@@ -210,7 +226,8 @@ Self-scans of three internal repositories (Python + TypeScript), 189 pairs judge
|
|
|
210
226
|
judged sample gives ~50% true duplicates and ~76% incl. partial while keeping ~70% of the true ones.
|
|
211
227
|
These numbers are partly in-sample (weights and filters were chosen on the same pairs) — re-judge a fresh sample
|
|
212
228
|
before trusting them. A structure-heavy `--profile reimpl` exists for renamed re-implementations
|
|
213
|
-
(
|
|
229
|
+
(implies `--min-name 0` and `--min-lines 12`; on a 11k-unit repo the name gate off adds many small wrappers, so the
|
|
230
|
+
line floor matters) but has no judged real-repo precision data yet.
|
|
214
231
|
|
|
215
232
|
### Fresh held-out check (copies profile, threshold 0.6 = the `copies` default for `scan`)
|
|
216
233
|
|
|
@@ -223,6 +223,7 @@ fn sweep(
|
|
|
223
223
|
weights: Weights::default(),
|
|
224
224
|
skip_tests: false,
|
|
225
225
|
skip_boilerplate: true,
|
|
226
|
+
use_idf: false,
|
|
226
227
|
};
|
|
227
228
|
println!(
|
|
228
229
|
"\n== threshold sweep vs. negatives corpus {} ({} units) ==",
|
|
@@ -653,6 +654,7 @@ pub fn reimpl_eval(cases_path: &Path, impls: &Path) -> Result<()> {
|
|
|
653
654
|
weights: *w,
|
|
654
655
|
skip_tests: false,
|
|
655
656
|
skip_boilerplate: false,
|
|
657
|
+
use_idf: false,
|
|
656
658
|
};
|
|
657
659
|
let hits = corpus.best_for(q, opts);
|
|
658
660
|
let orig = hits
|
|
@@ -11,11 +11,14 @@ use duplicatecode_engine::{
|
|
|
11
11
|
use std::io::Read;
|
|
12
12
|
use std::path::PathBuf;
|
|
13
13
|
|
|
14
|
-
/// Named embedding setups for `--embed`.
|
|
15
|
-
/// http://127.0.0.1:
|
|
14
|
+
/// Named embedding setups for `--embed`. `gemma` talks to a local ollama (default
|
|
15
|
+
/// http://127.0.0.1:11434/v1); the other local presets talk to `eval/embed_server.py` (default
|
|
16
|
+
/// http://127.0.0.1:8099/v1). DUPLICATECODE_EMBED_ENDPOINT overrides either, loopback hosts only.
|
|
16
17
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
|
17
18
|
enum Preset {
|
|
18
|
-
///
|
|
19
|
+
/// embeddinggemma via local ollama (`ollama pull embeddinggemma`): best in the codebase bake-off, the default of a bare `--embed`
|
|
20
|
+
Gemma,
|
|
21
|
+
/// all-MiniLM-L6-v2, local server: small and fast, but below `gemma` on re-implementation retrieval
|
|
19
22
|
Minilm,
|
|
20
23
|
/// Qwen3-Embedding-0.6B, local: 8k-token context, slower
|
|
21
24
|
Qwen3,
|
|
@@ -30,9 +33,9 @@ enum Preset {
|
|
|
30
33
|
impl Preset {
|
|
31
34
|
fn config(self, dims: Option<u32>) -> Result<duplicatecode_engine::embed::EmbedConfig> {
|
|
32
35
|
use duplicatecode_engine::embed::EmbedConfig;
|
|
33
|
-
let local = |model: &str| -> Result<EmbedConfig> {
|
|
36
|
+
let local = |model: &str, default_endpoint: &str| -> Result<EmbedConfig> {
|
|
34
37
|
let endpoint = std::env::var("DUPLICATECODE_EMBED_ENDPOINT")
|
|
35
|
-
.unwrap_or_else(|_|
|
|
38
|
+
.unwrap_or_else(|_| default_endpoint.into());
|
|
36
39
|
// the local server returns full vectors whatever `dimensions` asks for
|
|
37
40
|
let cfg = EmbedConfig::new(endpoint, model.into(), Some("local".into()), None, None);
|
|
38
41
|
anyhow::ensure!(
|
|
@@ -43,13 +46,15 @@ impl Preset {
|
|
|
43
46
|
);
|
|
44
47
|
Ok(cfg)
|
|
45
48
|
};
|
|
49
|
+
const SERVER: &str = "http://127.0.0.1:8099/v1";
|
|
46
50
|
let key = |name: &str| {
|
|
47
51
|
std::env::var(name).with_context(|| format!("this preset needs {name} to be set"))
|
|
48
52
|
};
|
|
49
53
|
Ok(match self {
|
|
50
|
-
Preset::
|
|
51
|
-
Preset::
|
|
52
|
-
Preset::
|
|
54
|
+
Preset::Gemma => local("embeddinggemma", "http://127.0.0.1:11434/v1")?,
|
|
55
|
+
Preset::Minilm => local("sentence-transformers/all-MiniLM-L6-v2", SERVER)?,
|
|
56
|
+
Preset::Qwen3 => local("Qwen/Qwen3-Embedding-0.6B", SERVER)?,
|
|
57
|
+
Preset::Potion => local("minishlab/potion-base-8M", SERVER)?,
|
|
53
58
|
Preset::Openai => EmbedConfig::new(
|
|
54
59
|
"https://api.openai.com/v1".into(),
|
|
55
60
|
"text-embedding-3-small".into(),
|
|
@@ -77,7 +82,8 @@ struct EmbedArgs {
|
|
|
77
82
|
#[arg(long)]
|
|
78
83
|
embeddings: bool,
|
|
79
84
|
/// Embed every unit with a named setup (implies --embed-code); see `--help` for the presets.
|
|
80
|
-
|
|
85
|
+
/// A bare `--embed` means `gemma`; write `--embed=minilm` etc. for the others.
|
|
86
|
+
#[arg(long, value_enum, num_args = 0..=1, require_equals = true, default_missing_value = "gemma")]
|
|
81
87
|
embed: Option<Preset>,
|
|
82
88
|
/// Embed the full text of every unit (function, class, file, statement) and blend the cosine of
|
|
83
89
|
/// the two vectors into the score. Finds re-implementations that share no tokens.
|
|
@@ -241,6 +247,22 @@ impl Profile {
|
|
|
241
247
|
/// too (measured with MiniLM: score at 1% / 0.1% false-positive rate goes from ~0.27 / 0.35 to
|
|
242
248
|
/// ~0.33 / 0.42), so the `reimpl` defaults move up with it. `copies` thresholds were tuned on
|
|
243
249
|
/// judged real pairs and are left alone.
|
|
250
|
+
/// The name gate hides renamed re-implementations (no shared name subword = never scored),
|
|
251
|
+
/// which is exactly what `reimpl` looks for.
|
|
252
|
+
fn default_min_lines(self) -> u32 {
|
|
253
|
+
match self {
|
|
254
|
+
Profile::Copies => 6,
|
|
255
|
+
Profile::Reimpl => 12,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn default_min_name(self) -> f64 {
|
|
260
|
+
match self {
|
|
261
|
+
Profile::Copies => 0.3,
|
|
262
|
+
Profile::Reimpl => 0.0,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
244
266
|
fn default_threshold(self, command: Command, embedded: bool) -> f64 {
|
|
245
267
|
match (self, command) {
|
|
246
268
|
(Profile::Copies, Command::Diff) => 0.4,
|
|
@@ -298,15 +320,21 @@ enum Cmd {
|
|
|
298
320
|
#[arg(long, default_value_t = 8)]
|
|
299
321
|
min_tokens: usize,
|
|
300
322
|
/// Minimum name similarity (0 = also report look-alikes with unrelated names).
|
|
301
|
-
|
|
302
|
-
|
|
323
|
+
/// Default 0.3 for `copies`, 0 for `reimpl`.
|
|
324
|
+
#[arg(long)]
|
|
325
|
+
min_name: Option<f64>,
|
|
303
326
|
/// `copies`: tuned on real repos, favours same-name copy-paste. `reimpl`: structure-heavy,
|
|
304
|
-
/// finds renamed re-implementations (experimental
|
|
327
|
+
/// finds renamed re-implementations (experimental; implies --min-name 0).
|
|
305
328
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
306
329
|
profile: Profile,
|
|
307
|
-
///
|
|
308
|
-
|
|
309
|
-
|
|
330
|
+
/// Weight k-gram overlap by corpus rarity (boilerplate n-grams count less). Shifts score
|
|
331
|
+
/// calibration; experimental.
|
|
332
|
+
#[arg(long)]
|
|
333
|
+
idf: bool,
|
|
334
|
+
/// Units shorter than this only match as near-exact same-name copies. Default 6 for
|
|
335
|
+
/// `copies`, 12 for `reimpl` (with the name gate off, tiny wrappers flood the results).
|
|
336
|
+
#[arg(long)]
|
|
337
|
+
min_lines: Option<u32>,
|
|
310
338
|
#[arg(long)]
|
|
311
339
|
json: bool,
|
|
312
340
|
},
|
|
@@ -322,10 +350,10 @@ enum Cmd {
|
|
|
322
350
|
/// Lower = more candidates (recall), higher = fewer (precision).
|
|
323
351
|
#[arg(long)]
|
|
324
352
|
threshold: Option<f64>,
|
|
325
|
-
#[arg(long
|
|
326
|
-
min_name: f64
|
|
327
|
-
#[arg(long
|
|
328
|
-
min_lines: u32
|
|
353
|
+
#[arg(long)]
|
|
354
|
+
min_name: Option<f64>,
|
|
355
|
+
#[arg(long)]
|
|
356
|
+
min_lines: Option<u32>,
|
|
329
357
|
#[arg(long)]
|
|
330
358
|
skip_tests: bool,
|
|
331
359
|
#[arg(long, default_value_t = 40)]
|
|
@@ -343,6 +371,10 @@ enum Cmd {
|
|
|
343
371
|
preview_lines: usize,
|
|
344
372
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
345
373
|
profile: Profile,
|
|
374
|
+
/// Weight k-gram overlap by corpus rarity (boilerplate n-grams count less). Shifts score
|
|
375
|
+
/// calibration; experimental.
|
|
376
|
+
#[arg(long)]
|
|
377
|
+
idf: bool,
|
|
346
378
|
},
|
|
347
379
|
/// Embed identifiers and print their cosine similarity (to check credentials and calibrate `--embed-floor`).
|
|
348
380
|
EmbedTest {
|
|
@@ -383,6 +415,9 @@ enum Cmd {
|
|
|
383
415
|
/// Minimum number of consecutive identical statements.
|
|
384
416
|
#[arg(long, default_value_t = 4)]
|
|
385
417
|
min_stmts: usize,
|
|
418
|
+
/// Statements that may differ between two shared runs without splitting the fragment.
|
|
419
|
+
#[arg(long, default_value_t = 1)]
|
|
420
|
+
max_gap: usize,
|
|
386
421
|
/// Minimum normalized tokens in the shared run.
|
|
387
422
|
#[arg(long, default_value_t = 30)]
|
|
388
423
|
min_tokens: usize,
|
|
@@ -413,15 +448,24 @@ enum Cmd {
|
|
|
413
448
|
#[arg(long, default_value_t = 8)]
|
|
414
449
|
min_tokens: usize,
|
|
415
450
|
/// Minimum name similarity (0 = also report look-alikes with unrelated names).
|
|
416
|
-
|
|
417
|
-
|
|
451
|
+
/// Default 0.3 for `copies`, 0 for `reimpl`.
|
|
452
|
+
#[arg(long)]
|
|
453
|
+
min_name: Option<f64>,
|
|
454
|
+
/// Best matches kept per unit before pairing (more = fewer pairs lost to look-alikes).
|
|
455
|
+
#[arg(long, default_value_t = 3)]
|
|
456
|
+
top_n: usize,
|
|
418
457
|
/// `copies`: tuned on real repos, favours same-name copy-paste. `reimpl`: structure-heavy,
|
|
419
|
-
/// finds renamed re-implementations (experimental
|
|
458
|
+
/// finds renamed re-implementations (experimental; implies --min-name 0).
|
|
420
459
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
421
460
|
profile: Profile,
|
|
422
|
-
///
|
|
423
|
-
|
|
424
|
-
|
|
461
|
+
/// Weight k-gram overlap by corpus rarity (boilerplate n-grams count less). Shifts score
|
|
462
|
+
/// calibration; experimental.
|
|
463
|
+
#[arg(long)]
|
|
464
|
+
idf: bool,
|
|
465
|
+
/// Units shorter than this only match as near-exact same-name copies. Default 6 for
|
|
466
|
+
/// `copies`, 12 for `reimpl` (with the name gate off, tiny wrappers flood the results).
|
|
467
|
+
#[arg(long)]
|
|
468
|
+
min_lines: Option<u32>,
|
|
425
469
|
/// Ignore test code (about half of the noise in the judged repos, but also some real copies).
|
|
426
470
|
#[arg(long)]
|
|
427
471
|
skip_tests: bool,
|
|
@@ -556,7 +600,13 @@ fn group_pairs(pairs: &[duplicatecode_engine::Match]) -> Vec<Group> {
|
|
|
556
600
|
}
|
|
557
601
|
})
|
|
558
602
|
.collect();
|
|
559
|
-
|
|
603
|
+
// components come out of a hash map: break score ties by the first member's place
|
|
604
|
+
groups.sort_by(|a, b| {
|
|
605
|
+
let first = |g: &Group| g.units.first().map(|u| (u.file.clone(), u.start_line));
|
|
606
|
+
b.score
|
|
607
|
+
.total_cmp(&a.score)
|
|
608
|
+
.then_with(|| first(a).cmp(&first(b)))
|
|
609
|
+
});
|
|
560
610
|
groups
|
|
561
611
|
}
|
|
562
612
|
|
|
@@ -583,6 +633,7 @@ fn main() -> Result<()> {
|
|
|
583
633
|
min_tokens,
|
|
584
634
|
min_name,
|
|
585
635
|
profile,
|
|
636
|
+
idf,
|
|
586
637
|
min_lines,
|
|
587
638
|
json,
|
|
588
639
|
} => {
|
|
@@ -615,9 +666,10 @@ fn main() -> Result<()> {
|
|
|
615
666
|
let opts = MatchOptions {
|
|
616
667
|
threshold,
|
|
617
668
|
min_tokens,
|
|
618
|
-
min_name,
|
|
619
|
-
min_lines,
|
|
669
|
+
min_name: min_name.unwrap_or(profile.default_min_name()),
|
|
670
|
+
min_lines: min_lines.unwrap_or(profile.default_min_lines()),
|
|
620
671
|
weights: embed.weights(profile.weights()),
|
|
672
|
+
use_idf: idf,
|
|
621
673
|
..Default::default()
|
|
622
674
|
};
|
|
623
675
|
// when both sides are new code the pair shows up twice; report it once
|
|
@@ -662,6 +714,7 @@ fn main() -> Result<()> {
|
|
|
662
714
|
tier,
|
|
663
715
|
preview_lines,
|
|
664
716
|
profile,
|
|
717
|
+
idf,
|
|
665
718
|
} => {
|
|
666
719
|
let threshold = threshold
|
|
667
720
|
.unwrap_or(profile.default_threshold(Command::Review, embed.code_enabled()));
|
|
@@ -683,8 +736,8 @@ fn main() -> Result<()> {
|
|
|
683
736
|
embed.apply(&mut units)?;
|
|
684
737
|
let o = review::ReviewOptions {
|
|
685
738
|
threshold,
|
|
686
|
-
min_name,
|
|
687
|
-
min_lines,
|
|
739
|
+
min_name: min_name.unwrap_or(profile.default_min_name()),
|
|
740
|
+
min_lines: min_lines.unwrap_or(profile.default_min_lines()),
|
|
688
741
|
skip_tests,
|
|
689
742
|
max_groups,
|
|
690
743
|
offset,
|
|
@@ -692,6 +745,7 @@ fn main() -> Result<()> {
|
|
|
692
745
|
tier,
|
|
693
746
|
preview_lines,
|
|
694
747
|
weights: embed.weights(profile.weights()),
|
|
748
|
+
use_idf: idf,
|
|
695
749
|
};
|
|
696
750
|
print!("{}", review::run(units, &src, &o));
|
|
697
751
|
}
|
|
@@ -770,6 +824,7 @@ fn main() -> Result<()> {
|
|
|
770
824
|
paths,
|
|
771
825
|
exclude,
|
|
772
826
|
min_stmts,
|
|
827
|
+
max_gap,
|
|
773
828
|
min_tokens,
|
|
774
829
|
skip_tests,
|
|
775
830
|
cross_file,
|
|
@@ -782,6 +837,7 @@ fn main() -> Result<()> {
|
|
|
782
837
|
duplicatecode_engine::fragments::FragmentOptions {
|
|
783
838
|
min_stmts,
|
|
784
839
|
min_tokens,
|
|
840
|
+
max_gap,
|
|
785
841
|
..Default::default()
|
|
786
842
|
},
|
|
787
843
|
);
|
|
@@ -861,7 +917,9 @@ fn main() -> Result<()> {
|
|
|
861
917
|
threshold,
|
|
862
918
|
min_tokens,
|
|
863
919
|
min_name,
|
|
920
|
+
top_n,
|
|
864
921
|
profile,
|
|
922
|
+
idf,
|
|
865
923
|
min_lines,
|
|
866
924
|
skip_tests,
|
|
867
925
|
cross_file,
|
|
@@ -881,11 +939,12 @@ fn main() -> Result<()> {
|
|
|
881
939
|
let opts = MatchOptions {
|
|
882
940
|
threshold,
|
|
883
941
|
min_tokens,
|
|
884
|
-
top_n
|
|
885
|
-
min_name,
|
|
886
|
-
min_lines,
|
|
942
|
+
top_n,
|
|
943
|
+
min_name: min_name.unwrap_or(profile.default_min_name()),
|
|
944
|
+
min_lines: min_lines.unwrap_or(profile.default_min_lines()),
|
|
887
945
|
skip_tests,
|
|
888
946
|
weights: embed.weights(profile.weights()),
|
|
947
|
+
use_idf: idf,
|
|
889
948
|
..Default::default()
|
|
890
949
|
};
|
|
891
950
|
let mut seen = std::collections::HashSet::new();
|
|
@@ -902,7 +961,20 @@ fn main() -> Result<()> {
|
|
|
902
961
|
seen.insert(if a < b { (a, b) } else { (b, a) })
|
|
903
962
|
})
|
|
904
963
|
.collect();
|
|
905
|
-
pairs.sort_by(|a, b|
|
|
964
|
+
pairs.sort_by(|a, b| {
|
|
965
|
+
let key = |m: &duplicatecode_engine::Match| {
|
|
966
|
+
(
|
|
967
|
+
m.query.file.clone(),
|
|
968
|
+
m.query.start_line,
|
|
969
|
+
m.candidate.file.clone(),
|
|
970
|
+
m.candidate.start_line,
|
|
971
|
+
)
|
|
972
|
+
};
|
|
973
|
+
b.scores
|
|
974
|
+
.combined
|
|
975
|
+
.total_cmp(&a.scores.combined)
|
|
976
|
+
.then_with(|| key(a).cmp(&key(b)))
|
|
977
|
+
});
|
|
906
978
|
let groups = group_pairs(&pairs);
|
|
907
979
|
// keyed by the full place: units that start on the same line (minified code, one-line
|
|
908
980
|
// classes) must not be mixed up
|
|
@@ -23,6 +23,7 @@ pub struct ReviewOptions {
|
|
|
23
23
|
pub tier: Option<String>,
|
|
24
24
|
pub preview_lines: usize,
|
|
25
25
|
pub weights: Weights,
|
|
26
|
+
pub use_idf: bool,
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
struct G {
|
|
@@ -64,6 +65,7 @@ pub fn run(units: Vec<Unit>, src: &HashMap<String, PathBuf>, o: &ReviewOptions)
|
|
|
64
65
|
min_lines: o.min_lines,
|
|
65
66
|
skip_tests: o.skip_tests,
|
|
66
67
|
weights: o.weights,
|
|
68
|
+
use_idf: o.use_idf,
|
|
67
69
|
..Default::default()
|
|
68
70
|
};
|
|
69
71
|
let idx: HashMap<(&str, u32), usize> = units
|
|
@@ -6,6 +6,7 @@ license.workspace = true
|
|
|
6
6
|
description = "Static duplicate/similar code detection engine for Python, TypeScript/JavaScript, SQL and C#"
|
|
7
7
|
|
|
8
8
|
[dependencies]
|
|
9
|
+
rayon = "1.12.0"
|
|
9
10
|
ignore = "0.4.33"
|
|
10
11
|
serde = { version = "1.0.229", features = ["derive"] }
|
|
11
12
|
serde_json = "1.0.151"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//! Minimal unified-diff parser: which line ranges of which (new) files were added.
|
|
2
|
+
|
|
3
|
+
use std::collections::BTreeMap;
|
|
4
|
+
|
|
5
|
+
/// new-file path -> inclusive 1-based (start, end) ranges of added lines.
|
|
6
|
+
pub type AddedRanges = BTreeMap<String, Vec<(u32, u32)>>;
|
|
7
|
+
|
|
8
|
+
/// Undo git's C-style quoting (`"\\303\\274.py"` for non-ASCII paths when core.quotePath is on).
|
|
9
|
+
fn unquote_path(p: &str) -> String {
|
|
10
|
+
let Some(inner) = p.strip_prefix('"').and_then(|r| r.strip_suffix('"')) else {
|
|
11
|
+
return p.to_string();
|
|
12
|
+
};
|
|
13
|
+
let b = inner.as_bytes();
|
|
14
|
+
let mut out = Vec::with_capacity(b.len());
|
|
15
|
+
let mut i = 0;
|
|
16
|
+
while i < b.len() {
|
|
17
|
+
if b[i] != b'\\' || i + 1 >= b.len() {
|
|
18
|
+
out.push(b[i]);
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
i += 1;
|
|
23
|
+
match b[i] {
|
|
24
|
+
b'n' => out.push(b'\n'),
|
|
25
|
+
b't' => out.push(b'\t'),
|
|
26
|
+
b'"' => out.push(b'"'),
|
|
27
|
+
b'\\' => out.push(b'\\'),
|
|
28
|
+
d @ b'0'..=b'7' => {
|
|
29
|
+
let mut v = (d - b'0') as u32;
|
|
30
|
+
let mut n = 1;
|
|
31
|
+
while n < 3 && i + 1 < b.len() && (b'0'..=b'7').contains(&b[i + 1]) {
|
|
32
|
+
i += 1;
|
|
33
|
+
v = v * 8 + (b[i] - b'0') as u32;
|
|
34
|
+
n += 1;
|
|
35
|
+
}
|
|
36
|
+
out.push(v as u8);
|
|
37
|
+
}
|
|
38
|
+
c => out.push(c),
|
|
39
|
+
}
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
String::from_utf8_lossy(&out).into_owned()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// `-a,b` / `+c,d` -> line count (default 1).
|
|
46
|
+
fn hunk_count(tok: &str) -> u32 {
|
|
47
|
+
match tok[1..].split_once(',') {
|
|
48
|
+
Some((_, n)) => n.parse().unwrap_or(1),
|
|
49
|
+
None => 1,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pub fn added_ranges(diff: &str) -> AddedRanges {
|
|
54
|
+
let mut out = AddedRanges::new();
|
|
55
|
+
let mut file: Option<String> = None;
|
|
56
|
+
let mut new_line = 0u32;
|
|
57
|
+
let mut run_start: Option<u32> = None;
|
|
58
|
+
// Lines still owed by the current hunk; while > 0 a `+++ x` line is content, not a header.
|
|
59
|
+
let (mut old_left, mut new_left) = (0u32, 0u32);
|
|
60
|
+
|
|
61
|
+
let close =
|
|
62
|
+
|out: &mut AddedRanges, file: &Option<String>, start: &mut Option<u32>, end: u32| {
|
|
63
|
+
if let (Some(f), Some(s)) = (file, start.take()) {
|
|
64
|
+
out.entry(f.clone()).or_default().push((s, end));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for line in diff.lines() {
|
|
69
|
+
let in_hunk = old_left > 0 || new_left > 0;
|
|
70
|
+
if !in_hunk && line.starts_with("+++ ") {
|
|
71
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
72
|
+
let rest = &line[4..];
|
|
73
|
+
let p = if rest.starts_with('"') {
|
|
74
|
+
rest
|
|
75
|
+
} else {
|
|
76
|
+
rest.split('\t').next().unwrap_or(rest)
|
|
77
|
+
};
|
|
78
|
+
let p = unquote_path(p);
|
|
79
|
+
file = match p.as_str() {
|
|
80
|
+
"/dev/null" => None,
|
|
81
|
+
_ => Some(p.strip_prefix("b/").unwrap_or(&p).to_string()),
|
|
82
|
+
};
|
|
83
|
+
} else if !in_hunk && line.starts_with("@@ ") {
|
|
84
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
85
|
+
// @@ -a,b +c,d @@
|
|
86
|
+
let rest = &line[3..];
|
|
87
|
+
let minus = rest.split_whitespace().find(|t| t.starts_with('-'));
|
|
88
|
+
let plus = rest
|
|
89
|
+
.split_whitespace()
|
|
90
|
+
.find(|t| t.starts_with('+'))
|
|
91
|
+
.unwrap_or("+1");
|
|
92
|
+
let start = plus[1..].split(',').next().unwrap_or("1");
|
|
93
|
+
new_line = start.parse().unwrap_or(1);
|
|
94
|
+
old_left = minus.map_or(0, hunk_count);
|
|
95
|
+
new_left = hunk_count(plus);
|
|
96
|
+
} else if file.is_some() {
|
|
97
|
+
match line.as_bytes().first() {
|
|
98
|
+
Some(b'+') => {
|
|
99
|
+
run_start.get_or_insert(new_line);
|
|
100
|
+
new_line += 1;
|
|
101
|
+
new_left = new_left.saturating_sub(1);
|
|
102
|
+
}
|
|
103
|
+
Some(b'-') => {
|
|
104
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
105
|
+
old_left = old_left.saturating_sub(1);
|
|
106
|
+
}
|
|
107
|
+
Some(b'\\') => {
|
|
108
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
109
|
+
}
|
|
110
|
+
_ => {
|
|
111
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
112
|
+
new_line += 1;
|
|
113
|
+
old_left = old_left.saturating_sub(1);
|
|
114
|
+
new_left = new_left.saturating_sub(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
close(&mut out, &file, &mut run_start, new_line.saturating_sub(1));
|
|
120
|
+
out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[cfg(test)]
|
|
124
|
+
mod tests {
|
|
125
|
+
use super::*;
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn parses_added_ranges() {
|
|
129
|
+
let d = "diff --git a/x.py b/x.py\n--- a/x.py\n+++ b/x.py\n@@ -1,3 +1,5 @@\n a\n+b\n+c\n d\n-e\n+f\n g\n";
|
|
130
|
+
let r = added_ranges(d);
|
|
131
|
+
assert_eq!(r["x.py"], vec![(2, 3), (5, 5)]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn new_file() {
|
|
136
|
+
let d = "--- /dev/null\n+++ b/n.ts\n@@ -0,0 +1,2 @@\n+a\n+b\n";
|
|
137
|
+
assert_eq!(added_ranges(d)["n.ts"], vec![(1, 2)]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[test]
|
|
141
|
+
fn quoted_non_ascii_path() {
|
|
142
|
+
let d = "--- /dev/null\n+++ \"b/\\303\\274.py\"\n@@ -0,0 +1,2 @@\n+a\n+b\n";
|
|
143
|
+
assert_eq!(added_ranges(d)["\u{fc}.py"], vec![(1, 2)]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn header_like_content_line_is_not_a_header() {
|
|
148
|
+
let d = "--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,3 @@\n a\n+++ y\n+b\n";
|
|
149
|
+
let r = added_ranges(d);
|
|
150
|
+
assert_eq!(r.len(), 1);
|
|
151
|
+
assert_eq!(r["x.py"], vec![(2, 3)]);
|
|
152
|
+
}
|
|
153
|
+
}
|