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.
- package/README.md +39 -6
- package/THIRD-PARTY-NOTICES.txt +8911 -0
- package/dist/cliConfig.cjs +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.js +1 -1
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.d.ts +2 -2
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/crossFileNearMiss.cjs +1 -1
- package/dist/crossFileNearMiss.cjs.map +1 -1
- package/dist/crossFileNearMiss.d.ts +18 -12
- package/dist/crossFileNearMiss.js +1 -1
- package/dist/crossFileNearMiss.js.map +1 -1
- package/dist/diffCommand.cjs +1 -1
- package/dist/diffCommand.cjs.map +1 -1
- package/dist/diffCommand.js +1 -1
- package/dist/languages.cjs +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.js +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/nativeMetrics.cjs +3 -3
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.d.ts +14 -0
- package/dist/nativeMetrics.js +3 -3
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/scan.cjs +1 -1
- package/dist/scan.js +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/wasmBinding.cjs +2 -0
- package/dist/wasmBinding.cjs.map +1 -0
- package/dist/wasmBinding.d.ts +9 -0
- package/dist/wasmBinding.js +2 -0
- package/dist/wasmBinding.js.map +1 -0
- package/dist/worker.cjs +2 -0
- package/dist/worker.cjs.map +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +2 -0
- package/dist/worker.js.map +1 -0
- package/native/Cargo.toml +5 -2
- package/native/build.rs +4 -1
- package/native/code-gauge.wasm +0 -0
- package/native/src/duplication.rs +332 -234
- package/native/src/lib.rs +32 -37
- package/native/src/napi.rs +45 -0
- package/native/src/near_miss.rs +455 -0
- package/native/src/wasm.rs +128 -0
- 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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
42
|
-
) -> Result<String> {
|
|
43
|
-
let definition = find_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
|
-
|
|
47
|
+
code,
|
|
47
48
|
definition,
|
|
48
|
-
include_syntax_tree
|
|
49
|
-
include_cross_file_data
|
|
49
|
+
include_syntax_tree,
|
|
50
|
+
include_cross_file_data,
|
|
50
51
|
&settings,
|
|
51
|
-
)
|
|
52
|
-
.map_err(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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(
|
|
69
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
let
|
|
78
|
-
|
|
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
|
+
}
|