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.
Files changed (26) hide show
  1. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/Cargo.lock +3 -2
  2. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/Cargo.toml +1 -1
  3. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/PKG-INFO +25 -8
  4. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/README.md +24 -7
  5. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/bench.rs +2 -0
  6. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/main.rs +106 -34
  7. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/review.rs +2 -0
  8. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/Cargo.toml +1 -0
  9. duplicatecode-0.4.0/crates/duplicatecode-engine/src/diff.rs +153 -0
  10. duplicatecode-0.4.0/crates/duplicatecode-engine/src/fingerprint.rs +23 -0
  11. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/fragments.rs +87 -12
  12. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/index.rs +86 -23
  13. duplicatecode-0.4.0/crates/duplicatecode-engine/src/similarity.rs +229 -0
  14. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/units.rs +21 -3
  15. duplicatecode-0.3.1/crates/duplicatecode-engine/src/diff.rs +0 -74
  16. duplicatecode-0.3.1/crates/duplicatecode-engine/src/fingerprint.rs +0 -24
  17. duplicatecode-0.3.1/crates/duplicatecode-engine/src/similarity.rs +0 -117
  18. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/Cargo.toml +0 -0
  19. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/groups.rs +0 -0
  20. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/embed.rs +0 -0
  21. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/explain.rs +0 -0
  22. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lang.rs +0 -0
  23. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lib.rs +0 -0
  24. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/mutate.rs +0 -0
  25. {duplicatecode-0.3.1 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/naming.rs +0 -0
  26. {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.3.1"
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.3.1"
215
+ version = "0.4.0"
216
216
  dependencies = [
217
217
  "ignore",
218
+ "rayon",
218
219
  "serde",
219
220
  "serde_json",
220
221
  "tree-sitter",
@@ -3,6 +3,6 @@ resolver = "2"
3
3
  members = ["crates/duplicatecode-engine", "crates/duplicatecode-cli"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.1"
6
+ version = "0.4.0"
7
7
  edition = "2021"
8
8
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duplicatecode
3
- Version: 0.3.1
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 (default `127.0.0.1:8099`; `DUPLICATECODE_EMBED_ENDPOINT` overrides it): pointing that
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 <preset>` is the short form (`minilm`, `qwen3`, `potion`, `openai`, `cohere`; `qwen3` is about 5x slower than `minilm` on CPU, so use it with a GPU or prefer `minilm`) and implies
80
- `--embed-code`. Model ids go into the cache key, so vectors of different models never mix.
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: start the server once (CPU; it loads whichever model a request names)
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 --min-name 0 --embed minilm --pairs --explain
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
- (use with `--min-name 0`) but has no real-repo precision data yet.
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 (default `127.0.0.1:8099`; `DUPLICATECODE_EMBED_ENDPOINT` overrides it): pointing that
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 <preset>` is the short form (`minilm`, `qwen3`, `potion`, `openai`, `cohere`; `qwen3` is about 5x slower than `minilm` on CPU, so use it with a GPU or prefer `minilm`) and implies
70
- `--embed-code`. Model ids go into the cache key, so vectors of different models never mix.
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: start the server once (CPU; it loads whichever model a request names)
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 --min-name 0 --embed minilm --pairs --explain
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
- (use with `--min-name 0`) but has no real-repo precision data yet.
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`. Local presets talk to `eval/embed_server.py` (default
15
- /// http://127.0.0.1:8099/v1, override with DUPLICATECODE_EMBED_ENDPOINT), which loads the named model.
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
- /// all-MiniLM-L6-v2, local: fastest, tied for best quality in the bake-off
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(|_| "http://127.0.0.1:8099/v1".into());
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::Minilm => local("sentence-transformers/all-MiniLM-L6-v2")?,
51
- Preset::Qwen3 => local("Qwen/Qwen3-Embedding-0.6B")?,
52
- Preset::Potion => local("minishlab/potion-base-8M")?,
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
- #[arg(long, value_enum)]
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
- #[arg(long, default_value_t = 0.3)]
302
- min_name: f64,
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, use with --min-name 0).
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
- /// Ignore units shorter than this many lines.
308
- #[arg(long, default_value_t = 6)]
309
- min_lines: u32,
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, default_value_t = 0.3)]
326
- min_name: f64,
327
- #[arg(long, default_value_t = 6)]
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
- #[arg(long, default_value_t = 0.3)]
417
- min_name: f64,
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, use with --min-name 0).
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
- /// Ignore units shorter than this many lines.
423
- #[arg(long, default_value_t = 6)]
424
- min_lines: u32,
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
- groups.sort_by(|a, b| b.score.total_cmp(&a.score));
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: 3,
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| b.scores.combined.total_cmp(&a.scores.combined));
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
+ }