duplicatecode 0.3.2__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.2 → duplicatecode-0.4.0}/Cargo.lock +3 -2
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/Cargo.toml +1 -1
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/PKG-INFO +17 -3
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/README.md +16 -2
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/bench.rs +2 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/main.rs +91 -25
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/review.rs +2 -0
- {duplicatecode-0.3.2 → 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.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/fragments.rs +87 -12
- {duplicatecode-0.3.2 → 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.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/units.rs +21 -3
- duplicatecode-0.3.2/crates/duplicatecode-engine/src/diff.rs +0 -74
- duplicatecode-0.3.2/crates/duplicatecode-engine/src/fingerprint.rs +0 -24
- duplicatecode-0.3.2/crates/duplicatecode-engine/src/similarity.rs +0 -117
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-cli/Cargo.toml +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-cli/src/groups.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/embed.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/explain.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lang.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/lib.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/mutate.rs +0 -0
- {duplicatecode-0.3.2 → duplicatecode-0.4.0}/crates/duplicatecode-engine/src/naming.rs +0 -0
- {duplicatecode-0.3.2 → 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#
|
|
@@ -80,6 +80,19 @@ Two optional signals, both off by default (the detector stays static and offline
|
|
|
80
80
|
[ollama](https://ollama.com): `ollama pull embeddinggemma`), the best model in the codebase benchmark (issue #12: re-implementation
|
|
81
81
|
retrieval R@1 0.94 vs 0.89 for the static detector alone, unchanged when function names are masked). Other presets:
|
|
82
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
|
+
|
|
83
96
|
- `--embed-code` embeds the *whole text of every unit* (function, class, file, SQL statement) and blends
|
|
84
97
|
the cosine into the score (`--embed-weight`, default 0.35, others scaled by 1 - weight). It finds
|
|
85
98
|
re-implementations that share no tokens. On function-level LLM re-implementations it lifted Python from
|
|
@@ -130,7 +143,7 @@ Shared options: `--profile copies|reimpl`, `--threshold`, `--min-tokens`, `--min
|
|
|
130
143
|
`--embed-weight`, `--embed-max-chars`, `--embeddings`, `--embed-cache`, `--embed-dims`.
|
|
131
144
|
|
|
132
145
|
```sh
|
|
133
|
-
duplicatecode scan . --profile reimpl --
|
|
146
|
+
duplicatecode scan . --profile reimpl --embed minilm --pairs --explain
|
|
134
147
|
duplicatecode fragments src/ --min-stmts 4 --min-tokens 30 --cross-file --json
|
|
135
148
|
duplicatecode find "retry with exponential backoff" src/ --top 5 --json
|
|
136
149
|
duplicatecode diff --repo . < change.diff --embed openai
|
|
@@ -223,7 +236,8 @@ Self-scans of three internal repositories (Python + TypeScript), 189 pairs judge
|
|
|
223
236
|
judged sample gives ~50% true duplicates and ~76% incl. partial while keeping ~70% of the true ones.
|
|
224
237
|
These numbers are partly in-sample (weights and filters were chosen on the same pairs) — re-judge a fresh sample
|
|
225
238
|
before trusting them. A structure-heavy `--profile reimpl` exists for renamed re-implementations
|
|
226
|
-
(
|
|
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.
|
|
227
241
|
|
|
228
242
|
### Fresh held-out check (copies profile, threshold 0.6 = the `copies` default for `scan`)
|
|
229
243
|
|
|
@@ -70,6 +70,19 @@ Two optional signals, both off by default (the detector stays static and offline
|
|
|
70
70
|
[ollama](https://ollama.com): `ollama pull embeddinggemma`), the best model in the codebase benchmark (issue #12: re-implementation
|
|
71
71
|
retrieval R@1 0.94 vs 0.89 for the static detector alone, unchanged when function names are masked). Other presets:
|
|
72
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
|
+
|
|
73
86
|
- `--embed-code` embeds the *whole text of every unit* (function, class, file, SQL statement) and blends
|
|
74
87
|
the cosine into the score (`--embed-weight`, default 0.35, others scaled by 1 - weight). It finds
|
|
75
88
|
re-implementations that share no tokens. On function-level LLM re-implementations it lifted Python from
|
|
@@ -120,7 +133,7 @@ Shared options: `--profile copies|reimpl`, `--threshold`, `--min-tokens`, `--min
|
|
|
120
133
|
`--embed-weight`, `--embed-max-chars`, `--embeddings`, `--embed-cache`, `--embed-dims`.
|
|
121
134
|
|
|
122
135
|
```sh
|
|
123
|
-
duplicatecode scan . --profile reimpl --
|
|
136
|
+
duplicatecode scan . --profile reimpl --embed minilm --pairs --explain
|
|
124
137
|
duplicatecode fragments src/ --min-stmts 4 --min-tokens 30 --cross-file --json
|
|
125
138
|
duplicatecode find "retry with exponential backoff" src/ --top 5 --json
|
|
126
139
|
duplicatecode diff --repo . < change.diff --embed openai
|
|
@@ -213,7 +226,8 @@ Self-scans of three internal repositories (Python + TypeScript), 189 pairs judge
|
|
|
213
226
|
judged sample gives ~50% true duplicates and ~76% incl. partial while keeping ~70% of the true ones.
|
|
214
227
|
These numbers are partly in-sample (weights and filters were chosen on the same pairs) — re-judge a fresh sample
|
|
215
228
|
before trusting them. A structure-heavy `--profile reimpl` exists for renamed re-implementations
|
|
216
|
-
(
|
|
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.
|
|
217
231
|
|
|
218
232
|
### Fresh held-out check (copies profile, threshold 0.6 = the `copies` default for `scan`)
|
|
219
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
|
|
@@ -247,6 +247,22 @@ impl Profile {
|
|
|
247
247
|
/// too (measured with MiniLM: score at 1% / 0.1% false-positive rate goes from ~0.27 / 0.35 to
|
|
248
248
|
/// ~0.33 / 0.42), so the `reimpl` defaults move up with it. `copies` thresholds were tuned on
|
|
249
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
|
+
|
|
250
266
|
fn default_threshold(self, command: Command, embedded: bool) -> f64 {
|
|
251
267
|
match (self, command) {
|
|
252
268
|
(Profile::Copies, Command::Diff) => 0.4,
|
|
@@ -304,15 +320,21 @@ enum Cmd {
|
|
|
304
320
|
#[arg(long, default_value_t = 8)]
|
|
305
321
|
min_tokens: usize,
|
|
306
322
|
/// Minimum name similarity (0 = also report look-alikes with unrelated names).
|
|
307
|
-
|
|
308
|
-
|
|
323
|
+
/// Default 0.3 for `copies`, 0 for `reimpl`.
|
|
324
|
+
#[arg(long)]
|
|
325
|
+
min_name: Option<f64>,
|
|
309
326
|
/// `copies`: tuned on real repos, favours same-name copy-paste. `reimpl`: structure-heavy,
|
|
310
|
-
/// finds renamed re-implementations (experimental
|
|
327
|
+
/// finds renamed re-implementations (experimental; implies --min-name 0).
|
|
311
328
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
312
329
|
profile: Profile,
|
|
313
|
-
///
|
|
314
|
-
|
|
315
|
-
|
|
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>,
|
|
316
338
|
#[arg(long)]
|
|
317
339
|
json: bool,
|
|
318
340
|
},
|
|
@@ -328,10 +350,10 @@ enum Cmd {
|
|
|
328
350
|
/// Lower = more candidates (recall), higher = fewer (precision).
|
|
329
351
|
#[arg(long)]
|
|
330
352
|
threshold: Option<f64>,
|
|
331
|
-
#[arg(long
|
|
332
|
-
min_name: f64
|
|
333
|
-
#[arg(long
|
|
334
|
-
min_lines: u32
|
|
353
|
+
#[arg(long)]
|
|
354
|
+
min_name: Option<f64>,
|
|
355
|
+
#[arg(long)]
|
|
356
|
+
min_lines: Option<u32>,
|
|
335
357
|
#[arg(long)]
|
|
336
358
|
skip_tests: bool,
|
|
337
359
|
#[arg(long, default_value_t = 40)]
|
|
@@ -349,6 +371,10 @@ enum Cmd {
|
|
|
349
371
|
preview_lines: usize,
|
|
350
372
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
351
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,
|
|
352
378
|
},
|
|
353
379
|
/// Embed identifiers and print their cosine similarity (to check credentials and calibrate `--embed-floor`).
|
|
354
380
|
EmbedTest {
|
|
@@ -389,6 +415,9 @@ enum Cmd {
|
|
|
389
415
|
/// Minimum number of consecutive identical statements.
|
|
390
416
|
#[arg(long, default_value_t = 4)]
|
|
391
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,
|
|
392
421
|
/// Minimum normalized tokens in the shared run.
|
|
393
422
|
#[arg(long, default_value_t = 30)]
|
|
394
423
|
min_tokens: usize,
|
|
@@ -419,15 +448,24 @@ enum Cmd {
|
|
|
419
448
|
#[arg(long, default_value_t = 8)]
|
|
420
449
|
min_tokens: usize,
|
|
421
450
|
/// Minimum name similarity (0 = also report look-alikes with unrelated names).
|
|
422
|
-
|
|
423
|
-
|
|
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,
|
|
424
457
|
/// `copies`: tuned on real repos, favours same-name copy-paste. `reimpl`: structure-heavy,
|
|
425
|
-
/// finds renamed re-implementations (experimental
|
|
458
|
+
/// finds renamed re-implementations (experimental; implies --min-name 0).
|
|
426
459
|
#[arg(long, value_enum, default_value_t = Profile::Copies)]
|
|
427
460
|
profile: Profile,
|
|
428
|
-
///
|
|
429
|
-
|
|
430
|
-
|
|
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>,
|
|
431
469
|
/// Ignore test code (about half of the noise in the judged repos, but also some real copies).
|
|
432
470
|
#[arg(long)]
|
|
433
471
|
skip_tests: bool,
|
|
@@ -562,7 +600,13 @@ fn group_pairs(pairs: &[duplicatecode_engine::Match]) -> Vec<Group> {
|
|
|
562
600
|
}
|
|
563
601
|
})
|
|
564
602
|
.collect();
|
|
565
|
-
|
|
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
|
+
});
|
|
566
610
|
groups
|
|
567
611
|
}
|
|
568
612
|
|
|
@@ -589,6 +633,7 @@ fn main() -> Result<()> {
|
|
|
589
633
|
min_tokens,
|
|
590
634
|
min_name,
|
|
591
635
|
profile,
|
|
636
|
+
idf,
|
|
592
637
|
min_lines,
|
|
593
638
|
json,
|
|
594
639
|
} => {
|
|
@@ -621,9 +666,10 @@ fn main() -> Result<()> {
|
|
|
621
666
|
let opts = MatchOptions {
|
|
622
667
|
threshold,
|
|
623
668
|
min_tokens,
|
|
624
|
-
min_name,
|
|
625
|
-
min_lines,
|
|
669
|
+
min_name: min_name.unwrap_or(profile.default_min_name()),
|
|
670
|
+
min_lines: min_lines.unwrap_or(profile.default_min_lines()),
|
|
626
671
|
weights: embed.weights(profile.weights()),
|
|
672
|
+
use_idf: idf,
|
|
627
673
|
..Default::default()
|
|
628
674
|
};
|
|
629
675
|
// when both sides are new code the pair shows up twice; report it once
|
|
@@ -668,6 +714,7 @@ fn main() -> Result<()> {
|
|
|
668
714
|
tier,
|
|
669
715
|
preview_lines,
|
|
670
716
|
profile,
|
|
717
|
+
idf,
|
|
671
718
|
} => {
|
|
672
719
|
let threshold = threshold
|
|
673
720
|
.unwrap_or(profile.default_threshold(Command::Review, embed.code_enabled()));
|
|
@@ -689,8 +736,8 @@ fn main() -> Result<()> {
|
|
|
689
736
|
embed.apply(&mut units)?;
|
|
690
737
|
let o = review::ReviewOptions {
|
|
691
738
|
threshold,
|
|
692
|
-
min_name,
|
|
693
|
-
min_lines,
|
|
739
|
+
min_name: min_name.unwrap_or(profile.default_min_name()),
|
|
740
|
+
min_lines: min_lines.unwrap_or(profile.default_min_lines()),
|
|
694
741
|
skip_tests,
|
|
695
742
|
max_groups,
|
|
696
743
|
offset,
|
|
@@ -698,6 +745,7 @@ fn main() -> Result<()> {
|
|
|
698
745
|
tier,
|
|
699
746
|
preview_lines,
|
|
700
747
|
weights: embed.weights(profile.weights()),
|
|
748
|
+
use_idf: idf,
|
|
701
749
|
};
|
|
702
750
|
print!("{}", review::run(units, &src, &o));
|
|
703
751
|
}
|
|
@@ -776,6 +824,7 @@ fn main() -> Result<()> {
|
|
|
776
824
|
paths,
|
|
777
825
|
exclude,
|
|
778
826
|
min_stmts,
|
|
827
|
+
max_gap,
|
|
779
828
|
min_tokens,
|
|
780
829
|
skip_tests,
|
|
781
830
|
cross_file,
|
|
@@ -788,6 +837,7 @@ fn main() -> Result<()> {
|
|
|
788
837
|
duplicatecode_engine::fragments::FragmentOptions {
|
|
789
838
|
min_stmts,
|
|
790
839
|
min_tokens,
|
|
840
|
+
max_gap,
|
|
791
841
|
..Default::default()
|
|
792
842
|
},
|
|
793
843
|
);
|
|
@@ -867,7 +917,9 @@ fn main() -> Result<()> {
|
|
|
867
917
|
threshold,
|
|
868
918
|
min_tokens,
|
|
869
919
|
min_name,
|
|
920
|
+
top_n,
|
|
870
921
|
profile,
|
|
922
|
+
idf,
|
|
871
923
|
min_lines,
|
|
872
924
|
skip_tests,
|
|
873
925
|
cross_file,
|
|
@@ -887,11 +939,12 @@ fn main() -> Result<()> {
|
|
|
887
939
|
let opts = MatchOptions {
|
|
888
940
|
threshold,
|
|
889
941
|
min_tokens,
|
|
890
|
-
top_n
|
|
891
|
-
min_name,
|
|
892
|
-
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()),
|
|
893
945
|
skip_tests,
|
|
894
946
|
weights: embed.weights(profile.weights()),
|
|
947
|
+
use_idf: idf,
|
|
895
948
|
..Default::default()
|
|
896
949
|
};
|
|
897
950
|
let mut seen = std::collections::HashSet::new();
|
|
@@ -908,7 +961,20 @@ fn main() -> Result<()> {
|
|
|
908
961
|
seen.insert(if a < b { (a, b) } else { (b, a) })
|
|
909
962
|
})
|
|
910
963
|
.collect();
|
|
911
|
-
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
|
+
});
|
|
912
978
|
let groups = group_pairs(&pairs);
|
|
913
979
|
// keyed by the full place: units that start on the same line (minified code, one-line
|
|
914
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
use std::collections::hash_map::DefaultHasher;
|
|
2
|
+
use std::hash::{Hash, Hasher};
|
|
3
|
+
|
|
4
|
+
/// Sorted, distinct hashes of every k-gram of the normalized token stream.
|
|
5
|
+
pub fn kgram_hashes(tokens: &[String], k: usize) -> Vec<u64> {
|
|
6
|
+
let mut out = Vec::new();
|
|
7
|
+
if tokens.len() < k {
|
|
8
|
+
if !tokens.is_empty() {
|
|
9
|
+
out.push(hash_slice(tokens));
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
out.extend(tokens.windows(k).map(hash_slice));
|
|
14
|
+
out.sort_unstable();
|
|
15
|
+
out.dedup();
|
|
16
|
+
out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn hash_slice(w: &[String]) -> u64 {
|
|
20
|
+
let mut h = DefaultHasher::new();
|
|
21
|
+
w.hash(&mut h);
|
|
22
|
+
h.finish()
|
|
23
|
+
}
|