code-gauge 4.4.0 → 4.6.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 (44) hide show
  1. package/README.md +16 -5
  2. package/dist/crossFileDuplication.cjs +1 -1
  3. package/dist/crossFileDuplication.cjs.map +1 -1
  4. package/dist/crossFileDuplication.d.ts +11 -4
  5. package/dist/crossFileDuplication.js +1 -1
  6. package/dist/crossFileDuplication.js.map +1 -1
  7. package/dist/crossFileNearMiss.cjs +2 -0
  8. package/dist/crossFileNearMiss.cjs.map +1 -0
  9. package/dist/crossFileNearMiss.d.ts +33 -0
  10. package/dist/crossFileNearMiss.js +2 -0
  11. package/dist/crossFileNearMiss.js.map +1 -0
  12. package/dist/diffCommand.cjs +1 -1
  13. package/dist/diffCommand.cjs.map +1 -1
  14. package/dist/diffCommand.js +3 -3
  15. package/dist/diffCommand.js.map +1 -1
  16. package/dist/duplication.cjs +1 -1
  17. package/dist/duplication.cjs.map +1 -1
  18. package/dist/duplication.d.ts +11 -0
  19. package/dist/duplication.js +1 -1
  20. package/dist/duplication.js.map +1 -1
  21. package/dist/metrics.cjs +1 -1
  22. package/dist/metrics.cjs.map +1 -1
  23. package/dist/metrics.d.ts +10 -0
  24. package/dist/metrics.js +1 -1
  25. package/dist/metrics.js.map +1 -1
  26. package/dist/nativeMetrics.cjs +2 -2
  27. package/dist/nativeMetrics.cjs.map +1 -1
  28. package/dist/nativeMetrics.d.ts +7 -2
  29. package/dist/nativeMetrics.js +2 -2
  30. package/dist/nativeMetrics.js.map +1 -1
  31. package/dist/scan.cjs +1 -1
  32. package/dist/scan.cjs.map +1 -1
  33. package/dist/scan.d.ts +12 -1
  34. package/dist/scan.js +1 -1
  35. package/dist/scan.js.map +1 -1
  36. package/dist/types.d.ts +4 -2
  37. package/native/src/dep_degree.rs +2 -3
  38. package/native/src/duplication.rs +490 -337
  39. package/native/src/functions.rs +1 -1
  40. package/native/src/lib.rs +7 -2
  41. package/native/src/measure.rs +35 -11
  42. package/native/src/near_miss.rs +455 -0
  43. package/native/src/types.rs +5 -0
  44. package/package.json +10 -10
@@ -1304,7 +1304,7 @@ fn declares_deduced_type(declarator: Node<'_>) -> bool {
1304
1304
  .is_some_and(|declared| declared.kind() == "placeholder_type_specifier")
1305
1305
  }
1306
1306
 
1307
- /// Unwraps a C/C++ declarator chain to the declared name; see unwrapDeclaratorName in metrics.ts.
1307
+ /// Unwraps a C/C++ declarator chain to the declared name.
1308
1308
  fn unwrap_declarator_name(declarator: Option<Node<'_>>, code: &Source<'_>) -> Option<String> {
1309
1309
  let mut current = declarator;
1310
1310
  while let Some(node) = current {
package/native/src/lib.rs CHANGED
@@ -12,6 +12,7 @@ mod functions;
12
12
  mod languages;
13
13
  mod measure;
14
14
  mod ncss;
15
+ mod near_miss;
15
16
  mod types;
16
17
  mod util;
17
18
 
@@ -21,10 +22,12 @@ mod util;
21
22
  /// together with `expectedPayloadVersion` in src/nativeMetrics.ts.
22
23
  #[napi]
23
24
  pub fn payload_version() -> u32 {
24
- 6
25
+ 7
25
26
  }
26
27
 
27
- /// Measures code metrics for the given source, returning the NativeMetrics payload as JSON.
28
+ /// Measures code metrics for the given source, returning the NativeMetrics payload as JSON; with
29
+ /// `include_cross_file_data`, the payload also carries the file's cross-file clone-detection
30
+ /// contribution from the same parse.
28
31
  /// The TypeScript wrapper derives the remaining float metrics (Halstead volume/effort/...): they
29
32
  /// involve transcendental functions whose last-bit results can differ between V8 and Rust's libm,
30
33
  /// and results must not depend on which side computes them.
@@ -36,6 +39,7 @@ pub fn measure_code_native(
36
39
  min_tokens: Option<u32>,
37
40
  max_gap_tokens: Option<u32>,
38
41
  min_similarity_percent: Option<u32>,
42
+ include_cross_file_data: Option<bool>,
39
43
  ) -> Result<String> {
40
44
  let definition = find_language(&language)?;
41
45
  let settings = to_duplication_settings(min_tokens, max_gap_tokens, min_similarity_percent);
@@ -43,6 +47,7 @@ pub fn measure_code_native(
43
47
  &code,
44
48
  definition,
45
49
  include_syntax_tree.unwrap_or(false),
50
+ include_cross_file_data.unwrap_or(false),
46
51
  &settings,
47
52
  )
48
53
  .map_err(Error::from_reason)?;
@@ -7,7 +7,8 @@ use crate::complexity::{
7
7
  };
8
8
  use crate::dep_degree::measure_dep_degree;
9
9
  use crate::duplication::{
10
- collect_cross_file_file_data, hash_text, measure_duplication, DuplicationSettings,
10
+ collect_cross_file_file_data, hash_text, measure_duplication, tokenize, DuplicationSettings,
11
+ TokenizedSource,
11
12
  };
12
13
  use crate::functions::{
13
14
  collect_nodes, count_parameters, find_function_name, is_implemented_function,
@@ -25,6 +26,7 @@ pub fn measure(
25
26
  code: &str,
26
27
  language: &LanguageDefinition,
27
28
  include_syntax_tree: bool,
29
+ include_cross_file_data: bool,
28
30
  duplication_settings: &DuplicationSettings,
29
31
  ) -> Result<NativeMetrics, String> {
30
32
  let source = Source::new(code);
@@ -72,6 +74,7 @@ pub fn measure(
72
74
  let global_complexity = measure_complexity(root, &sets, code);
73
75
  let (lines, code_line_numbers) = classify_lines(code, root);
74
76
  let halstead_counts = measure_halstead(root, code);
77
+ let tokenized = tokenize(root, code);
75
78
 
76
79
  Ok(NativeMetrics {
77
80
  language: language.name.to_string(),
@@ -95,7 +98,14 @@ pub fn measure(
95
98
  .unwrap_or(0),
96
99
  nesting_depth: global_complexity.nesting_depth,
97
100
  ncss_count: crate::ncss::count_ncss(root, &sets.ncss_nodes, &sets.ncss_containers),
98
- duplication: measure_duplication(root, &code_line_numbers, code, duplication_settings),
101
+ duplication: measure_duplication(&tokenized, &code_line_numbers, duplication_settings),
102
+ cross_file_data: include_cross_file_data.then(|| {
103
+ to_cross_file_data(
104
+ &tokenized,
105
+ &code_line_numbers,
106
+ duplication_settings.min_tokens,
107
+ )
108
+ }),
99
109
  halstead_counts,
100
110
  functions: function_metrics,
101
111
  syntax_tree: if include_syntax_tree {
@@ -185,17 +195,30 @@ pub fn collect_cross_file_data(
185
195
  let source = Source::new(code);
186
196
  let tree = parse_source(&source, language)?;
187
197
  let root = tree.root_node();
188
- let (candidates, tokens, container_statements) =
189
- collect_cross_file_file_data(root, &source, min_tokens);
190
198
  let (_, code_line_numbers) = classify_lines(&source, root);
191
- let mut code_line_numbers: Vec<usize> = code_line_numbers.into_iter().collect();
199
+ Ok(to_cross_file_data(
200
+ &tokenize(root, &source),
201
+ &code_line_numbers,
202
+ min_tokens,
203
+ ))
204
+ }
205
+
206
+ fn to_cross_file_data(
207
+ tokenized: &TokenizedSource<'_>,
208
+ code_line_numbers: &HashSet<usize>,
209
+ min_tokens: usize,
210
+ ) -> CrossFileFileData {
211
+ let (candidates, tokens, container_statements, near_miss_blocks) =
212
+ collect_cross_file_file_data(tokenized, min_tokens);
213
+ let mut code_line_numbers: Vec<usize> = code_line_numbers.iter().copied().collect();
192
214
  code_line_numbers.sort_unstable();
193
- Ok(CrossFileFileData {
215
+ CrossFileFileData {
194
216
  candidates,
195
217
  tokens,
196
218
  container_statements,
219
+ near_miss_blocks,
197
220
  code_line_numbers,
198
- })
221
+ }
199
222
  }
200
223
 
201
224
  /// Name-carrying leaf types anonymized by tokenize_function so consistent renames still match.
@@ -214,7 +237,7 @@ const IDENTIFIER_LEAF_NODE_TYPES: &[&str] = &[
214
237
  ];
215
238
 
216
239
  /// Normalized token hash sequences of every function, index-parallel to the functions array of
217
- /// measure(); a faithful port of tokenizeFunction in src/metrics.ts.
240
+ /// measure().
218
241
  pub fn collect_function_token_sequences(
219
242
  code: &str,
220
243
  language: &LanguageDefinition,
@@ -326,8 +349,9 @@ struct CommentSpan {
326
349
  end_column: usize,
327
350
  }
328
351
 
329
- /// 1-based numbers of lines that are neither blank nor comment-only, matching classifyLines in
330
- /// metrics.ts so duplication line coverage and its code-line denominator agree.
352
+ /// Line metrics plus the 1-based numbers of lines that are neither blank nor comment-only, shared
353
+ /// by the line counts and duplication line coverage so the coverage and its code-line denominator
354
+ /// agree.
331
355
  fn classify_lines(code: &Source<'_>, root: Node<'_>) -> (LineMetrics, HashSet<usize>) {
332
356
  let source_lines = split_lines(code.code);
333
357
  // Spans are bucketed by line so classification stays linear.
@@ -606,7 +630,7 @@ const OPERAND_NODE_TYPES: &[&str] = &[
606
630
  "none",
607
631
  ];
608
632
 
609
- /// Non-leaf literals counted as one Halstead operand without descending; see metrics.ts.
633
+ /// Non-leaf literals counted as one Halstead operand without descending.
610
634
  /// `character_literal` is a leaf in Java and Kotlin but wraps a content node in C#; Kotlin's
611
635
  /// suffixed numbers (`1L`, `1u`) wrap the bare literal, so `1` and `1L` stay distinct.
612
636
  const ATOMIC_OPERAND_NODE_TYPES: &[&str] = &[
@@ -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
+ }
@@ -17,6 +17,9 @@ pub struct NativeMetrics {
17
17
  pub nesting_depth: u64,
18
18
  pub ncss_count: u64,
19
19
  pub duplication: DuplicationMetrics,
20
+ /// Collected from the same parse on request, so a directory scan tokenizes each file once.
21
+ #[serde(skip_serializing_if = "Option::is_none")]
22
+ pub cross_file_data: Option<CrossFileFileData>,
20
23
  pub halstead_counts: HalsteadCounts,
21
24
  #[serde(skip_serializing_if = "Option::is_none")]
22
25
  pub syntax_tree: Option<String>,
@@ -80,6 +83,8 @@ pub struct CrossFileFileData {
80
83
  pub candidates: Vec<CrossFileCandidate>,
81
84
  pub tokens: Vec<CrossFileToken>,
82
85
  pub container_statements: Vec<Vec<CrossFileTokenRange>>,
86
+ /// The mutually disjoint blocks cross-file near-miss (Type-3) comparison considers.
87
+ pub near_miss_blocks: Vec<CrossFileTokenRange>,
83
88
  /// 1-based lines that are neither blank nor comment-only, sorted ascending.
84
89
  pub code_line_numbers: Vec<usize>,
85
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-gauge",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "Measure code metrics with tree-sitter.",
5
5
  "keywords": [
6
6
  "cli",
@@ -69,13 +69,13 @@
69
69
  "@types/node": "25.9.4",
70
70
  "@willbooster/oxfmt-config": "1.2.2",
71
71
  "@willbooster/oxlint-config": "1.4.8",
72
- "@willbooster/wb": "22.7.0",
72
+ "@willbooster/wb": "22.7.1",
73
73
  "build-ts": "21.0.16",
74
74
  "conventional-changelog-conventionalcommits": "9.3.1",
75
75
  "lefthook": "2.1.14",
76
76
  "oxfmt": "0.68.0",
77
77
  "oxlint": "1.83.0",
78
- "oxlint-tsgolint": "7.0.2001",
78
+ "oxlint-tsgolint": "7.0.2002",
79
79
  "semantic-release": "25.0.5",
80
80
  "sort-package-json": "4.0.0",
81
81
  "typescript": "7.0.2",
@@ -89,12 +89,12 @@
89
89
  "registry": "https://registry.npmjs.org/"
90
90
  },
91
91
  "optionalDependencies": {
92
- "code-gauge-linux-x64-gnu": "4.4.0",
93
- "code-gauge-linux-arm64-gnu": "4.4.0",
94
- "code-gauge-linux-x64-musl": "4.4.0",
95
- "code-gauge-linux-arm64-musl": "4.4.0",
96
- "code-gauge-darwin-x64": "4.4.0",
97
- "code-gauge-darwin-arm64": "4.4.0",
98
- "code-gauge-win32-x64-msvc": "4.4.0"
92
+ "code-gauge-linux-x64-gnu": "4.6.0",
93
+ "code-gauge-linux-arm64-gnu": "4.6.0",
94
+ "code-gauge-linux-x64-musl": "4.6.0",
95
+ "code-gauge-linux-arm64-musl": "4.6.0",
96
+ "code-gauge-darwin-x64": "4.6.0",
97
+ "code-gauge-darwin-arm64": "4.6.0",
98
+ "code-gauge-win32-x64-msvc": "4.6.0"
99
99
  }
100
100
  }