code-gauge 10.2.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.
@@ -5,7 +5,7 @@ use std::sync::OnceLock;
5
5
  use tree_sitter::Node;
6
6
 
7
7
  use crate::near_miss::{
8
- Alignment, Block, CorePair, Matcher, PairMatch, FILTRATION_PERCENT, MAX_LENGTH_RATIO,
8
+ Block, CorePair, Matcher, PairMatch, TokenRuns, FILTRATION_PERCENT, MAX_LENGTH_RATIO,
9
9
  };
10
10
  use crate::tree_index::NodeExt;
11
11
  use crate::types::{
@@ -1586,6 +1586,9 @@ struct CountedOccurrence {
1586
1586
  /// The token runs of a near-miss copy that its partners match, within its segments;
1587
1587
  /// `None` for an exact or gapped copy, whose segments are matched throughout.
1588
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)>,
1589
1592
  /// Sum of segment token counts (the gap tokens are not matched content).
1590
1593
  token_count: usize,
1591
1594
  start_token_index: usize,
@@ -1594,6 +1597,16 @@ struct CountedOccurrence {
1594
1597
  end_line: usize,
1595
1598
  }
1596
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
+
1597
1610
  fn to_counted_groups(
1598
1611
  counted: &IndexMap<std::rc::Rc<str>, Vec<DuplicateCandidate>>,
1599
1612
  ) -> Vec<Vec<CountedOccurrence>> {
@@ -1604,6 +1617,7 @@ fn to_counted_groups(
1604
1617
  .map(|candidate| CountedOccurrence {
1605
1618
  shared_with_merged_group: false,
1606
1619
  matched_runs: None,
1620
+ exact_runs: Vec::new(),
1607
1621
  segments: vec![(candidate.start_token_index, candidate.end_token_index)],
1608
1622
  token_count: candidate.token_count,
1609
1623
  start_token_index: candidate.start_token_index,
@@ -1793,6 +1807,7 @@ fn merge_groups(
1793
1807
  // A merged occurrence is a fresh span combination; it inherits no shared marks.
1794
1808
  shared_with_merged_group: false,
1795
1809
  matched_runs: None,
1810
+ exact_runs: Vec::new(),
1796
1811
  segments: [leading.segments.clone(), trailing.segments.clone()].concat(),
1797
1812
  token_count: leading.token_count + trailing.token_count,
1798
1813
  start_token_index: leading.start_token_index,
@@ -1815,7 +1830,8 @@ struct MatchEdge {
1815
1830
  left: usize,
1816
1831
  right: usize,
1817
1832
  cores: Option<CorePair>,
1818
- alignment: Alignment,
1833
+ /// The token runs the pair matches, on the left and on the right.
1834
+ matched_runs: (TokenRuns, TokenRuns),
1819
1835
  }
1820
1836
 
1821
1837
  impl MatchEdge {
@@ -1833,7 +1849,8 @@ impl MatchEdge {
1833
1849
  /// merged), each clustered with its own partners, so code no verified pair matched never counts
1834
1850
  /// as duplicated. A node overlapping a reported occurrence is an anchor: it joins its group at its
1835
1851
  /// own size, so the copies of a block are listed at the size they matched it, and is marked as
1836
- /// counted elsewhere unless the group takes over the reported occurrences it overlaps.
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.
1837
1854
  fn collect_near_miss_groups(
1838
1855
  source: &TokenizedSource<'_>,
1839
1856
  settings: &DuplicationSettings,
@@ -1848,16 +1865,17 @@ fn collect_near_miss_groups(
1848
1865
  return Vec::new();
1849
1866
  }
1850
1867
 
1851
- // Reported-group indices whose occurrences overlap a token range: near-miss nodes covering
1852
- // 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.
1853
1871
  let touched_groups_in = |start: usize, end: usize| -> Vec<usize> {
1854
1872
  reported_groups
1855
1873
  .iter()
1856
1874
  .enumerate()
1857
1875
  .filter(|(_, group)| {
1858
- group.iter().any(|occurrence| {
1859
- occurrence.start_token_index < end && start < occurrence.end_token_index
1860
- })
1876
+ group
1877
+ .iter()
1878
+ .any(|occurrence| occurrence.matches_within((start, end)))
1861
1879
  })
1862
1880
  .map(|(group_index, _)| group_index)
1863
1881
  .collect()
@@ -1884,41 +1902,69 @@ fn collect_near_miss_groups(
1884
1902
  settings.min_similarity_percent,
1885
1903
  );
1886
1904
 
1887
- // Two spans that both overlap reported content have nothing new to contribute to each other.
1888
- // Their pair is dropped before clustering, so that it neither collapses the blocks' core nodes
1889
- // nor widens a node beyond what the pairs clustering it matched.
1890
- let is_touched = |(start, end): (usize, usize)| !touched_groups_in(start, end).is_empty();
1891
- let block_touched: Vec<bool> = comparable
1892
- .iter()
1893
- .map(|range| is_touched((range.start_token_index, range.end_token_index)))
1894
- .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
+ };
1895
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
+ };
1896
1959
  for_each_candidate_pair(
1897
1960
  &blocks,
1898
1961
  settings.min_similarity_percent,
1899
1962
  |left_index, right_index| match matcher.verify(&blocks[left_index], &blocks[right_index]) {
1900
1963
  None => {}
1901
- Some(PairMatch::Whole(alignment))
1902
- if !(block_touched[left_index] && block_touched[right_index]) =>
1903
- {
1904
- edges.push(MatchEdge {
1905
- left: left_index,
1906
- right: right_index,
1907
- cores: None,
1908
- alignment,
1909
- })
1910
- }
1911
- Some(PairMatch::Whole(_)) => {}
1964
+ Some(PairMatch::Whole(alignment)) => add_edge(left_index, right_index, None, alignment),
1912
1965
  Some(PairMatch::Local(cores)) => {
1913
1966
  for (core_pair, alignment) in cores {
1914
- if !(is_touched(core_pair.0) && is_touched(core_pair.1)) {
1915
- edges.push(MatchEdge {
1916
- left: left_index,
1917
- right: right_index,
1918
- cores: Some(core_pair),
1919
- alignment,
1920
- });
1921
- }
1967
+ add_edge(left_index, right_index, Some(core_pair), alignment);
1922
1968
  }
1923
1969
  }
1924
1970
  },
@@ -2006,21 +2052,10 @@ fn collect_near_miss_groups(
2006
2052
  parent[left_root.max(right_root)] = left_root.min(right_root);
2007
2053
  }
2008
2054
 
2009
- // The lines of a near-miss copy that are duplicated are those a verified partner matches,
2010
- // which takes the pairing itself. Every pair is aligned: verifying it already cost a longest
2011
- // common subsequence of the same sequences, and any choice among a copy's partners would let
2012
- // their number or their order decide what the copy counts.
2013
2055
  let mut matched_runs_by_node: Vec<Vec<(usize, usize)>> = vec![Vec::new(); node_blocks.len()];
2014
2056
  for (edge, nodes) in edges.iter().zip(&edge_nodes) {
2015
- let (left_runs, right_runs) = matcher.align(
2016
- &blocks[edge.left],
2017
- &blocks[edge.right],
2018
- edge.cores,
2019
- edge.alignment,
2020
- &token_keys,
2021
- );
2022
- matched_runs_by_node[nodes[0]].extend(left_runs);
2023
- matched_runs_by_node[nodes[1]].extend(right_runs);
2057
+ matched_runs_by_node[nodes[0]].extend(&edge.matched_runs.0);
2058
+ matched_runs_by_node[nodes[1]].extend(&edge.matched_runs.1);
2024
2059
  }
2025
2060
 
2026
2061
  let mut members_by_root: IndexMap<usize, Vec<usize>> = IndexMap::new();
@@ -2058,6 +2093,7 @@ fn collect_near_miss_groups(
2058
2093
  CountedOccurrence {
2059
2094
  shared_with_merged_group: false,
2060
2095
  matched_runs: Some(merge_overlapping_cores(&matched_runs)),
2096
+ exact_runs: Vec::new(),
2061
2097
  token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
2062
2098
  segments,
2063
2099
  start_token_index: start,
@@ -2106,17 +2142,42 @@ fn collect_near_miss_groups(
2106
2142
  }
2107
2143
  continue;
2108
2144
  }
2109
- 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
+ }) {
2110
2149
  continue;
2111
2150
  }
2112
- // An anchored cluster extends a reported group only when every occurrence of that group
2113
- // overlaps one of the cluster's member nodes: an occurrence disjoint from all members
2114
- // reports content the cluster does not share.
2115
- let overlaps_member = |occurrence: &CountedOccurrence| {
2116
- members.iter().any(|&index| {
2117
- let (start, end) = node_range(index);
2118
- occurrence.start_token_index < end && start < occurrence.end_token_index
2119
- })
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
+ })
2120
2181
  };
2121
2182
  // Ascending by construction: BTreeSet iteration is sorted and filter preserves order.
2122
2183
  let fully_clustered: Vec<usize> = covered
@@ -2124,16 +2185,12 @@ fn collect_near_miss_groups(
2124
2185
  .flat_map(|&index| touched_groups_of(index).iter().copied())
2125
2186
  .collect::<std::collections::BTreeSet<usize>>()
2126
2187
  .into_iter()
2127
- .filter(|&group_index| {
2128
- let group = &reported_groups[group_index];
2129
- !group.is_empty() && group.iter().all(&overlaps_member)
2130
- })
2188
+ .filter(|&group_index| stands_for_members(&reported_groups[group_index]))
2131
2189
  .collect();
2132
2190
  if let Some((&target_index, source_indexes)) = fully_clustered.split_first() {
2133
2191
  // Rebuild the component as ONE group with one coalesced occurrence per member block:
2134
- // 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
2135
2193
  // cores are parts of one copy.
2136
- let mut consumed: FxHashSet<(usize, usize)> = FxHashSet::default();
2137
2194
  let mut merged: Vec<CountedOccurrence> = Vec::new();
2138
2195
  let mut unanchored_nodes: Vec<usize> = Vec::new();
2139
2196
  let mut anchor_nodes: Vec<usize> = Vec::new();
@@ -2145,26 +2202,17 @@ fn collect_near_miss_groups(
2145
2202
  .push(member_index);
2146
2203
  }
2147
2204
  for block_nodes in nodes_by_block.values() {
2148
- // Occurrences of ONE group are distinct copies; only fragments from DIFFERENT
2149
- // groups belong to the same copy. Consecutive position-order slices keep the
2150
- // coalesced spans disjoint.
2151
- let mut fragments: Vec<(CountedOccurrence, usize)> = Vec::new();
2152
- for &node in block_nodes {
2153
- let (range_start, range_end) = node_range(node);
2154
- for &group_index in &fully_clustered {
2155
- for (occurrence_index, occurrence) in
2156
- reported_groups[group_index].iter().enumerate()
2157
- {
2158
- if !consumed.contains(&(group_index, occurrence_index))
2159
- && occurrence.start_token_index < range_end
2160
- && range_start < occurrence.end_token_index
2161
- {
2162
- consumed.insert((group_index, occurrence_index));
2163
- fragments.push((occurrence.clone(), group_index));
2164
- }
2165
- }
2166
- }
2167
- }
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();
2168
2216
  if fragments.is_empty() {
2169
2217
  let (anchors, plain): (Vec<usize>, Vec<usize>) = block_nodes
2170
2218
  .iter()
@@ -2174,27 +2222,9 @@ fn collect_near_miss_groups(
2174
2222
  continue;
2175
2223
  }
2176
2224
  // The block's nodes are part of the same copy as its fragments: a block matched
2177
- // beyond the fragments it holds is a copy at that size. A group index no reported
2178
- // group uses keeps them in that copy.
2179
- for occurrence in to_occurrences(block_nodes) {
2180
- fragments.push((occurrence, usize::MAX));
2181
- }
2182
- fragments.sort_by_key(|(occurrence, _)| {
2183
- (occurrence.start_token_index, occurrence.end_token_index)
2184
- });
2185
- let mut copy_parts: Vec<CountedOccurrence> = Vec::new();
2186
- let mut copy_groups: FxHashSet<usize> = FxHashSet::default();
2187
- for (occurrence, group_index) in fragments {
2188
- if copy_groups.contains(&group_index) {
2189
- merged.push(coalesce_occurrences(std::mem::take(&mut copy_parts)));
2190
- copy_groups.clear();
2191
- }
2192
- copy_parts.push(occurrence);
2193
- copy_groups.insert(group_index);
2194
- }
2195
- if !copy_parts.is_empty() {
2196
- merged.push(coalesce_occurrences(copy_parts));
2197
- }
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));
2198
2228
  }
2199
2229
  merged.extend(to_occurrences(&unanchored_nodes));
2200
2230
  // The rebuild consumed every fully-clustered group, so shared-span marks from earlier
@@ -2349,8 +2379,22 @@ fn coalesce_occurrences(occurrences: Vec<CountedOccurrence>) -> CountedOccurrenc
2349
2379
  .collect();
2350
2380
  merge_overlapping_cores(&runs)
2351
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();
2352
2390
  CountedOccurrence {
2353
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
+ },
2354
2398
  matched_runs,
2355
2399
  token_count: segments.iter().map(|segment| segment.1 - segment.0).sum(),
2356
2400
  start_token_index: occurrences
@@ -2532,6 +2576,7 @@ fn count_redundant_fragments(group: &[CountedOccurrence]) -> usize {
2532
2576
  /// of line coverage. A line of a near-miss copy counts when its partners match more than half of
2533
2577
  /// its tokens: a line the copy adds or rewrites is not duplicated, while a longest common
2534
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.
2535
2580
  fn collect_matched_lines(
2536
2581
  occurrence: &CountedOccurrence,
2537
2582
  code_line_numbers: &FxHashSet<usize>,
@@ -2552,10 +2597,17 @@ fn collect_matched_lines(
2552
2597
  }
2553
2598
  }
2554
2599
  }
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();
2555
2606
  token_counts_by_row
2556
2607
  .into_iter()
2557
2608
  .filter(|(line, (matched_count, token_count))| {
2558
- code_line_numbers.contains(line) && matched_count * 2 > *token_count
2609
+ code_line_numbers.contains(line)
2610
+ && (matched_count * 2 > *token_count || exact_rows.contains(line))
2559
2611
  })
2560
2612
  .map(|(line, _)| line)
2561
2613
  .collect()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-gauge",
3
- "version": "10.2.0",
3
+ "version": "10.2.1",
4
4
  "description": "Measure code metrics with tree-sitter.",
5
5
  "keywords": [
6
6
  "cli",
@@ -93,12 +93,12 @@
93
93
  "registry": "https://registry.npmjs.org/"
94
94
  },
95
95
  "optionalDependencies": {
96
- "code-gauge-linux-x64-gnu": "10.2.0",
97
- "code-gauge-linux-arm64-gnu": "10.2.0",
98
- "code-gauge-linux-x64-musl": "10.2.0",
99
- "code-gauge-linux-arm64-musl": "10.2.0",
100
- "code-gauge-darwin-x64": "10.2.0",
101
- "code-gauge-darwin-arm64": "10.2.0",
102
- "code-gauge-win32-x64-msvc": "10.2.0"
96
+ "code-gauge-linux-x64-gnu": "10.2.1",
97
+ "code-gauge-linux-arm64-gnu": "10.2.1",
98
+ "code-gauge-linux-x64-musl": "10.2.1",
99
+ "code-gauge-linux-arm64-musl": "10.2.1",
100
+ "code-gauge-darwin-x64": "10.2.1",
101
+ "code-gauge-darwin-arm64": "10.2.1",
102
+ "code-gauge-win32-x64-msvc": "10.2.1"
103
103
  }
104
104
  }