code-gauge 10.1.0 → 10.2.1

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 (42) hide show
  1. package/README.md +37 -15
  2. package/THIRD-PARTY-NOTICES.txt +8 -8
  3. package/dist/checkCommand.cjs +1 -1
  4. package/dist/checkCommand.cjs.map +1 -1
  5. package/dist/checkCommand.js +1 -1
  6. package/dist/checkCommand.js.map +1 -1
  7. package/dist/crossFileDuplication.cjs +1 -1
  8. package/dist/crossFileDuplication.cjs.map +1 -1
  9. package/dist/crossFileDuplication.d.ts +3 -2
  10. package/dist/crossFileDuplication.js +1 -1
  11. package/dist/crossFileDuplication.js.map +1 -1
  12. package/dist/crossFileNearMiss.cjs +1 -1
  13. package/dist/crossFileNearMiss.cjs.map +1 -1
  14. package/dist/crossFileNearMiss.d.ts +9 -4
  15. package/dist/crossFileNearMiss.js +1 -1
  16. package/dist/crossFileNearMiss.js.map +1 -1
  17. package/dist/duplication.cjs +1 -1
  18. package/dist/duplication.cjs.map +1 -1
  19. package/dist/duplication.d.ts +28 -7
  20. package/dist/duplication.js +1 -1
  21. package/dist/duplication.js.map +1 -1
  22. package/dist/thresholdCheck.cjs +1 -1
  23. package/dist/thresholdCheck.cjs.map +1 -1
  24. package/dist/thresholdCheck.d.ts +2 -0
  25. package/dist/thresholdCheck.js +1 -1
  26. package/dist/thresholdCheck.js.map +1 -1
  27. package/dist/thresholds.cjs.map +1 -1
  28. package/dist/thresholds.d.ts +2 -3
  29. package/dist/thresholds.js.map +1 -1
  30. package/dist/types.d.ts +20 -8
  31. package/native/Cargo.lock +16 -16
  32. package/native/Cargo.toml +8 -8
  33. package/native/code-gauge.wasm +0 -0
  34. package/native/src/complexity.rs +10 -1
  35. package/native/src/container.rs +158 -7
  36. package/native/src/dep_degree.rs +13 -14
  37. package/native/src/duplication.rs +302 -137
  38. package/native/src/functions.rs +112 -24
  39. package/native/src/languages.rs +5 -0
  40. package/native/src/measure.rs +9 -4
  41. package/native/src/near_miss.rs +303 -52
  42. package/package.json +8 -8
@@ -4,7 +4,9 @@ use std::borrow::Cow;
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
+ use crate::near_miss::{
8
+ Block, CorePair, Matcher, PairMatch, TokenRuns, FILTRATION_PERCENT, MAX_LENGTH_RATIO,
9
+ };
8
10
  use crate::tree_index::NodeExt;
9
11
  use crate::types::{
10
12
  CrossFileCandidate, CrossFileToken, CrossFileTokenRange, DuplicateBlockOccurrence,
@@ -1577,9 +1579,16 @@ fn dedupe_by_region(group: Vec<DuplicateCandidate>) -> Vec<DuplicateCandidate> {
1577
1579
  #[derive(Clone)]
1578
1580
  struct CountedOccurrence {
1579
1581
  segments: Vec<(usize, usize)>,
1580
- /// Set on a retained group's occurrences that a partial gapped merge also paired into a
1581
- /// merged group: their spans are counted there, so block counting must not count them again.
1582
+ /// Set on an occurrence whose span another group counts, so block counting must not count it
1583
+ /// again: a retained group's occurrence that a partial gapped merge also paired into a merged
1584
+ /// group, and a near-miss anchor, which overlaps the reported occurrence it was matched through.
1582
1585
  shared_with_merged_group: bool,
1586
+ /// The token runs of a near-miss copy that its partners match, within its segments;
1587
+ /// `None` for an exact or gapped copy, whose segments are matched throughout.
1588
+ matched_runs: Option<Vec<(usize, usize)>>,
1589
+ /// The exact fragments coalesced into a near-miss copy: each line they reach is duplicated,
1590
+ /// whatever else of the copy shares the line.
1591
+ exact_runs: Vec<(usize, usize)>,
1583
1592
  /// Sum of segment token counts (the gap tokens are not matched content).
1584
1593
  token_count: usize,
1585
1594
  start_token_index: usize,
@@ -1588,6 +1597,16 @@ struct CountedOccurrence {
1588
1597
  end_line: usize,
1589
1598
  }
1590
1599
 
1600
+ impl CountedOccurrence {
1601
+ /// Whether a matched segment overlaps the token range: the gap of a gapped clone is not
1602
+ /// matched content.
1603
+ fn matches_within(&self, (start, end): (usize, usize)) -> bool {
1604
+ self.segments
1605
+ .iter()
1606
+ .any(|&(segment_start, segment_end)| segment_start < end && start < segment_end)
1607
+ }
1608
+ }
1609
+
1591
1610
  fn to_counted_groups(
1592
1611
  counted: &IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>>,
1593
1612
  ) -> Vec<Vec<CountedOccurrence>> {
@@ -1597,6 +1616,8 @@ fn to_counted_groups(
1597
1616
  .iter()
1598
1617
  .map(|candidate| CountedOccurrence {
1599
1618
  shared_with_merged_group: false,
1619
+ matched_runs: None,
1620
+ exact_runs: Vec::new(),
1600
1621
  segments: vec![(candidate.start_token_index, candidate.end_token_index)],
1601
1622
  token_count: candidate.token_count,
1602
1623
  start_token_index: candidate.start_token_index,
@@ -1785,6 +1806,8 @@ fn merge_groups(
1785
1806
  CountedOccurrence {
1786
1807
  // A merged occurrence is a fresh span combination; it inherits no shared marks.
1787
1808
  shared_with_merged_group: false,
1809
+ matched_runs: None,
1810
+ exact_runs: Vec::new(),
1788
1811
  segments: [leading.segments.clone(), trailing.segments.clone()].concat(),
1789
1812
  token_count: leading.token_count + trailing.token_count,
1790
1813
  start_token_index: leading.start_token_index,
@@ -1802,15 +1825,32 @@ fn merge_groups(
1802
1825
  })
1803
1826
  }
1804
1827
 
1805
- /// A verified near-miss pair as (block, core, block, core); a `None` core is a whole-block match.
1806
- type MatchEdge = (usize, Option<(usize, usize)>, usize, Option<(usize, usize)>);
1828
+ /// A verified near-miss pair of blocks; without `cores`, the blocks match as wholes.
1829
+ struct MatchEdge {
1830
+ left: usize,
1831
+ right: usize,
1832
+ cores: Option<CorePair>,
1833
+ /// The token runs the pair matches, on the left and on the right.
1834
+ matched_runs: (TokenRuns, TokenRuns),
1835
+ }
1836
+
1837
+ impl MatchEdge {
1838
+ /// The (block, core) on each side.
1839
+ fn sides(&self) -> [(usize, Option<(usize, usize)>); 2] {
1840
+ let (left_core, right_core) = self.cores.unzip();
1841
+ [(self.left, left_core), (self.right, right_core)]
1842
+ }
1843
+ }
1807
1844
 
1808
1845
  /// Detects near-miss (Type-3) clone groups among block candidates the exact pipeline left
1809
1846
  /// unreported: NIL-style n-gram filtration, then pair verification (near_miss::Matcher), then
1810
1847
  /// transitive clustering of verified pairs (crossFileNearMiss.ts applies the same model across
1811
1848
  /// files). A block that matched only locally is reported as its matched cores (overlapping cores
1812
1849
  /// merged), each clustered with its own partners, so code no verified pair matched never counts
1813
- /// as duplicated.
1850
+ /// as duplicated. A node overlapping a reported occurrence is an anchor: it joins its group at its
1851
+ /// own size, so the copies of a block are listed at the size they matched it, and is marked as
1852
+ /// counted elsewhere unless the group takes over the reported occurrences it overlaps. An anchor
1853
+ /// still counts the lines its partners match outside those occurrences.
1814
1854
  fn collect_near_miss_groups(
1815
1855
  source: &TokenizedSource<'_>,
1816
1856
  settings: &DuplicationSettings,
@@ -1825,22 +1865,23 @@ fn collect_near_miss_groups(
1825
1865
  return Vec::new();
1826
1866
  }
1827
1867
 
1828
- // Reported-group indices whose occurrences overlap a token range: near-miss nodes covering
1829
- // such content anchor comparisons but are never re-reported.
1868
+ // Reported-group indices whose occurrences' matched segments overlap a token range (the gap
1869
+ // of a gapped clone is not reported content): near-miss nodes covering such content anchor
1870
+ // comparisons but are never re-reported.
1830
1871
  let touched_groups_in = |start: usize, end: usize| -> Vec<usize> {
1831
1872
  reported_groups
1832
1873
  .iter()
1833
1874
  .enumerate()
1834
1875
  .filter(|(_, group)| {
1835
- group.iter().any(|occurrence| {
1836
- occurrence.start_token_index < end && start < occurrence.end_token_index
1837
- })
1876
+ group
1877
+ .iter()
1878
+ .any(|occurrence| occurrence.matches_within((start, end)))
1838
1879
  })
1839
1880
  .map(|(group_index, _)| group_index)
1840
1881
  .collect()
1841
1882
  };
1842
1883
 
1843
- let (symbols, is_content) = to_symbol_stream(tokens);
1884
+ let (symbols, is_content, token_keys) = to_symbol_stream(tokens);
1844
1885
  let statements = top_level_statement_finder(&source.container_statement_ranges);
1845
1886
  let mut blocks: Vec<Block> = comparable
1846
1887
  .iter()
@@ -1848,6 +1889,7 @@ fn collect_near_miss_groups(
1848
1889
  Block::new(
1849
1890
  &symbols,
1850
1891
  &is_content,
1892
+ &token_keys,
1851
1893
  range.start_token_index,
1852
1894
  range.end_token_index,
1853
1895
  statements(range.start_token_index, range.end_token_index),
@@ -1860,27 +1902,69 @@ fn collect_near_miss_groups(
1860
1902
  settings.min_similarity_percent,
1861
1903
  );
1862
1904
 
1863
- let block_touched: Vec<bool> = comparable
1864
- .iter()
1865
- .map(|range| !touched_groups_in(range.start_token_index, range.end_token_index).is_empty())
1866
- .collect();
1905
+ // The lines of a near-miss copy that are duplicated are those a verified partner matches,
1906
+ // which takes the pairing itself. Every pair kept is aligned: verifying it already cost a
1907
+ // longest common subsequence of the same sequences, and any choice among a copy's partners
1908
+ // would let their number or their order decide what the copy counts.
1909
+ //
1910
+ // A pair matching nothing outside reported content has nothing to add to it and is dropped
1911
+ // before clustering, so that it neither collapses the blocks' core nodes nor widens a node
1912
+ // beyond what the pairs clustering it matched. Any other pair is kept however much of it is
1913
+ // reported: what the exact pipeline reports of a copy depends on where its copies stand, and
1914
+ // the lines the copy counts must not.
1915
+ let mut reported_prefix = vec![0usize; tokens.len() + 1];
1916
+ {
1917
+ let mut reported = vec![false; tokens.len()];
1918
+ for occurrence in reported_groups.iter().flatten() {
1919
+ for &(start, end) in &occurrence.segments {
1920
+ reported[start..end].fill(true);
1921
+ }
1922
+ }
1923
+ for (index, &flag) in reported.iter().enumerate() {
1924
+ reported_prefix[index + 1] = reported_prefix[index] + usize::from(flag);
1925
+ }
1926
+ }
1927
+ let reported_count =
1928
+ |(start, end): (usize, usize)| reported_prefix[end] - reported_prefix[start];
1929
+ let unreported_count = |runs: &[(usize, usize)]| -> usize {
1930
+ runs.iter()
1931
+ .map(|&run| run.1 - run.0 - reported_count(run))
1932
+ .sum()
1933
+ };
1934
+ let block_span = |index: usize| {
1935
+ (
1936
+ comparable[index].start_token_index,
1937
+ comparable[index].end_token_index,
1938
+ )
1939
+ };
1867
1940
  let mut edges: Vec<MatchEdge> = Vec::new();
1941
+ let mut add_edge = |left: usize, right: usize, cores: Option<CorePair>, alignment| {
1942
+ let (left_span, right_span) = cores.unwrap_or((block_span(left), block_span(right)));
1943
+ // Fully reported spans need no alignment to be dropped.
1944
+ if unreported_count(&[left_span, right_span]) == 0 {
1945
+ return;
1946
+ }
1947
+ let matched_runs =
1948
+ matcher.align(&blocks[left], &blocks[right], cores, alignment, &token_keys);
1949
+ if unreported_count(&matched_runs.0) + unreported_count(&matched_runs.1) == 0 {
1950
+ return;
1951
+ }
1952
+ edges.push(MatchEdge {
1953
+ left,
1954
+ right,
1955
+ cores,
1956
+ matched_runs,
1957
+ });
1958
+ };
1868
1959
  for_each_candidate_pair(
1869
1960
  &blocks,
1870
1961
  settings.min_similarity_percent,
1871
1962
  |left_index, right_index| match matcher.verify(&blocks[left_index], &blocks[right_index]) {
1872
1963
  None => {}
1873
- // A whole match between two blocks that both overlap reported content could never join
1874
- // a group, and recording it would collapse the blocks' core nodes.
1875
- Some(PairMatch::Whole)
1876
- if !(block_touched[left_index] && block_touched[right_index]) =>
1877
- {
1878
- edges.push((left_index, None, right_index, None))
1879
- }
1880
- Some(PairMatch::Whole) => {}
1964
+ Some(PairMatch::Whole(alignment)) => add_edge(left_index, right_index, None, alignment),
1881
1965
  Some(PairMatch::Local(cores)) => {
1882
- for (left_core, right_core) in cores {
1883
- edges.push((left_index, Some(left_core), right_index, Some(right_core)));
1966
+ for (core_pair, alignment) in cores {
1967
+ add_edge(left_index, right_index, Some(core_pair), alignment);
1884
1968
  }
1885
1969
  }
1886
1970
  },
@@ -1891,8 +1975,8 @@ fn collect_near_miss_groups(
1891
1975
  // cores matched with different partners fall into separate groups.
1892
1976
  let mut matched_whole = vec![false; comparable.len()];
1893
1977
  let mut local_cores: Vec<Vec<(usize, usize)>> = vec![Vec::new(); comparable.len()];
1894
- for &(left_index, left_core, right_index, right_core) in &edges {
1895
- for (index, core) in [(left_index, left_core), (right_index, right_core)] {
1978
+ for edge in &edges {
1979
+ for (index, core) in edge.sides() {
1896
1980
  match core {
1897
1981
  Some(core) => local_cores[index].push(core),
1898
1982
  None => matched_whole[index] = true,
@@ -1958,22 +2042,22 @@ fn collect_near_miss_groups(
1958
2042
  touched_groups_in(start, end)
1959
2043
  })
1960
2044
  .collect();
1961
- for &(left_index, left_core, right_index, right_core) in &edges {
1962
- let (left_node, right_node) = (
1963
- node_of(left_index, left_core),
1964
- node_of(right_index, right_core),
1965
- );
1966
- // Two already-reported nodes have nothing new to contribute to each other.
1967
- if !touched_groups_by_node[left_node].is_empty()
1968
- && !touched_groups_by_node[right_node].is_empty()
1969
- {
1970
- continue;
1971
- }
2045
+ let edge_nodes: Vec<[usize; 2]> = edges
2046
+ .iter()
2047
+ .map(|edge| edge.sides().map(|(index, core)| node_of(index, core)))
2048
+ .collect();
2049
+ for &[left_node, right_node] in &edge_nodes {
1972
2050
  let left_root = find(&mut parent, left_node);
1973
2051
  let right_root = find(&mut parent, right_node);
1974
2052
  parent[left_root.max(right_root)] = left_root.min(right_root);
1975
2053
  }
1976
2054
 
2055
+ let mut matched_runs_by_node: Vec<Vec<(usize, usize)>> = vec![Vec::new(); node_blocks.len()];
2056
+ for (edge, nodes) in edges.iter().zip(&edge_nodes) {
2057
+ matched_runs_by_node[nodes[0]].extend(&edge.matched_runs.0);
2058
+ matched_runs_by_node[nodes[1]].extend(&edge.matched_runs.1);
2059
+ }
2060
+
1977
2061
  let mut members_by_root: IndexMap<usize, Vec<usize>> = IndexMap::new();
1978
2062
  for node in 0..node_blocks.len() {
1979
2063
  let root = find(&mut parent, node);
@@ -2001,8 +2085,15 @@ fn collect_near_miss_groups(
2001
2085
  segments.sort_unstable();
2002
2086
  let whole = spans.iter().any(Option::is_none);
2003
2087
  let (start, end) = (segments[0].0, segments[segments.len() - 1].1);
2088
+ let matched_runs: Vec<(usize, usize)> = nodes
2089
+ .iter()
2090
+ .filter(|&&node| node_blocks[node] == block)
2091
+ .flat_map(|&node| matched_runs_by_node[node].iter().copied())
2092
+ .collect();
2004
2093
  CountedOccurrence {
2005
2094
  shared_with_merged_group: false,
2095
+ matched_runs: Some(merge_overlapping_cores(&matched_runs)),
2096
+ exact_runs: Vec::new(),
2006
2097
  token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
2007
2098
  segments,
2008
2099
  start_token_index: start,
@@ -2022,6 +2113,13 @@ fn collect_near_miss_groups(
2022
2113
  .collect()
2023
2114
  };
2024
2115
  let touched_groups_of = |node: usize| &touched_groups_by_node[node];
2116
+ let to_anchor_occurrences = |nodes: &[usize]| -> Vec<CountedOccurrence> {
2117
+ let mut anchors = to_occurrences(nodes);
2118
+ for anchor in &mut anchors {
2119
+ anchor.shared_with_merged_group = true;
2120
+ }
2121
+ anchors
2122
+ };
2025
2123
  let mut groups: Vec<Vec<CountedOccurrence>> = Vec::new();
2026
2124
  for members in members_by_root.values() {
2027
2125
  if members.len() < 2 {
@@ -2044,17 +2142,42 @@ fn collect_near_miss_groups(
2044
2142
  }
2045
2143
  continue;
2046
2144
  }
2047
- if uncovered.is_empty() {
2145
+ if members.iter().all(|&node| {
2146
+ let (start, end) = node_range(node);
2147
+ reported_count((start, end)) == end - start
2148
+ }) {
2048
2149
  continue;
2049
2150
  }
2050
- // An anchored cluster extends a reported group only when every occurrence of that group
2051
- // overlaps one of the cluster's member nodes: an occurrence disjoint from all members
2052
- // reports content the cluster does not share.
2053
- let overlaps_member = |occurrence: &CountedOccurrence| {
2054
- members.iter().any(|&index| {
2055
- let (start, end) = node_range(index);
2056
- occurrence.start_token_index < end && start < occurrence.end_token_index
2057
- })
2151
+ // An anchored cluster extends a reported group only when it stands for the same copies:
2152
+ // every occurrence of the group overlaps the member nodes of exactly one block (one
2153
+ // disjoint from all members reports content the cluster does not share, and one reaching
2154
+ // into two members would make one copy take over lines of the next), exceeds the span of
2155
+ // those nodes by less than `min_tokens` (a copy is listed at the size it matched, not at
2156
+ // that of a clone larger by a reportable part, which may run into code no member
2157
+ // matched), and shares that block with no other occurrence of the group (those repeat
2158
+ // within the copy, not between the copies).
2159
+ let member_block_of = |occurrence: &CountedOccurrence| {
2160
+ let mut blocks = members
2161
+ .iter()
2162
+ .filter(|&&index| occurrence.matches_within(node_range(index)))
2163
+ .map(|&index| node_blocks[index]);
2164
+ let block = blocks.next()?;
2165
+ let mut spans = members
2166
+ .iter()
2167
+ .filter(|&&index| node_blocks[index] == block)
2168
+ .map(|&index| node_range(index));
2169
+ let first = spans.next()?;
2170
+ let span = spans.fold(first, |span, node| (span.0.min(node.0), span.1.max(node.1)));
2171
+ let excess = span.0.saturating_sub(occurrence.start_token_index)
2172
+ + occurrence.end_token_index.saturating_sub(span.1);
2173
+ (blocks.all(|other| other == block) && excess < settings.min_tokens).then_some(block)
2174
+ };
2175
+ let stands_for_members = |group: &[CountedOccurrence]| {
2176
+ let mut blocks: FxHashSet<usize> = FxHashSet::default();
2177
+ !group.is_empty()
2178
+ && group.iter().all(|occurrence| {
2179
+ member_block_of(occurrence).is_some_and(|block| blocks.insert(block))
2180
+ })
2058
2181
  };
2059
2182
  // Ascending by construction: BTreeSet iteration is sorted and filter preserves order.
2060
2183
  let fully_clustered: Vec<usize> = covered
@@ -2062,18 +2185,15 @@ fn collect_near_miss_groups(
2062
2185
  .flat_map(|&index| touched_groups_of(index).iter().copied())
2063
2186
  .collect::<std::collections::BTreeSet<usize>>()
2064
2187
  .into_iter()
2065
- .filter(|&group_index| {
2066
- let group = &reported_groups[group_index];
2067
- !group.is_empty() && group.iter().all(&overlaps_member)
2068
- })
2188
+ .filter(|&group_index| stands_for_members(&reported_groups[group_index]))
2069
2189
  .collect();
2070
2190
  if let Some((&target_index, source_indexes)) = fully_clustered.split_first() {
2071
2191
  // Rebuild the component as ONE group with one coalesced occurrence per member block:
2072
- // the fragments every node of a block overlaps are collected together, since a block's
2192
+ // the fragments the nodes of a block overlap are collected together, since a block's
2073
2193
  // cores are parts of one copy.
2074
- let mut consumed: FxHashSet<(usize, usize)> = FxHashSet::default();
2075
2194
  let mut merged: Vec<CountedOccurrence> = Vec::new();
2076
2195
  let mut unanchored_nodes: Vec<usize> = Vec::new();
2196
+ let mut anchor_nodes: Vec<usize> = Vec::new();
2077
2197
  let mut nodes_by_block: IndexMap<usize, Vec<usize>> = IndexMap::new();
2078
2198
  for &member_index in members {
2079
2199
  nodes_by_block
@@ -2082,73 +2202,51 @@ fn collect_near_miss_groups(
2082
2202
  .push(member_index);
2083
2203
  }
2084
2204
  for block_nodes in nodes_by_block.values() {
2085
- // Occurrences of ONE group are distinct copies; only fragments from DIFFERENT
2086
- // groups belong to the same copy. Consecutive position-order slices keep the
2087
- // coalesced spans disjoint.
2088
- let mut fragments: Vec<(CountedOccurrence, usize)> = Vec::new();
2089
- let mut plain_nodes: Vec<usize> = Vec::new();
2090
- for &node in block_nodes {
2091
- let (range_start, range_end) = node_range(node);
2092
- let fragment_count = fragments.len();
2093
- for &group_index in &fully_clustered {
2094
- for (occurrence_index, occurrence) in
2095
- reported_groups[group_index].iter().enumerate()
2096
- {
2097
- if !consumed.contains(&(group_index, occurrence_index))
2098
- && occurrence.start_token_index < range_end
2099
- && range_start < occurrence.end_token_index
2100
- {
2101
- consumed.insert((group_index, occurrence_index));
2102
- fragments.push((occurrence.clone(), group_index));
2103
- }
2104
- }
2105
- }
2106
- if fragments.len() == fragment_count && touched_groups_of(node).is_empty() {
2107
- plain_nodes.push(node);
2108
- }
2109
- }
2205
+ let overlaps_block_node = |occurrence: &&CountedOccurrence| {
2206
+ block_nodes
2207
+ .iter()
2208
+ .any(|&node| occurrence.matches_within(node_range(node)))
2209
+ };
2210
+ let mut fragments: Vec<CountedOccurrence> = fully_clustered
2211
+ .iter()
2212
+ .flat_map(|&group_index| &reported_groups[group_index])
2213
+ .filter(overlaps_block_node)
2214
+ .cloned()
2215
+ .collect();
2110
2216
  if fragments.is_empty() {
2111
- unanchored_nodes.extend(plain_nodes);
2112
- } else {
2113
- // An untouched core of a block that also holds fragments is part of the same
2114
- // copy; a group index no reported group uses keeps it in that copy.
2115
- for occurrence in to_occurrences(&plain_nodes) {
2116
- fragments.push((occurrence, usize::MAX));
2117
- }
2118
- }
2119
- fragments.sort_by_key(|(occurrence, _)| {
2120
- (occurrence.start_token_index, occurrence.end_token_index)
2121
- });
2122
- let mut copy_parts: Vec<CountedOccurrence> = Vec::new();
2123
- let mut copy_groups: FxHashSet<usize> = FxHashSet::default();
2124
- for (occurrence, group_index) in fragments {
2125
- if copy_groups.contains(&group_index) {
2126
- merged.push(coalesce_occurrences(std::mem::take(&mut copy_parts)));
2127
- copy_groups.clear();
2128
- }
2129
- copy_parts.push(occurrence);
2130
- copy_groups.insert(group_index);
2131
- }
2132
- if !copy_parts.is_empty() {
2133
- merged.push(coalesce_occurrences(copy_parts));
2217
+ let (anchors, plain): (Vec<usize>, Vec<usize>) = block_nodes
2218
+ .iter()
2219
+ .partition(|&&node| !touched_groups_of(node).is_empty());
2220
+ anchor_nodes.extend(anchors);
2221
+ unanchored_nodes.extend(plain);
2222
+ continue;
2134
2223
  }
2224
+ // The block's nodes are part of the same copy as its fragments: a block matched
2225
+ // beyond the fragments it holds is a copy at that size.
2226
+ fragments.extend(to_occurrences(block_nodes));
2227
+ merged.push(coalesce_occurrences(fragments));
2135
2228
  }
2136
2229
  merged.extend(to_occurrences(&unanchored_nodes));
2137
- merged.sort_by_key(|occurrence| {
2138
- (occurrence.start_token_index, occurrence.end_token_index)
2139
- });
2140
2230
  // The rebuild consumed every fully-clustered group, so shared-span marks from earlier
2141
2231
  // partial merges no longer point at a separate merged group.
2142
2232
  for occurrence in &mut merged {
2143
2233
  occurrence.shared_with_merged_group = false;
2144
2234
  }
2235
+ merged.extend(to_anchor_occurrences(&anchor_nodes));
2236
+ merged.sort_by_key(|occurrence| {
2237
+ (occurrence.start_token_index, occurrence.end_token_index)
2238
+ });
2145
2239
  reported_groups[target_index] = merged;
2146
2240
  for &source_index in source_indexes {
2147
2241
  reported_groups[source_index].clear();
2148
2242
  }
2149
2243
  } else {
2150
- let occurrences = to_occurrences(&uncovered);
2244
+ let mut occurrences = to_occurrences(&uncovered);
2245
+ occurrences.extend(to_anchor_occurrences(&covered));
2151
2246
  if occurrences.len() >= 2 {
2247
+ occurrences.sort_by_key(|occurrence| {
2248
+ (occurrence.start_token_index, occurrence.end_token_index)
2249
+ });
2152
2250
  groups.push(occurrences);
2153
2251
  }
2154
2252
  }
@@ -2263,8 +2361,41 @@ fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrenc
2263
2361
  _ => segments.push(segment),
2264
2362
  }
2265
2363
  }
2364
+ // An exact fragment is matched throughout, a near-miss part where its partners match it.
2365
+ let matched_runs = occurrences
2366
+ .iter()
2367
+ .any(|occurrence| occurrence.matched_runs.is_some())
2368
+ .then(|| {
2369
+ let runs: Vec<(usize, usize)> = occurrences
2370
+ .iter()
2371
+ .flat_map(|occurrence| {
2372
+ occurrence
2373
+ .matched_runs
2374
+ .as_ref()
2375
+ .unwrap_or(&occurrence.segments)
2376
+ .iter()
2377
+ .copied()
2378
+ })
2379
+ .collect();
2380
+ merge_overlapping_cores(&runs)
2381
+ });
2382
+ let exact_runs: Vec<(usize, usize)> = occurrences
2383
+ .iter()
2384
+ .flat_map(|occurrence| match occurrence.matched_runs {
2385
+ Some(_) => &occurrence.exact_runs,
2386
+ None => &occurrence.segments,
2387
+ })
2388
+ .copied()
2389
+ .collect();
2266
2390
  CountedOccurrence {
2267
2391
  shared_with_merged_group: false,
2392
+ // Without a near-miss part, the segments are matched throughout and say it all.
2393
+ exact_runs: if matched_runs.is_some() {
2394
+ merge_overlapping_cores(&exact_runs)
2395
+ } else {
2396
+ Vec::new()
2397
+ },
2398
+ matched_runs,
2268
2399
  token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
2269
2400
  start_token_index: occurrences
2270
2401
  .iter()
@@ -2300,32 +2431,45 @@ fn merge_overlapping_cores(cores: &[(usize, usize)]) -> Vec<(usize, usize)> {
2300
2431
  /// other token interned from its hash pairs (literal VALUES folded in, unlike the exact
2301
2432
  /// fingerprint's kind tags), plus which tokens are content-bearing (names and literal values).
2302
2433
  /// Interned per call so a file's symbol ids (and thus its n-gram hashes) never depend on which
2303
- /// other files the process measured before it.
2304
- fn to_symbol_stream(tokens: &[Token<'_>]) -> (Vec<i32>, Vec<bool>) {
2434
+ /// other files the process measured before it. A symbol id still depends on where in the file its
2435
+ /// token first occurs, so each non-identifier token also gets a key of its content alone.
2436
+ fn to_symbol_stream(tokens: &[Token<'_>]) -> (Vec<i32>, Vec<bool>, Vec<i64>) {
2305
2437
  let mut symbol_by_token_hashes: FxHashMap<(i32, i32, i32, i32), i32> = FxHashMap::default();
2306
2438
  let mut id_by_identifier: FxHashMap<&str, i32> = FxHashMap::default();
2307
- tokens
2308
- .iter()
2309
- .map(|token| {
2310
- if token.is_id {
2311
- let next_id = id_by_identifier.len() as i32;
2312
- let id = *id_by_identifier
2313
- .entry(token.text.as_ref())
2314
- .or_insert(next_id);
2315
- return (-(id + 1), false);
2316
- }
2317
- let next_symbol = symbol_by_token_hashes.len() as i32;
2318
- let symbol = *symbol_by_token_hashes
2319
- .entry((
2320
- token.text_hash,
2321
- token.text_hash2,
2322
- token.literal_hash.unwrap_or(0),
2323
- token.literal_hash2.unwrap_or(0),
2324
- ))
2325
- .or_insert(next_symbol);
2326
- (symbol, token.is_name || token.literal_hash.is_some())
2327
- })
2328
- .unzip()
2439
+ let mut symbols = Vec::with_capacity(tokens.len());
2440
+ let mut is_content = Vec::with_capacity(tokens.len());
2441
+ let mut token_keys = Vec::with_capacity(tokens.len());
2442
+ for token in tokens {
2443
+ if token.is_id {
2444
+ let next_id = id_by_identifier.len() as i32;
2445
+ let id = *id_by_identifier
2446
+ .entry(token.text.as_ref())
2447
+ .or_insert(next_id);
2448
+ symbols.push(-(id + 1));
2449
+ is_content.push(false);
2450
+ token_keys.push(0);
2451
+ continue;
2452
+ }
2453
+ let hashes = (
2454
+ token.text_hash,
2455
+ token.text_hash2,
2456
+ token.literal_hash.unwrap_or(0),
2457
+ token.literal_hash2.unwrap_or(0),
2458
+ );
2459
+ let next_symbol = symbol_by_token_hashes.len() as i32;
2460
+ symbols.push(*symbol_by_token_hashes.entry(hashes).or_insert(next_symbol));
2461
+ is_content.push(token.is_name || token.literal_hash.is_some());
2462
+ token_keys.push(token_content_key(hashes));
2463
+ }
2464
+ (symbols, is_content, token_keys)
2465
+ }
2466
+
2467
+ /// A 53-bit key of a token's two text hashes, each mixed with the matching literal value hash; the
2468
+ /// same key as `tokenKey` in crossFileNearMiss.ts, so that both detectors order content alike.
2469
+ fn token_content_key((text, text2, literal, literal2): (i32, i32, i32, i32)) -> i64 {
2470
+ let primary = (text ^ literal.wrapping_mul(0x9E37_79B1_u32 as i32)) as u32;
2471
+ let secondary = (text2 ^ literal2.wrapping_mul(0x85EB_CA6B_u32 as i32)) as u32;
2472
+ i64::from(primary) * 0x20_0000 + i64::from(secondary >> 11)
2329
2473
  }
2330
2474
 
2331
2475
  /// Returns a lookup of the outermost container statements inside a token range, excluding a
@@ -2429,23 +2573,44 @@ fn count_redundant_fragments(group: &[CountedOccurrence]) -> usize {
2429
2573
  }
2430
2574
 
2431
2575
  /// Only CODE lines carrying matched tokens count; the unmatched gap of a merged clone stays out
2432
- /// of line coverage.
2576
+ /// of line coverage. A line of a near-miss copy counts when its partners match more than half of
2577
+ /// its tokens: a line the copy adds or rewrites is not duplicated, while a longest common
2578
+ /// subsequence still pairs a stray token of it (a bracket, a separator) with one of the partner.
2579
+ /// A line an exact fragment of the copy reaches counts in any case.
2433
2580
  fn collect_matched_lines(
2434
2581
  occurrence: &CountedOccurrence,
2435
2582
  code_line_numbers: &FxHashSet<usize>,
2436
2583
  tokens: &[Token<'_>],
2437
2584
  ) -> FxHashSet<usize> {
2438
- let mut lines: FxHashSet<usize> = FxHashSet::default();
2585
+ let mut token_counts_by_row: FxHashMap<usize, (usize, usize)> = FxHashMap::default();
2586
+ let mut runs = occurrence.matched_runs.iter().flatten().peekable();
2439
2587
  for &(segment_start, segment_end) in &occurrence.segments {
2440
- for token in &tokens[segment_start..segment_end.min(tokens.len())] {
2588
+ let segment = tokens.iter().enumerate().take(segment_end);
2589
+ for (index, token) in segment.skip(segment_start) {
2590
+ while runs.next_if(|run| run.1 <= index).is_some() {}
2591
+ let matched =
2592
+ occurrence.matched_runs.is_none() || runs.peek().is_some_and(|run| run.0 <= index);
2441
2593
  for row in token.start_row..=token.end_row {
2442
- if code_line_numbers.contains(&(row + 1)) {
2443
- lines.insert(row + 1);
2444
- }
2594
+ let (matched_count, token_count) = token_counts_by_row.entry(row + 1).or_default();
2595
+ *matched_count += usize::from(matched);
2596
+ *token_count += 1;
2445
2597
  }
2446
2598
  }
2447
2599
  }
2448
- lines
2600
+ let exact_rows: FxHashSet<usize> = occurrence
2601
+ .exact_runs
2602
+ .iter()
2603
+ .flat_map(|&(start, end)| &tokens[start..end])
2604
+ .flat_map(|token| token.start_row + 1..=token.end_row + 1)
2605
+ .collect();
2606
+ token_counts_by_row
2607
+ .into_iter()
2608
+ .filter(|(line, (matched_count, token_count))| {
2609
+ code_line_numbers.contains(line)
2610
+ && (matched_count * 2 > *token_count || exact_rows.contains(line))
2611
+ })
2612
+ .map(|(line, _)| line)
2613
+ .collect()
2449
2614
  }
2450
2615
 
2451
2616
  fn summarize_duplicates(