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
@@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet};
4
4
  use std::sync::OnceLock;
5
5
  use tree_sitter::Node;
6
6
 
7
+ use crate::near_miss::{Block, Matcher, PairMatch, FILTRATION_PERCENT, MAX_LENGTH_RATIO};
7
8
  use crate::types::{
8
9
  CrossFileCandidate, CrossFileToken, CrossFileTokenRange, DuplicateBlockOccurrence,
9
10
  DuplicationMetrics,
@@ -314,14 +315,6 @@ impl Default for DuplicationSettings {
314
315
  }
315
316
  }
316
317
  }
317
- /// N-gram size for the near-miss candidate index (NIL's default); shared with crossFileNearMiss.ts.
318
- const NEAR_MISS_NGRAM_SIZE: usize = 5;
319
- /// Filtration threshold: shared distinct n-grams over the smaller set; shared with crossFileNearMiss.ts.
320
- const NEAR_MISS_FILTRATION_PERCENT: usize = 10;
321
- /// Exclusive bound on shared content-bearing tokens (names and literal values); shared with
322
- /// crossFileNearMiss.ts.
323
- const MIN_CONTENT_SIMILARITY_PERCENT: usize = 50;
324
-
325
318
  /// See isLiteralDense in duplication.ts: >= 20% literal values marks a region as data-like.
326
319
  fn is_literal_dense(literal_count: usize, token_count: usize) -> bool {
327
320
  literal_count * 5 >= token_count
@@ -1663,10 +1656,15 @@ fn merge_groups(
1663
1656
  })
1664
1657
  }
1665
1658
 
1659
+ /// A verified near-miss pair as (block, core, block, core); a `None` core is a whole-block match.
1660
+ type MatchEdge = (usize, Option<(usize, usize)>, usize, Option<(usize, usize)>);
1661
+
1666
1662
  /// Detects near-miss (Type-3) clone groups among block candidates the exact pipeline left
1667
- /// unreported: NIL-style n-gram filtration, then token-level LCS with NiCad-style per-fragment
1668
- /// similarity, then transitive clustering of verified pairs (crossFileNearMiss.ts applies the same
1669
- /// model across files).
1663
+ /// unreported: NIL-style n-gram filtration, then pair verification (near_miss::Matcher), then
1664
+ /// transitive clustering of verified pairs (crossFileNearMiss.ts applies the same model across
1665
+ /// files). A block that matched only locally is reported as its matched cores (overlapping cores
1666
+ /// merged), each clustered with its own partners, so code no verified pair matched never counts
1667
+ /// as duplicated.
1670
1668
  fn collect_near_miss_groups(
1671
1669
  source: &TokenizedSource<'_>,
1672
1670
  settings: &DuplicationSettings,
@@ -1681,39 +1679,113 @@ fn collect_near_miss_groups(
1681
1679
  return Vec::new();
1682
1680
  }
1683
1681
 
1684
- // Reported-group indices whose occurrences overlap each comparable block: such blocks anchor
1685
- // near-miss comparisons but are never re-reported.
1686
- let touched_groups_by_block: Vec<Vec<usize>> = comparable
1682
+ // Reported-group indices whose occurrences overlap a token range: near-miss nodes covering
1683
+ // such content anchor comparisons but are never re-reported.
1684
+ let touched_groups_in = |start: usize, end: usize| -> Vec<usize> {
1685
+ reported_groups
1686
+ .iter()
1687
+ .enumerate()
1688
+ .filter(|(_, group)| {
1689
+ group.iter().any(|occurrence| {
1690
+ occurrence.start_token_index < end && start < occurrence.end_token_index
1691
+ })
1692
+ })
1693
+ .map(|(group_index, _)| group_index)
1694
+ .collect()
1695
+ };
1696
+
1697
+ let (symbols, is_content) = to_symbol_stream(tokens);
1698
+ let statements = top_level_statement_finder(&source.container_statement_ranges);
1699
+ let mut blocks: Vec<Block> = comparable
1687
1700
  .iter()
1688
1701
  .map(|range| {
1689
- reported_groups
1690
- .iter()
1691
- .enumerate()
1692
- .filter(|(_, group)| {
1693
- group.iter().any(|occurrence| {
1694
- occurrence.start_token_index < range.end_token_index
1695
- && range.start_token_index < occurrence.end_token_index
1696
- })
1697
- })
1698
- .map(|(group_index, _)| group_index)
1699
- .collect()
1702
+ Block::new(
1703
+ &symbols,
1704
+ &is_content,
1705
+ range.start_token_index,
1706
+ range.end_token_index,
1707
+ statements(range.start_token_index, range.end_token_index),
1708
+ )
1700
1709
  })
1701
1710
  .collect();
1711
+ let matcher = Matcher::new(
1712
+ &mut blocks,
1713
+ settings.min_tokens,
1714
+ settings.min_similarity_percent,
1715
+ );
1702
1716
 
1703
- // Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
1704
- // other files the process measured before it.
1705
- let mut symbol_id_by_token_hashes: HashMap<(i32, i32, i32, i32), i32> = HashMap::new();
1706
- let sequences: Vec<NormalizedBlock> = comparable
1717
+ let block_touched: Vec<bool> = comparable
1707
1718
  .iter()
1708
- .map(|range| normalize_block_sequence(tokens, range, &mut symbol_id_by_token_hashes))
1719
+ .map(|range| !touched_groups_in(range.start_token_index, range.end_token_index).is_empty())
1709
1720
  .collect();
1710
- let ngram_sets: Vec<HashSet<i32>> = sequences
1711
- .iter()
1712
- .map(|block| collect_ngram_set(&block.sequence))
1713
- .collect();
1714
- let shared_counts = count_shared_ngrams(&ngram_sets);
1721
+ let mut edges: Vec<MatchEdge> = Vec::new();
1722
+ for_each_candidate_pair(
1723
+ &blocks,
1724
+ settings.min_similarity_percent,
1725
+ |left_index, right_index| match matcher.verify(&blocks[left_index], &blocks[right_index]) {
1726
+ None => {}
1727
+ // A whole match between two blocks that both overlap reported content could never join
1728
+ // a group, and recording it would collapse the blocks' core nodes.
1729
+ Some(PairMatch::Whole)
1730
+ if !(block_touched[left_index] && block_touched[right_index]) =>
1731
+ {
1732
+ edges.push((left_index, None, right_index, None))
1733
+ }
1734
+ Some(PairMatch::Whole) => {}
1735
+ Some(PairMatch::Local(cores)) => {
1736
+ for (left_core, right_core) in cores {
1737
+ edges.push((left_index, Some(left_core), right_index, Some(right_core)));
1738
+ }
1739
+ }
1740
+ },
1741
+ );
1742
+
1743
+ // Clustering runs over (block, core) nodes: a block with a recorded whole match is one
1744
+ // node, and otherwise each union of its overlapping local cores is its own node, so disjoint
1745
+ // cores matched with different partners fall into separate groups.
1746
+ let mut matched_whole = vec![false; comparable.len()];
1747
+ let mut local_cores: Vec<Vec<(usize, usize)>> = vec![Vec::new(); comparable.len()];
1748
+ for &(left_index, left_core, right_index, right_core) in &edges {
1749
+ for (index, core) in [(left_index, left_core), (right_index, right_core)] {
1750
+ match core {
1751
+ Some(core) => local_cores[index].push(core),
1752
+ None => matched_whole[index] = true,
1753
+ }
1754
+ }
1755
+ }
1756
+ let mut node_blocks: Vec<usize> = Vec::new();
1757
+ let mut node_spans: Vec<Option<(usize, usize)>> = Vec::new();
1758
+ let mut first_node_by_block: Vec<usize> = Vec::with_capacity(comparable.len());
1759
+ for index in 0..comparable.len() {
1760
+ first_node_by_block.push(node_blocks.len());
1761
+ let cores = if matched_whole[index] {
1762
+ Vec::new()
1763
+ } else {
1764
+ merge_overlapping_cores(&local_cores[index])
1765
+ };
1766
+ if cores.is_empty() {
1767
+ node_blocks.push(index);
1768
+ node_spans.push(None);
1769
+ }
1770
+ for core in cores {
1771
+ node_blocks.push(index);
1772
+ node_spans.push(Some(core));
1773
+ }
1774
+ }
1775
+ let node_of = |index: usize, core: Option<(usize, usize)>| {
1776
+ let first = first_node_by_block[index];
1777
+ match core {
1778
+ Some(core) if !matched_whole[index] => (first..node_blocks.len())
1779
+ .take_while(|&node| node_blocks[node] == index)
1780
+ .find(|&node| {
1781
+ node_spans[node].is_some_and(|span| span.0 <= core.0 && core.1 <= span.1)
1782
+ })
1783
+ .expect("every local core lies in one of its block's merged cores"),
1784
+ _ => first,
1785
+ }
1786
+ };
1715
1787
 
1716
- let mut parent: Vec<usize> = (0..comparable.len()).collect();
1788
+ let mut parent: Vec<usize> = (0..node_blocks.len()).collect();
1717
1789
  fn find(parent: &mut [usize], mut index: usize) -> usize {
1718
1790
  let mut root = index;
1719
1791
  while parent[root] != root {
@@ -1726,52 +1798,84 @@ fn collect_near_miss_groups(
1726
1798
  }
1727
1799
  root
1728
1800
  }
1729
- for (&(left_index, right_index), &shared) in &shared_counts {
1730
- let left = &sequences[left_index];
1731
- let right = &sequences[right_index];
1732
- // Two already-reported blocks have nothing new to contribute to each other.
1733
- if !touched_groups_by_block[left_index].is_empty()
1734
- && !touched_groups_by_block[right_index].is_empty()
1735
- {
1736
- continue;
1737
- }
1738
- let min_ngrams = ngram_sets[left_index]
1739
- .len()
1740
- .min(ngram_sets[right_index].len());
1741
- if shared * 100 < NEAR_MISS_FILTRATION_PERCENT * min_ngrams {
1742
- continue;
1743
- }
1744
- // A structural match must be backed by shared content (names and literal values); the
1745
- // bound is exclusive, matching crossFileNearMiss.ts.
1746
- if content_overlap(left, right) * 100
1747
- <= MIN_CONTENT_SIMILARITY_PERCENT * left.content_total.max(right.content_total)
1801
+ // Coverage is judged per node: a core is covered only when a reported occurrence overlaps
1802
+ // the core itself, not merely elsewhere in its block.
1803
+ let node_range = |node: usize| {
1804
+ node_spans[node].unwrap_or((
1805
+ comparable[node_blocks[node]].start_token_index,
1806
+ comparable[node_blocks[node]].end_token_index,
1807
+ ))
1808
+ };
1809
+ let touched_groups_by_node: Vec<Vec<usize>> = (0..node_blocks.len())
1810
+ .map(|node| {
1811
+ let (start, end) = node_range(node);
1812
+ touched_groups_in(start, end)
1813
+ })
1814
+ .collect();
1815
+ for &(left_index, left_core, right_index, right_core) in &edges {
1816
+ let (left_node, right_node) = (
1817
+ node_of(left_index, left_core),
1818
+ node_of(right_index, right_core),
1819
+ );
1820
+ // Two already-reported nodes have nothing new to contribute to each other.
1821
+ if !touched_groups_by_node[left_node].is_empty()
1822
+ && !touched_groups_by_node[right_node].is_empty()
1748
1823
  {
1749
1824
  continue;
1750
1825
  }
1751
- // Per-fragment similarity against the larger block (NiCad semantics).
1752
- if lcs_length(&left.sequence, &right.sequence) * 100
1753
- >= settings.min_similarity_percent * left.sequence.len().max(right.sequence.len())
1754
- {
1755
- let left_root = find(&mut parent, left_index);
1756
- let right_root = find(&mut parent, right_index);
1757
- parent[left_root.max(right_root)] = left_root.min(right_root);
1758
- }
1826
+ let left_root = find(&mut parent, left_node);
1827
+ let right_root = find(&mut parent, right_node);
1828
+ parent[left_root.max(right_root)] = left_root.min(right_root);
1759
1829
  }
1760
1830
 
1761
1831
  let mut members_by_root: IndexMap<usize, Vec<usize>> = IndexMap::new();
1762
- for index in 0..comparable.len() {
1763
- let root = find(&mut parent, index);
1764
- members_by_root.entry(root).or_default().push(index);
1765
- }
1766
- let to_occurrence = |range: &TokenRange| CountedOccurrence {
1767
- shared_with_merged_group: false,
1768
- segments: vec![(range.start_token_index, range.end_token_index)],
1769
- token_count: range.end_token_index - range.start_token_index,
1770
- start_token_index: range.start_token_index,
1771
- end_token_index: range.end_token_index,
1772
- start_line: range.start_line,
1773
- end_line: range.end_line,
1832
+ for node in 0..node_blocks.len() {
1833
+ let root = find(&mut parent, node);
1834
+ members_by_root.entry(root).or_default().push(node);
1835
+ }
1836
+ // A group's nodes from one block become ONE occurrence whose segments are its cores, so the
1837
+ // fragment-weighted count charges the block as one copy (as for gapped clones), not once per
1838
+ // core.
1839
+ let to_occurrences = |nodes: &[usize]| -> Vec<CountedOccurrence> {
1840
+ let mut spans_by_block: IndexMap<usize, Vec<Option<(usize, usize)>>> = IndexMap::new();
1841
+ for &node in nodes {
1842
+ spans_by_block
1843
+ .entry(node_blocks[node])
1844
+ .or_default()
1845
+ .push(node_spans[node]);
1846
+ }
1847
+ spans_by_block
1848
+ .into_iter()
1849
+ .map(|(block, spans)| {
1850
+ let range = comparable[block];
1851
+ let mut segments: Vec<(usize, usize)> = spans
1852
+ .iter()
1853
+ .map(|span| span.unwrap_or((range.start_token_index, range.end_token_index)))
1854
+ .collect();
1855
+ segments.sort_unstable();
1856
+ let whole = spans.iter().any(Option::is_none);
1857
+ let (start, end) = (segments[0].0, segments[segments.len() - 1].1);
1858
+ CountedOccurrence {
1859
+ shared_with_merged_group: false,
1860
+ token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
1861
+ segments,
1862
+ start_token_index: start,
1863
+ end_token_index: end,
1864
+ start_line: if whole {
1865
+ range.start_line
1866
+ } else {
1867
+ tokens[start].start_row + 1
1868
+ },
1869
+ end_line: if whole {
1870
+ range.end_line
1871
+ } else {
1872
+ tokens[end - 1].end_row + 1
1873
+ },
1874
+ }
1875
+ })
1876
+ .collect()
1774
1877
  };
1878
+ let touched_groups_of = |node: usize| &touched_groups_by_node[node];
1775
1879
  let mut groups: Vec<Vec<CountedOccurrence>> = Vec::new();
1776
1880
  for members in members_by_root.values() {
1777
1881
  if members.len() < 2 {
@@ -1780,39 +1884,36 @@ fn collect_near_miss_groups(
1780
1884
  let uncovered: Vec<usize> = members
1781
1885
  .iter()
1782
1886
  .copied()
1783
- .filter(|&index| touched_groups_by_block[index].is_empty())
1887
+ .filter(|&index| touched_groups_of(index).is_empty())
1784
1888
  .collect();
1785
1889
  let covered: Vec<usize> = members
1786
1890
  .iter()
1787
1891
  .copied()
1788
- .filter(|&index| !touched_groups_by_block[index].is_empty())
1892
+ .filter(|&index| !touched_groups_of(index).is_empty())
1789
1893
  .collect();
1790
1894
  if covered.is_empty() {
1791
- groups.push(
1792
- members
1793
- .iter()
1794
- .map(|&index| to_occurrence(comparable[index]))
1795
- .collect(),
1796
- );
1895
+ let occurrences = to_occurrences(members);
1896
+ if occurrences.len() >= 2 {
1897
+ groups.push(occurrences);
1898
+ }
1797
1899
  continue;
1798
1900
  }
1799
1901
  if uncovered.is_empty() {
1800
1902
  continue;
1801
1903
  }
1802
1904
  // An anchored cluster extends a reported group only when every occurrence of that group
1803
- // overlaps one of the cluster's member blocks: an occurrence disjoint from all members
1905
+ // overlaps one of the cluster's member nodes: an occurrence disjoint from all members
1804
1906
  // reports content the cluster does not share.
1805
1907
  let overlaps_member = |occurrence: &CountedOccurrence| {
1806
1908
  members.iter().any(|&index| {
1807
- let range = comparable[index];
1808
- occurrence.start_token_index < range.end_token_index
1809
- && range.start_token_index < occurrence.end_token_index
1909
+ let (start, end) = node_range(index);
1910
+ occurrence.start_token_index < end && start < occurrence.end_token_index
1810
1911
  })
1811
1912
  };
1812
1913
  // Ascending by construction: BTreeSet iteration is sorted and filter preserves order.
1813
1914
  let fully_clustered: Vec<usize> = covered
1814
1915
  .iter()
1815
- .flat_map(|&index| touched_groups_by_block[index].iter().copied())
1916
+ .flat_map(|&index| touched_groups_of(index).iter().copied())
1816
1917
  .collect::<std::collections::BTreeSet<usize>>()
1817
1918
  .into_iter()
1818
1919
  .filter(|&group_index| {
@@ -1821,32 +1922,57 @@ fn collect_near_miss_groups(
1821
1922
  })
1822
1923
  .collect();
1823
1924
  if let Some((&target_index, source_indexes)) = fully_clustered.split_first() {
1824
- // Rebuild the component as ONE group with one coalesced occurrence per member block.
1925
+ // Rebuild the component as ONE group with one coalesced occurrence per member block:
1926
+ // the fragments every node of a block overlaps are collected together, since a block's
1927
+ // cores are parts of one copy.
1825
1928
  let mut consumed: HashSet<(usize, usize)> = HashSet::new();
1826
1929
  let mut merged: Vec<CountedOccurrence> = Vec::new();
1930
+ let mut unanchored_nodes: Vec<usize> = Vec::new();
1931
+ let mut nodes_by_block: IndexMap<usize, Vec<usize>> = IndexMap::new();
1827
1932
  for &member_index in members {
1828
- let range = comparable[member_index];
1933
+ nodes_by_block
1934
+ .entry(node_blocks[member_index])
1935
+ .or_default()
1936
+ .push(member_index);
1937
+ }
1938
+ for block_nodes in nodes_by_block.values() {
1829
1939
  // Occurrences of ONE group are distinct copies; only fragments from DIFFERENT
1830
1940
  // groups belong to the same copy. Consecutive position-order slices keep the
1831
1941
  // coalesced spans disjoint.
1832
1942
  let mut fragments: Vec<(CountedOccurrence, usize)> = Vec::new();
1833
- for &group_index in &fully_clustered {
1834
- for (occurrence_index, occurrence) in
1835
- reported_groups[group_index].iter().enumerate()
1836
- {
1837
- if !consumed.contains(&(group_index, occurrence_index))
1838
- && occurrence.start_token_index < range.end_token_index
1839
- && range.start_token_index < occurrence.end_token_index
1943
+ let mut plain_nodes: Vec<usize> = Vec::new();
1944
+ for &node in block_nodes {
1945
+ let (range_start, range_end) = node_range(node);
1946
+ let fragment_count = fragments.len();
1947
+ for &group_index in &fully_clustered {
1948
+ for (occurrence_index, occurrence) in
1949
+ reported_groups[group_index].iter().enumerate()
1840
1950
  {
1841
- consumed.insert((group_index, occurrence_index));
1842
- fragments.push((occurrence.clone(), group_index));
1951
+ if !consumed.contains(&(group_index, occurrence_index))
1952
+ && occurrence.start_token_index < range_end
1953
+ && range_start < occurrence.end_token_index
1954
+ {
1955
+ consumed.insert((group_index, occurrence_index));
1956
+ fragments.push((occurrence.clone(), group_index));
1957
+ }
1843
1958
  }
1844
1959
  }
1960
+ if fragments.len() == fragment_count && touched_groups_of(node).is_empty() {
1961
+ plain_nodes.push(node);
1962
+ }
1963
+ }
1964
+ if fragments.is_empty() {
1965
+ unanchored_nodes.extend(plain_nodes);
1966
+ } else {
1967
+ // An untouched core of a block that also holds fragments is part of the same
1968
+ // copy; a group index no reported group uses keeps it in that copy.
1969
+ for occurrence in to_occurrences(&plain_nodes) {
1970
+ fragments.push((occurrence, usize::MAX));
1971
+ }
1845
1972
  }
1846
1973
  fragments.sort_by_key(|(occurrence, _)| {
1847
1974
  (occurrence.start_token_index, occurrence.end_token_index)
1848
1975
  });
1849
- let had_fragments = !fragments.is_empty();
1850
1976
  let mut copy_parts: Vec<CountedOccurrence> = Vec::new();
1851
1977
  let mut copy_groups: HashSet<usize> = HashSet::new();
1852
1978
  for (occurrence, group_index) in fragments {
@@ -1860,10 +1986,8 @@ fn collect_near_miss_groups(
1860
1986
  if !copy_parts.is_empty() {
1861
1987
  merged.push(coalesce_occurrences(copy_parts));
1862
1988
  }
1863
- if !had_fragments && touched_groups_by_block[member_index].is_empty() {
1864
- merged.push(to_occurrence(comparable[member_index]));
1865
- }
1866
1989
  }
1990
+ merged.extend(to_occurrences(&unanchored_nodes));
1867
1991
  merged.sort_by_key(|occurrence| {
1868
1992
  (occurrence.start_token_index, occurrence.end_token_index)
1869
1993
  });
@@ -1876,13 +2000,11 @@ fn collect_near_miss_groups(
1876
2000
  for &source_index in source_indexes {
1877
2001
  reported_groups[source_index].clear();
1878
2002
  }
1879
- } else if uncovered.len() >= 2 {
1880
- groups.push(
1881
- uncovered
1882
- .iter()
1883
- .map(|&index| to_occurrence(comparable[index]))
1884
- .collect(),
1885
- );
2003
+ } else {
2004
+ let occurrences = to_occurrences(&uncovered);
2005
+ if occurrences.len() >= 2 {
2006
+ groups.push(occurrences);
2007
+ }
1886
2008
  }
1887
2009
  }
1888
2010
  groups.sort_by_key(|group| group_sort_key(group));
@@ -2014,149 +2136,125 @@ fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrenc
2014
2136
  }
2015
2137
  }
2016
2138
 
2017
- struct NormalizedBlock {
2018
- sequence: Vec<i32>,
2019
- /// Occurrences per content-bearing symbol (names and literal values), for the content gate.
2020
- content_count_by_symbol: HashMap<i32, usize>,
2021
- content_total: usize,
2139
+ /// The unions of overlapping cores, in position order.
2140
+ fn merge_overlapping_cores(cores: &[(usize, usize)]) -> Vec<(usize, usize)> {
2141
+ let mut sorted = cores.to_vec();
2142
+ sorted.sort_unstable();
2143
+ let mut merged: Vec<(usize, usize)> = Vec::new();
2144
+ for (start, end) in sorted {
2145
+ match merged.last_mut() {
2146
+ Some(last) if start < last.1 => last.1 = last.1.max(end),
2147
+ _ => merged.push((start, end)),
2148
+ }
2149
+ }
2150
+ merged
2022
2151
  }
2023
2152
 
2024
- /// A block's tokens as comparable integers (literal VALUES are folded into the symbol, unlike the
2025
- /// exact fingerprint's kind tags).
2026
- fn normalize_block_sequence(
2027
- tokens: &[Token<'_>],
2028
- range: &TokenRange,
2029
- symbol_id_by_token_hashes: &mut HashMap<(i32, i32, i32, i32), i32>,
2030
- ) -> NormalizedBlock {
2031
- let mut sequence = Vec::with_capacity(range.end_token_index - range.start_token_index);
2032
- let mut index_by_identifier: HashMap<&str, i32> = HashMap::new();
2033
- let mut content_count_by_symbol: HashMap<i32, usize> = HashMap::new();
2034
- let mut content_total = 0usize;
2035
- for token in &tokens[range.start_token_index..range.end_token_index.min(tokens.len())] {
2036
- let value = if token.is_id {
2037
- let next_index = index_by_identifier.len() as i32;
2038
- let identifier_index = *index_by_identifier
2039
- .entry(token.text.as_ref())
2040
- .or_insert(next_index);
2041
- -(identifier_index + 1)
2042
- } else {
2043
- let next_id = symbol_id_by_token_hashes.len() as i32;
2044
- let id = *symbol_id_by_token_hashes
2153
+ /// The file's tokens as a near-miss symbol stream: identifiers as -(file-level id + 1), every
2154
+ /// other token interned from its hash pairs (literal VALUES folded in, unlike the exact
2155
+ /// fingerprint's kind tags), plus which tokens are content-bearing (names and literal values).
2156
+ /// Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
2157
+ /// other files the process measured before it.
2158
+ fn to_symbol_stream(tokens: &[Token<'_>]) -> (Vec<i32>, Vec<bool>) {
2159
+ let mut symbol_by_token_hashes: HashMap<(i32, i32, i32, i32), i32> = HashMap::new();
2160
+ let mut id_by_identifier: HashMap<&str, i32> = HashMap::new();
2161
+ tokens
2162
+ .iter()
2163
+ .map(|token| {
2164
+ if token.is_id {
2165
+ let next_id = id_by_identifier.len() as i32;
2166
+ let id = *id_by_identifier
2167
+ .entry(token.text.as_ref())
2168
+ .or_insert(next_id);
2169
+ return (-(id + 1), false);
2170
+ }
2171
+ let next_symbol = symbol_by_token_hashes.len() as i32;
2172
+ let symbol = *symbol_by_token_hashes
2045
2173
  .entry((
2046
2174
  token.text_hash,
2047
2175
  token.text_hash2,
2048
2176
  token.literal_hash.unwrap_or(0),
2049
2177
  token.literal_hash2.unwrap_or(0),
2050
2178
  ))
2051
- .or_insert(next_id);
2052
- if token.is_name || token.literal_hash.is_some() {
2053
- *content_count_by_symbol.entry(id).or_insert(0) += 1;
2054
- content_total += 1;
2055
- }
2056
- id
2057
- };
2058
- sequence.push(value);
2059
- }
2060
- NormalizedBlock {
2061
- sequence,
2062
- content_count_by_symbol,
2063
- content_total,
2064
- }
2065
- }
2066
-
2067
- /// Multiset overlap of two blocks' content-bearing symbols, for the content gate.
2068
- fn content_overlap(left: &NormalizedBlock, right: &NormalizedBlock) -> usize {
2069
- let (smaller, larger) =
2070
- if left.content_count_by_symbol.len() <= right.content_count_by_symbol.len() {
2071
- (left, right)
2072
- } else {
2073
- (right, left)
2074
- };
2075
- smaller
2076
- .content_count_by_symbol
2077
- .iter()
2078
- .map(|(symbol, count)| {
2079
- (*count).min(
2080
- larger
2081
- .content_count_by_symbol
2082
- .get(symbol)
2083
- .copied()
2084
- .unwrap_or(0),
2085
- )
2179
+ .or_insert(next_symbol);
2180
+ (symbol, token.is_name || token.literal_hash.is_some())
2086
2181
  })
2087
- .sum()
2182
+ .unzip()
2088
2183
  }
2089
2184
 
2090
- /// The distinct 5-gram hashes of a normalized block sequence, matching collectNgramSet exactly.
2091
- fn collect_ngram_set(sequence: &[i32]) -> HashSet<i32> {
2092
- if sequence.len() < NEAR_MISS_NGRAM_SIZE {
2093
- return HashSet::new();
2094
- }
2095
- // Exact upper bound: one n-gram per window, and most windows hash distinctly.
2096
- let mut ngrams = HashSet::with_capacity(sequence.len() - NEAR_MISS_NGRAM_SIZE + 1);
2097
- for window in sequence.windows(NEAR_MISS_NGRAM_SIZE) {
2098
- let mut hash: i32 = 5381;
2099
- for &value in window {
2100
- hash = hash.wrapping_mul(31).wrapping_add(value);
2185
+ /// Returns a lookup of the outermost container statements inside a token range, excluding a
2186
+ /// statement spanning the whole range (the block itself).
2187
+ fn top_level_statement_finder(
2188
+ container_statement_ranges: &[Vec<TokenRange>],
2189
+ ) -> impl Fn(usize, usize) -> Vec<(usize, usize)> {
2190
+ let mut statements: Vec<(usize, usize)> = container_statement_ranges
2191
+ .iter()
2192
+ .flatten()
2193
+ .map(|range| (range.start_token_index, range.end_token_index))
2194
+ .filter(|(start, end)| start < end)
2195
+ .collect();
2196
+ statements.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
2197
+ move |start, end| {
2198
+ let mut top_level: Vec<(usize, usize)> = Vec::new();
2199
+ let first = statements.partition_point(|statement| statement.0 < start);
2200
+ for &statement in statements[first..]
2201
+ .iter()
2202
+ .take_while(|statement| statement.0 < end)
2203
+ {
2204
+ let nested = top_level.last().is_some_and(|last| statement.0 < last.1);
2205
+ if statement.1 <= end && statement != (start, end) && !nested {
2206
+ top_level.push(statement);
2207
+ }
2101
2208
  }
2102
- ngrams.insert(hash);
2209
+ top_level
2103
2210
  }
2104
- ngrams
2105
2211
  }
2106
2212
 
2107
- /// Shared distinct-n-gram counts per block pair (left < right).
2108
- fn count_shared_ngrams(ngram_sets: &[HashSet<i32>]) -> HashMap<(usize, usize), usize> {
2109
- let mut blocks_by_ngram: HashMap<i32, Vec<usize>> = HashMap::new();
2110
- for (block_index, ngrams) in ngram_sets.iter().enumerate() {
2111
- for &ngram in ngrams {
2112
- blocks_by_ngram.entry(ngram).or_default().push(block_index);
2113
- }
2114
- }
2115
- let mut shared_counts: HashMap<(usize, usize), usize> = HashMap::new();
2116
- for blocks in blocks_by_ngram.values() {
2117
- for (position, &left_index) in blocks.iter().enumerate() {
2118
- // Bucket indices are appended in ascending block order, so left < right already.
2119
- for &right_index in &blocks[position + 1..] {
2120
- *shared_counts.entry((left_index, right_index)).or_insert(0) += 1;
2213
+ /// Visits every block pair sharing at least FILTRATION_PERCENT of the smaller block's distinct
2214
+ /// n-grams, except pairs whose length ratio rules out both whole-block similarity and
2215
+ /// MAX_LENGTH_RATIO; the same scan as forEachCandidatePair in crossFileNearMiss.ts. Blocks are
2216
+ /// visited in ascending length, so each posting list is scanned backwards only while its blocks are
2217
+ /// long enough, and shared counts accumulate in a dense counter instead of a pair map.
2218
+ fn for_each_candidate_pair(
2219
+ blocks: &[Block],
2220
+ min_similarity_percent: usize,
2221
+ mut visit: impl FnMut(usize, usize),
2222
+ ) {
2223
+ let mut order: Vec<usize> = (0..blocks.len()).collect();
2224
+ order.sort_by_key(|&index| blocks[index].len());
2225
+ let mut postings: HashMap<i32, Vec<usize>> = HashMap::new();
2226
+ let mut shared_counts = vec![0usize; blocks.len()];
2227
+ let mut touched: Vec<usize> = Vec::new();
2228
+ for right in order {
2229
+ let length = blocks[right].len();
2230
+ let min_left_length = length
2231
+ .div_ceil(MAX_LENGTH_RATIO)
2232
+ .min((min_similarity_percent * length).div_ceil(100));
2233
+ for &ngram in &blocks[right].ngrams {
2234
+ let posting = postings.entry(ngram).or_default();
2235
+ for &left in posting.iter().rev() {
2236
+ if blocks[left].len() < min_left_length {
2237
+ break;
2238
+ }
2239
+ if shared_counts[left] == 0 {
2240
+ touched.push(left);
2241
+ }
2242
+ shared_counts[left] += 1;
2121
2243
  }
2244
+ posting.push(right);
2122
2245
  }
2123
- }
2124
- shared_counts
2125
- }
2126
-
2127
- /// Longest-common-subsequence LENGTH via the Allison–Dix bit-parallel recurrence. Only the length
2128
- /// is needed and LCS length is algorithm-independent, so u64 words are safe even though the
2129
- /// TypeScript port in src/duplication.ts uses 32-bit words.
2130
- fn lcs_length(a: &[i32], b: &[i32]) -> usize {
2131
- if a.is_empty() || b.is_empty() {
2132
- return 0;
2133
- }
2134
- let word_count = a.len().div_ceil(64);
2135
- let mut position_masks: HashMap<i32, Vec<u64>> = HashMap::new();
2136
- for (index, &symbol) in a.iter().enumerate() {
2137
- position_masks
2138
- .entry(symbol)
2139
- .or_insert_with(|| vec![0; word_count])[index / 64] |= 1u64 << (index % 64);
2140
- }
2141
-
2142
- let mut v = vec![0u64; word_count];
2143
- for symbol in b {
2144
- let match_mask = position_masks.get(symbol);
2145
- // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.
2146
- let mut shift_carry = 1u64;
2147
- let mut borrow = 0u64;
2148
- for (word, slot) in v.iter_mut().enumerate() {
2149
- let previous = *slot;
2150
- let x = match_mask.map_or(0, |mask| mask[word]) | previous;
2151
- let shifted = (previous << 1) | shift_carry;
2152
- shift_carry = previous >> 63;
2153
- let (partial, underflow1) = x.overflowing_sub(shifted);
2154
- let (difference, underflow2) = partial.overflowing_sub(borrow);
2155
- borrow = u64::from(underflow1 || underflow2);
2156
- *slot = x & !difference;
2246
+ // Ascending so the visit order does not depend on the n-gram set's iteration order.
2247
+ touched.sort_unstable();
2248
+ for &left in &touched {
2249
+ let shared = std::mem::take(&mut shared_counts[left]);
2250
+ if shared * 100
2251
+ >= FILTRATION_PERCENT * blocks[left].ngrams.len().min(blocks[right].ngrams.len())
2252
+ {
2253
+ visit(left, right);
2254
+ }
2157
2255
  }
2256
+ touched.clear();
2158
2257
  }
2159
- v.iter().map(|word| word.count_ones() as usize).sum()
2160
2258
  }
2161
2259
 
2162
2260
  /// Redundant copies one group adds to duplicate_block_count; a faithful port of