code-gauge 4.5.0 → 4.7.0

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 (49) hide show
  1. package/README.md +39 -6
  2. package/THIRD-PARTY-NOTICES.txt +8911 -0
  3. package/dist/cliConfig.cjs +1 -1
  4. package/dist/cliConfig.cjs.map +1 -1
  5. package/dist/cliConfig.js +1 -1
  6. package/dist/crossFileDuplication.cjs +1 -1
  7. package/dist/crossFileDuplication.cjs.map +1 -1
  8. package/dist/crossFileDuplication.d.ts +2 -2
  9. package/dist/crossFileDuplication.js +1 -1
  10. package/dist/crossFileDuplication.js.map +1 -1
  11. package/dist/crossFileNearMiss.cjs +1 -1
  12. package/dist/crossFileNearMiss.cjs.map +1 -1
  13. package/dist/crossFileNearMiss.d.ts +18 -12
  14. package/dist/crossFileNearMiss.js +1 -1
  15. package/dist/crossFileNearMiss.js.map +1 -1
  16. package/dist/diffCommand.cjs +1 -1
  17. package/dist/diffCommand.cjs.map +1 -1
  18. package/dist/diffCommand.js +1 -1
  19. package/dist/languages.cjs +1 -1
  20. package/dist/languages.cjs.map +1 -1
  21. package/dist/languages.js +1 -1
  22. package/dist/languages.js.map +1 -1
  23. package/dist/nativeMetrics.cjs +3 -3
  24. package/dist/nativeMetrics.cjs.map +1 -1
  25. package/dist/nativeMetrics.d.ts +14 -0
  26. package/dist/nativeMetrics.js +3 -3
  27. package/dist/nativeMetrics.js.map +1 -1
  28. package/dist/scan.cjs +1 -1
  29. package/dist/scan.js +1 -1
  30. package/dist/types.d.ts +3 -1
  31. package/dist/wasmBinding.cjs +2 -0
  32. package/dist/wasmBinding.cjs.map +1 -0
  33. package/dist/wasmBinding.d.ts +9 -0
  34. package/dist/wasmBinding.js +2 -0
  35. package/dist/wasmBinding.js.map +1 -0
  36. package/dist/worker.cjs +2 -0
  37. package/dist/worker.cjs.map +1 -0
  38. package/dist/worker.d.ts +1 -0
  39. package/dist/worker.js +2 -0
  40. package/dist/worker.js.map +1 -0
  41. package/native/Cargo.toml +5 -2
  42. package/native/build.rs +4 -1
  43. package/native/code-gauge.wasm +0 -0
  44. package/native/src/duplication.rs +332 -234
  45. package/native/src/lib.rs +32 -37
  46. package/native/src/napi.rs +45 -0
  47. package/native/src/near_miss.rs +455 -0
  48. package/native/src/wasm.rs +128 -0
  49. package/package.json +18 -13
package/native/src/lib.rs CHANGED
@@ -1,8 +1,5 @@
1
1
  #![deny(clippy::all)]
2
2
 
3
- use napi::bindgen_prelude::*;
4
- use napi_derive::napi;
5
-
6
3
  use crate::duplication::DuplicationSettings;
7
4
 
8
5
  mod complexity;
@@ -11,15 +8,20 @@ mod duplication;
11
8
  mod functions;
12
9
  mod languages;
13
10
  mod measure;
11
+ #[cfg(not(target_family = "wasm"))]
12
+ mod napi;
14
13
  mod ncss;
14
+ mod near_miss;
15
15
  mod types;
16
16
  mod util;
17
+ #[cfg(target_family = "wasm")]
18
+ mod wasm;
17
19
 
18
20
  /// Version of the native payload schema. The TypeScript wrapper refuses a binding whose version
19
21
  /// differs from the one it expects, so a stale prebuilt addon fails with a clear rebuild message
20
22
  /// instead of silently returning an incompatible payload. Bump on every payload-shape change,
21
23
  /// together with `expectedPayloadVersion` in src/nativeMetrics.ts.
22
- #[napi]
24
+ /// scripts/installNative.mjs parses the literal from this function's source.
23
25
  pub fn payload_version() -> u32 {
24
26
  7
25
27
  }
@@ -30,59 +32,52 @@ pub fn payload_version() -> u32 {
30
32
  /// The TypeScript wrapper derives the remaining float metrics (Halstead volume/effort/...): they
31
33
  /// involve transcendental functions whose last-bit results can differ between V8 and Rust's libm,
32
34
  /// and results must not depend on which side computes them.
33
- #[napi]
34
- pub fn measure_code_native(
35
- code: String,
36
- language: String,
37
- include_syntax_tree: Option<bool>,
35
+ fn measure_code(
36
+ code: &str,
37
+ language: &str,
38
+ include_syntax_tree: bool,
38
39
  min_tokens: Option<u32>,
39
40
  max_gap_tokens: Option<u32>,
40
41
  min_similarity_percent: Option<u32>,
41
- include_cross_file_data: Option<bool>,
42
- ) -> Result<String> {
43
- let definition = find_language(&language)?;
42
+ include_cross_file_data: bool,
43
+ ) -> Result<String, String> {
44
+ let definition = find_language(language)?;
44
45
  let settings = to_duplication_settings(min_tokens, max_gap_tokens, min_similarity_percent);
45
46
  let metrics = measure::measure(
46
- &code,
47
+ code,
47
48
  definition,
48
- include_syntax_tree.unwrap_or(false),
49
- include_cross_file_data.unwrap_or(false),
49
+ include_syntax_tree,
50
+ include_cross_file_data,
50
51
  &settings,
51
- )
52
- .map_err(Error::from_reason)?;
53
- serde_json::to_string(&metrics).map_err(|error| Error::from_reason(error.to_string()))
52
+ )?;
53
+ serde_json::to_string(&metrics).map_err(|error| error.to_string())
54
54
  }
55
55
 
56
56
  /// Collects one file's cross-file clone-detection contribution (candidates, normalized token
57
57
  /// stream, statement structure, and code line numbers) as JSON; see CrossFileFileData.
58
- #[napi]
59
- pub fn collect_cross_file_data_native(
60
- code: String,
61
- language: String,
58
+ fn collect_cross_file_data(
59
+ code: &str,
60
+ language: &str,
62
61
  min_tokens: Option<u32>,
63
- ) -> Result<String> {
64
- let definition = find_language(&language)?;
62
+ ) -> Result<String, String> {
63
+ let definition = find_language(language)?;
65
64
  let min_tokens = min_tokens
66
65
  .map(|value| value as usize)
67
66
  .unwrap_or(DuplicationSettings::default().min_tokens);
68
- let data = measure::collect_cross_file_data(&code, definition, min_tokens)
69
- .map_err(Error::from_reason)?;
70
- serde_json::to_string(&data).map_err(|error| Error::from_reason(error.to_string()))
67
+ let data = measure::collect_cross_file_data(code, definition, min_tokens)?;
68
+ serde_json::to_string(&data).map_err(|error| error.to_string())
71
69
  }
72
70
 
73
71
  /// Collects normalized token hash sequences of every function as JSON (number[][]),
74
- /// index-parallel to the functions array of measure_code_native.
75
- #[napi]
76
- pub fn collect_function_token_sequences_native(code: String, language: String) -> Result<String> {
77
- let definition = find_language(&language)?;
78
- let sequences =
79
- measure::collect_function_token_sequences(&code, definition).map_err(Error::from_reason)?;
80
- serde_json::to_string(&sequences).map_err(|error| Error::from_reason(error.to_string()))
72
+ /// index-parallel to the functions array of measure_code.
73
+ fn collect_function_token_sequences(code: &str, language: &str) -> Result<String, String> {
74
+ let definition = find_language(language)?;
75
+ let sequences = measure::collect_function_token_sequences(code, definition)?;
76
+ serde_json::to_string(&sequences).map_err(|error| error.to_string())
81
77
  }
82
78
 
83
- fn find_language(language: &str) -> Result<&'static languages::LanguageDefinition> {
84
- languages::find_language(language)
85
- .ok_or_else(|| Error::from_reason(format!("Unsupported language: {language}")))
79
+ fn find_language(language: &str) -> Result<&'static languages::LanguageDefinition, String> {
80
+ languages::find_language(language).ok_or_else(|| format!("Unsupported language: {language}"))
86
81
  }
87
82
 
88
83
  fn to_duplication_settings(
@@ -0,0 +1,45 @@
1
+ //! The N-API binding loaded by Node.js (src/nativeMetrics.ts).
2
+
3
+ use napi::bindgen_prelude::*;
4
+ use napi_derive::napi;
5
+
6
+ #[napi]
7
+ pub fn payload_version() -> u32 {
8
+ crate::payload_version()
9
+ }
10
+
11
+ #[napi]
12
+ pub fn measure_code_native(
13
+ code: String,
14
+ language: String,
15
+ include_syntax_tree: Option<bool>,
16
+ min_tokens: Option<u32>,
17
+ max_gap_tokens: Option<u32>,
18
+ min_similarity_percent: Option<u32>,
19
+ include_cross_file_data: Option<bool>,
20
+ ) -> Result<String> {
21
+ crate::measure_code(
22
+ &code,
23
+ &language,
24
+ include_syntax_tree.unwrap_or(false),
25
+ min_tokens,
26
+ max_gap_tokens,
27
+ min_similarity_percent,
28
+ include_cross_file_data.unwrap_or(false),
29
+ )
30
+ .map_err(Error::from_reason)
31
+ }
32
+
33
+ #[napi]
34
+ pub fn collect_cross_file_data_native(
35
+ code: String,
36
+ language: String,
37
+ min_tokens: Option<u32>,
38
+ ) -> Result<String> {
39
+ crate::collect_cross_file_data(&code, &language, min_tokens).map_err(Error::from_reason)
40
+ }
41
+
42
+ #[napi]
43
+ pub fn collect_function_token_sequences_native(code: String, language: String) -> Result<String> {
44
+ crate::collect_function_token_sequences(&code, &language).map_err(Error::from_reason)
45
+ }
@@ -0,0 +1,455 @@
1
+ use std::collections::{HashMap, HashSet};
2
+
3
+ /// N-gram size for the candidate index and local-match anchors (NIL's default); shared with
4
+ /// crossFileNearMiss.ts.
5
+ const NGRAM_SIZE: usize = 5;
6
+ /// Filtration threshold: shared distinct n-grams over the smaller set; shared with
7
+ /// crossFileNearMiss.ts.
8
+ pub(crate) const FILTRATION_PERCENT: usize = 10;
9
+ /// Pairs whose longer block exceeds this multiple of the shorter are compared only when whole-block
10
+ /// similarity still allows their ratio (below a minSimilarityPercent of 34). The candidate scan stops
11
+ /// at this floor while walking length-ordered postings, so pairs of very different lengths are
12
+ /// neither counted nor verified. Shared with crossFileNearMiss.ts.
13
+ pub(crate) const MAX_LENGTH_RATIO: usize = 3;
14
+ /// Exclusive bound on the information-weighted share of content-bearing tokens (names and literal
15
+ /// values); shared with crossFileNearMiss.ts.
16
+ const MIN_CONTENT_SIMILARITY_PERCENT: u64 = 50;
17
+ /// Caps content weights so only names and values spread over more than a quarter of the blocks
18
+ /// are discounted: a family of copies shares its content across several blocks, and uncapped
19
+ /// rarity weighting would let each copy's few unique edits outweigh everything the family shares.
20
+ /// Shared with crossFileNearMiss.ts.
21
+ const MAX_CONTENT_WEIGHT: u64 = 3;
22
+ /// Anchors must cover at least this percent of the shorter core: sparser chains are coincidental
23
+ /// runs of common n-grams in merely similar-looking code, and the cheap bound spares their content
24
+ /// and LCS checks. Not the similarity threshold itself, since n-grams repeated within a block
25
+ /// (repetitive statements) never anchor. Shared with crossFileNearMiss.ts.
26
+ const MIN_ANCHOR_COVERAGE_PERCENT: usize = 50;
27
+ /// Anchors farther apart than this (in either block) split a local match into separate chains;
28
+ /// shared with crossFileNearMiss.ts.
29
+ const MAX_ANCHOR_GAP_TOKENS: usize = 30;
30
+ /// Statement-order-insensitive comparison needs this many top-level statements per block.
31
+ const MIN_REORDER_STATEMENT_COUNT: usize = 2;
32
+ /// Every identifier in the identifier-blind sequences n-grams are hashed over.
33
+ const BLIND_IDENTIFIER: i32 = -1;
34
+
35
+ /// A near-miss block: a token range of one file's symbol stream, where symbols >= 0 are interned
36
+ /// non-identifier tokens (literal values folded in) and identifiers are -(file-level id + 1).
37
+ pub(crate) struct Block {
38
+ pub start: usize,
39
+ symbols: Vec<i32>,
40
+ is_content: Vec<bool>,
41
+ /// Identifiers anonymized by first occurrence within the block.
42
+ sequence: Vec<i32>,
43
+ pub ngrams: HashSet<i32>,
44
+ /// The n-grams occurring exactly once in the block with their offsets, sorted by hash so two
45
+ /// blocks' local-match anchors intersect by merging.
46
+ unique_ngrams: Vec<(i32, usize)>,
47
+ /// Content-bearing symbols (names and literal values) with their counts, sorted by symbol;
48
+ /// Matcher::new turns the counts into information-weighted counts.
49
+ content: Vec<(i32, u64)>,
50
+ content_total: u64,
51
+ /// The sequence with its top-level statements in canonical order, when it has enough of them
52
+ /// for statement-order-insensitive comparison.
53
+ canonical_sequence: Option<Vec<i32>>,
54
+ }
55
+
56
+ impl Block {
57
+ pub fn new(
58
+ symbols: &[i32],
59
+ is_content: &[bool],
60
+ start: usize,
61
+ end: usize,
62
+ statements: Vec<(usize, usize)>,
63
+ ) -> Block {
64
+ let symbols = symbols[start..end].to_vec();
65
+ let is_content = is_content[start..end].to_vec();
66
+ // Identifier-blind, so a block copied into different surroundings (renumbering its
67
+ // identifiers) or with reordered statements still shares its n-grams.
68
+ let ngram_hashes: Vec<i32> = symbols
69
+ .windows(NGRAM_SIZE)
70
+ .map(|window| {
71
+ window.iter().fold(5381i32, |hash, &symbol| {
72
+ hash.wrapping_mul(31).wrapping_add(if symbol < 0 {
73
+ BLIND_IDENTIFIER
74
+ } else {
75
+ symbol
76
+ })
77
+ })
78
+ })
79
+ .collect();
80
+ let mut occurrence_counts: HashMap<i32, usize> = HashMap::new();
81
+ for &hash in &ngram_hashes {
82
+ *occurrence_counts.entry(hash).or_insert(0) += 1;
83
+ }
84
+ let mut unique_ngrams: Vec<(i32, usize)> = ngram_hashes
85
+ .iter()
86
+ .enumerate()
87
+ .filter(|(_, hash)| occurrence_counts[hash] == 1)
88
+ .map(|(offset, &hash)| (hash, offset))
89
+ .collect();
90
+ unique_ngrams.sort_unstable();
91
+ let canonical_sequence = (statements.len() >= MIN_REORDER_STATEMENT_COUNT).then(|| {
92
+ canonical_sequence(
93
+ &symbols,
94
+ statements.iter().map(|&(statement_start, statement_end)| {
95
+ (statement_start - start, statement_end - start)
96
+ }),
97
+ )
98
+ });
99
+ Block {
100
+ start,
101
+ sequence: anonymize(&symbols),
102
+ ngrams: occurrence_counts.into_keys().collect(),
103
+ unique_ngrams,
104
+ content: count_content(&symbols, &is_content),
105
+ content_total: 0,
106
+ canonical_sequence,
107
+ symbols,
108
+ is_content,
109
+ }
110
+ }
111
+
112
+ pub fn len(&self) -> usize {
113
+ self.symbols.len()
114
+ }
115
+ }
116
+
117
+ /// A verified core in each block of a pair, as absolute token ranges.
118
+ pub(crate) type CorePair = ((usize, usize), (usize, usize));
119
+
120
+ /// How a verified pair matched: whole blocks, or every anchored core pair (one per gap-split
121
+ /// chain segment) where the blocks share a copy embedded in different code.
122
+ pub(crate) enum PairMatch {
123
+ Whole,
124
+ Local(Vec<CorePair>),
125
+ }
126
+
127
+ /// Verifies near-miss block pairs: token-level LCS against the larger side (NiCad's per-fragment
128
+ /// similarity), backed by an information-weighted content gate, with a statement-order-insensitive
129
+ /// fallback and a local match over the anchored cores of two blocks.
130
+ pub(crate) struct Matcher {
131
+ min_tokens: usize,
132
+ min_similarity_percent: usize,
133
+ /// Integer self-information per content symbol, 1 + floor(log2((N + 1) / df)) over N blocks,
134
+ /// capped at MAX_CONTENT_WEIGHT.
135
+ /// Rare names and values (the logic a copy preserves) outweigh ubiquitous ones, following
136
+ /// the information-theoretic weighting of ECScan's essence-clone detection (2025).
137
+ weights: HashMap<i32, u64>,
138
+ }
139
+
140
+ impl Matcher {
141
+ /// Weights every block's content counts, which verification requires.
142
+ pub fn new(blocks: &mut [Block], min_tokens: usize, min_similarity_percent: usize) -> Matcher {
143
+ let mut document_frequencies: HashMap<i32, usize> = HashMap::new();
144
+ for block in blocks.iter() {
145
+ for &(symbol, _) in &block.content {
146
+ *document_frequencies.entry(symbol).or_insert(0) += 1;
147
+ }
148
+ }
149
+ let block_count = blocks.len();
150
+ let self_information = |document_frequency: usize| {
151
+ (((block_count + 1) / document_frequency).ilog2() as u64 + 1).min(MAX_CONTENT_WEIGHT)
152
+ };
153
+ let matcher = Matcher {
154
+ min_tokens,
155
+ min_similarity_percent,
156
+ weights: document_frequencies
157
+ .into_iter()
158
+ .map(|(symbol, frequency)| (symbol, self_information(frequency)))
159
+ .collect(),
160
+ };
161
+ for block in blocks.iter_mut() {
162
+ block.content_total = matcher.weigh(&mut block.content);
163
+ }
164
+ matcher
165
+ }
166
+
167
+ /// Multiplies each count by its symbol's weight, returning the weighted total.
168
+ fn weigh(&self, content: &mut [(i32, u64)]) -> u64 {
169
+ for (symbol, count) in content.iter_mut() {
170
+ // Span content comes from blocks, so every symbol has a weight.
171
+ *count *= self.weights[symbol];
172
+ }
173
+ content.iter().map(|&(_, count)| count).sum()
174
+ }
175
+
176
+ pub fn verify(&self, left: &Block, right: &Block) -> Option<PairMatch> {
177
+ let required = self.min_similarity_percent * left.len().max(right.len());
178
+ if left.len().min(right.len()) * 100 >= required
179
+ && shares_content(
180
+ &left.content,
181
+ left.content_total,
182
+ &right.content,
183
+ right.content_total,
184
+ )
185
+ && (lcs_length(&left.sequence, &right.sequence) * 100 >= required
186
+ || self.matches_reordered(left, right, required))
187
+ {
188
+ return Some(PairMatch::Whole);
189
+ }
190
+ self.match_locally(left, right)
191
+ }
192
+
193
+ /// Compares the blocks with their top-level statements (each anonymized on its own) in a
194
+ /// canonical order, so a copy whose independent statements were swapped still matches.
195
+ fn matches_reordered(&self, left: &Block, right: &Block, required: usize) -> bool {
196
+ match (&left.canonical_sequence, &right.canonical_sequence) {
197
+ (Some(left), Some(right)) => lcs_length(left, right) * 100 >= required,
198
+ _ => false,
199
+ }
200
+ }
201
+
202
+ /// Matches the cores two blocks share inside different surroundings (a copy wrapped in added
203
+ /// code, or two copies embedded in different code), which whole-block similarity misses
204
+ /// (CCAligner's large-gap and LVMapper's large-variance clones). N-grams unique to each block
205
+ /// anchor the alignment; their longest chain increasing in both blocks (a run filter keeps only
206
+ /// anchors continuing a diagonal, but the chain may shift diagonals at small insertions), split
207
+ /// at gaps, delimits the cores, and every core pair that is a near-miss clone in its own right
208
+ /// is returned.
209
+ fn match_locally(&self, left: &Block, right: &Block) -> Option<PairMatch> {
210
+ let mut anchors: Vec<(usize, usize)> = Vec::new();
211
+ let (mut left_index, mut right_index) = (0, 0);
212
+ while let (Some(&(left_hash, left_offset)), Some(&(right_hash, right_offset))) = (
213
+ left.unique_ngrams.get(left_index),
214
+ right.unique_ngrams.get(right_index),
215
+ ) {
216
+ if left_hash == right_hash {
217
+ anchors.push((left_offset, right_offset));
218
+ }
219
+ left_index += usize::from(left_hash <= right_hash);
220
+ right_index += usize::from(right_hash <= left_hash);
221
+ }
222
+ anchors.sort_unstable();
223
+ // An isolated 5-gram match is often coincidental (n-grams are identifier-blind); a copied
224
+ // core yields runs of consecutive anchors, so only anchors continuing a diagonal run are
225
+ // chained.
226
+ let run_anchors: Vec<(usize, usize)> = (0..anchors.len())
227
+ .filter(|&index| {
228
+ let (left_offset, right_offset) = anchors[index];
229
+ let continues = |neighbor: Option<&(usize, usize)>, step: isize| {
230
+ neighbor.is_some_and(|&(left, right)| {
231
+ left as isize == left_offset as isize + step
232
+ && right as isize == right_offset as isize + step
233
+ })
234
+ };
235
+ continues(
236
+ index
237
+ .checked_sub(1)
238
+ .and_then(|previous| anchors.get(previous)),
239
+ -1,
240
+ ) || continues(anchors.get(index + 1), 1)
241
+ })
242
+ .map(|index| anchors[index])
243
+ .collect();
244
+ let chain = longest_increasing_chain(&run_anchors);
245
+ let cores: Vec<CorePair> = chain_segments(&chain)
246
+ .filter_map(|segment| {
247
+ let (first, last) = (segment[0], segment[segment.len() - 1]);
248
+ let (left_start, left_end) = (first.0, last.0 + NGRAM_SIZE);
249
+ let (right_start, right_end) = (first.1, last.1 + NGRAM_SIZE);
250
+ let (left_length, right_length) = (left_end - left_start, right_end - right_start);
251
+ let shorter = left_length.min(right_length);
252
+ let required = self.min_similarity_percent * left_length.max(right_length);
253
+ let verified = shorter >= self.min_tokens
254
+ && shorter * 100 >= required
255
+ && anchored_token_count(segment) * 100 >= MIN_ANCHOR_COVERAGE_PERCENT * shorter
256
+ && self.spans_share_content(
257
+ left,
258
+ (left_start, left_end),
259
+ right,
260
+ (right_start, right_end),
261
+ )
262
+ && lcs_length(
263
+ &anonymize(&left.symbols[left_start..left_end]),
264
+ &anonymize(&right.symbols[right_start..right_end]),
265
+ ) * 100
266
+ >= required;
267
+ verified.then_some((
268
+ (left.start + left_start, left.start + left_end),
269
+ (right.start + right_start, right.start + right_end),
270
+ ))
271
+ })
272
+ .collect();
273
+ (!cores.is_empty()).then_some(PairMatch::Local(cores))
274
+ }
275
+
276
+ fn spans_share_content(
277
+ &self,
278
+ left: &Block,
279
+ (left_start, left_end): (usize, usize),
280
+ right: &Block,
281
+ (right_start, right_end): (usize, usize),
282
+ ) -> bool {
283
+ let mut left_content = count_content(
284
+ &left.symbols[left_start..left_end],
285
+ &left.is_content[left_start..left_end],
286
+ );
287
+ let mut right_content = count_content(
288
+ &right.symbols[right_start..right_end],
289
+ &right.is_content[right_start..right_end],
290
+ );
291
+ let left_total = self.weigh(&mut left_content);
292
+ let right_total = self.weigh(&mut right_content);
293
+ shares_content(&left_content, left_total, &right_content, right_total)
294
+ }
295
+ }
296
+
297
+ /// A structural match must be backed by shared content: more than half of the larger side's
298
+ /// information-weighted names and literal values. Two sides without content never pass.
299
+ fn shares_content(
300
+ left: &[(i32, u64)],
301
+ left_total: u64,
302
+ right: &[(i32, u64)],
303
+ right_total: u64,
304
+ ) -> bool {
305
+ let mut overlap = 0;
306
+ let (mut left_index, mut right_index) = (0, 0);
307
+ while let (Some(&(left_symbol, left_count)), Some(&(right_symbol, right_count))) =
308
+ (left.get(left_index), right.get(right_index))
309
+ {
310
+ if left_symbol == right_symbol {
311
+ overlap += left_count.min(right_count);
312
+ }
313
+ left_index += usize::from(left_symbol <= right_symbol);
314
+ right_index += usize::from(right_symbol <= left_symbol);
315
+ }
316
+ overlap * 100 > MIN_CONTENT_SIMILARITY_PERCENT * left_total.max(right_total)
317
+ }
318
+
319
+ /// Counts per content-bearing symbol, sorted by symbol.
320
+ fn count_content(symbols: &[i32], is_content: &[bool]) -> Vec<(i32, u64)> {
321
+ let mut content: Vec<i32> = symbols
322
+ .iter()
323
+ .zip(is_content)
324
+ .filter(|(_, &content)| content)
325
+ .map(|(&symbol, _)| symbol)
326
+ .collect();
327
+ content.sort_unstable();
328
+ let mut counts: Vec<(i32, u64)> = Vec::new();
329
+ for symbol in content {
330
+ match counts.last_mut() {
331
+ Some((last, count)) if *last == symbol => *count += 1,
332
+ _ => counts.push((symbol, 1)),
333
+ }
334
+ }
335
+ counts
336
+ }
337
+
338
+ /// Left-block tokens the segment's anchors cover (overlapping anchors count once).
339
+ fn anchored_token_count(segment: &[(usize, usize)]) -> usize {
340
+ segment
341
+ .windows(2)
342
+ .map(|pair| (pair[1].0 - pair[0].0).min(NGRAM_SIZE))
343
+ .sum::<usize>()
344
+ + NGRAM_SIZE
345
+ }
346
+
347
+ /// Identifiers renumbered by first occurrence within `symbols`, so a range compares the same
348
+ /// wherever it sits in its file.
349
+ fn anonymize(symbols: &[i32]) -> Vec<i32> {
350
+ let mut index_by_identifier: HashMap<i32, i32> = HashMap::new();
351
+ symbols
352
+ .iter()
353
+ .map(|&symbol| {
354
+ if symbol >= 0 {
355
+ return symbol;
356
+ }
357
+ let next_index = index_by_identifier.len() as i32;
358
+ -(*index_by_identifier.entry(symbol).or_insert(next_index) + 1)
359
+ })
360
+ .collect()
361
+ }
362
+
363
+ /// The block's units (its top-level statements, as block-relative offsets, and the token runs
364
+ /// between them), each anonymized on its own and sorted, concatenated.
365
+ fn canonical_sequence(
366
+ symbols: &[i32],
367
+ statements: impl Iterator<Item = (usize, usize)>,
368
+ ) -> Vec<i32> {
369
+ let mut units: Vec<Vec<i32>> = Vec::new();
370
+ let mut cursor = 0;
371
+ for (start, end) in statements {
372
+ if cursor < start {
373
+ units.push(anonymize(&symbols[cursor..start]));
374
+ }
375
+ units.push(anonymize(&symbols[start..end]));
376
+ cursor = end;
377
+ }
378
+ if cursor < symbols.len() {
379
+ units.push(anonymize(&symbols[cursor..]));
380
+ }
381
+ units.sort_unstable();
382
+ units.concat()
383
+ }
384
+
385
+ /// The longest chain of anchors increasing in both blocks (anchors arrive sorted by left offset),
386
+ /// via patience sorting over right offsets.
387
+ fn longest_increasing_chain(anchors: &[(usize, usize)]) -> Vec<(usize, usize)> {
388
+ let mut tail_indexes: Vec<usize> = Vec::new();
389
+ let mut predecessors: Vec<Option<usize>> = Vec::with_capacity(anchors.len());
390
+ for (index, &(_, right_offset)) in anchors.iter().enumerate() {
391
+ let position = tail_indexes.partition_point(|&tail| anchors[tail].1 < right_offset);
392
+ predecessors.push(
393
+ position
394
+ .checked_sub(1)
395
+ .map(|previous| tail_indexes[previous]),
396
+ );
397
+ if position == tail_indexes.len() {
398
+ tail_indexes.push(index);
399
+ } else {
400
+ tail_indexes[position] = index;
401
+ }
402
+ }
403
+ let mut chain = Vec::with_capacity(tail_indexes.len());
404
+ let mut cursor = tail_indexes.last().copied();
405
+ while let Some(index) = cursor {
406
+ chain.push(anchors[index]);
407
+ cursor = predecessors[index];
408
+ }
409
+ chain.reverse();
410
+ chain
411
+ }
412
+
413
+ /// The chain's segments, split where consecutive anchors lie more than MAX_ANCHOR_GAP_TOKENS apart
414
+ /// in either block.
415
+ fn chain_segments(chain: &[(usize, usize)]) -> impl Iterator<Item = &[(usize, usize)]> {
416
+ chain.chunk_by(|anchor, next| {
417
+ next.0.saturating_sub(anchor.0 + NGRAM_SIZE) <= MAX_ANCHOR_GAP_TOKENS
418
+ && next.1.saturating_sub(anchor.1 + NGRAM_SIZE) <= MAX_ANCHOR_GAP_TOKENS
419
+ })
420
+ }
421
+
422
+ /// Longest-common-subsequence LENGTH via the Allison–Dix bit-parallel recurrence. Only the length
423
+ /// is needed and LCS length is algorithm-independent, so u64 words are safe even though the
424
+ /// TypeScript port in src/duplication.ts uses 32-bit words.
425
+ fn lcs_length(a: &[i32], b: &[i32]) -> usize {
426
+ if a.is_empty() || b.is_empty() {
427
+ return 0;
428
+ }
429
+ let word_count = a.len().div_ceil(64);
430
+ let mut position_masks: HashMap<i32, Vec<u64>> = HashMap::new();
431
+ for (index, &symbol) in a.iter().enumerate() {
432
+ position_masks
433
+ .entry(symbol)
434
+ .or_insert_with(|| vec![0; word_count])[index / 64] |= 1u64 << (index % 64);
435
+ }
436
+
437
+ let mut v = vec![0u64; word_count];
438
+ for symbol in b {
439
+ let match_mask = position_masks.get(symbol);
440
+ // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.
441
+ let mut shift_carry = 1u64;
442
+ let mut borrow = 0u64;
443
+ for (word, slot) in v.iter_mut().enumerate() {
444
+ let previous = *slot;
445
+ let x = match_mask.map_or(0, |mask| mask[word]) | previous;
446
+ let shifted = (previous << 1) | shift_carry;
447
+ shift_carry = previous >> 63;
448
+ let (partial, underflow1) = x.overflowing_sub(shifted);
449
+ let (difference, underflow2) = partial.overflowing_sub(borrow);
450
+ borrow = u64::from(underflow1 || underflow2);
451
+ *slot = x & !difference;
452
+ }
453
+ }
454
+ v.iter().map(|word| word.count_ones() as usize).sum()
455
+ }