Skip to main content

fallow_engine/duplication_detector/
deepdive.rs

1//! Deep-dive helpers for the `fallow dupes --trace` inspector: a stable
2//! content fingerprint that addresses a clone group across runs, a group-level
3//! refactoring suggestion, and a best-effort "dominant identifier" name for the
4//! extracted function.
5//!
6//! These are pure functions over [`CloneInstance`] / [`CloneGroup`] so every
7//! surface (human listing, `--trace dup:<fp>` lookup, the typed JSON wrappers,
8//! and `trace_clone`) computes the same values without storing a field on the
9//! core [`CloneGroup`] struct.
10
11use std::path::Path;
12
13use fallow_config::DetectionMode;
14use rustc_hash::{FxHashMap, FxHashSet};
15use xxhash_rust::xxh3::{Xxh3, xxh3_64};
16
17use super::tokenize::{
18    FragmentTokenizationKind, FragmentTokenizationStrategy, fragment_tokenization_kind,
19};
20use super::types::{CloneGroup, CloneInstance, RefactoringKind, RefactoringSuggestion};
21
22/// Prefix marking a clone-group fingerprint addressable via `--trace`.
23pub const FINGERPRINT_PREFIX: &str = "dup:";
24
25/// Canonical identity for a clone group when assigning report-scoped handles.
26///
27/// Compact digests of canonically sorted fragments and locations make report
28/// entries addressable without retaining a second copy of every source fragment
29/// and path. Collision suffixes use lexical locations instead of these digests
30/// so a different checkout prefix cannot change their order.
31/// The separately hashed, sorted, deduplicated normalized instance sequences
32/// provide the public content identity.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct CloneFingerprintKey {
35    fragments_digest: u128,
36    locations_digest: u128,
37    token_count: usize,
38    line_count: usize,
39    instance_count: usize,
40}
41
42const _: () = assert!(std::mem::size_of::<CloneFingerprintKey>() <= 64);
43
44impl CloneFingerprintKey {
45    /// Build a fingerprint key from clone-group parts.
46    #[must_use]
47    fn from_parts(instances: &[CloneInstance], token_count: usize, line_count: usize) -> Self {
48        Self {
49            fragments_digest: hash_distinct_fragments(instances),
50            locations_digest: hash_sorted_locations(instances),
51            token_count,
52            line_count,
53            instance_count: instances.len(),
54        }
55    }
56
57    fn from_group(group: &CloneGroup) -> Self {
58        Self::from_parts(&group.instances, group.token_count, group.line_count)
59    }
60}
61
62/// Report-scoped clone fingerprint assignment.
63///
64/// Most reports retain the short `dup:<8hex>` handle. If two report entries
65/// collide on those low 32 bits, only the colliding entries widen to
66/// `dup:<16hex>`. If a full 64-bit collision ever occurs inside one report,
67/// every entry in that collision bucket receives a deterministic `-rN` suffix.
68/// Legacy `-N` suffixes deliberately do not resolve, since their assignment
69/// depended on the absolute checkout path and could address a different group.
70#[derive(Debug, Clone)]
71pub struct CloneFingerprintSet {
72    by_key: FxHashMap<CloneFingerprintKey, String>,
73    key_by_fingerprint: FxHashMap<String, CloneFingerprintKey>,
74}
75
76impl CloneFingerprintSet {
77    /// Assign collision-free fingerprints for the report's clone groups.
78    #[must_use]
79    pub fn from_groups(groups: &[CloneGroup]) -> Self {
80        let entries: Vec<_> = groups
81            .iter()
82            .map(|group| (group, hash_instances(&group.instances)))
83            .collect();
84        Self::from_hashed_entries(&entries)
85    }
86
87    /// Return the assigned fingerprint for a clone group.
88    #[must_use]
89    pub fn fingerprint_for_group(&self, group: &CloneGroup) -> String {
90        self.by_key
91            .get(&CloneFingerprintKey::from_group(group))
92            .cloned()
93            .unwrap_or_else(|| clone_fingerprint(&group.instances))
94    }
95
96    /// Return the config key used by `duplicates.ignoredClones` for a group.
97    #[must_use]
98    pub fn ignored_clone_key_for_group(&self, group: &CloneGroup) -> String {
99        format!(
100            "{}:{}",
101            self.fingerprint_for_group(group),
102            group.instances.len()
103        )
104    }
105
106    /// Return the assigned fingerprint for clone-group parts.
107    #[must_use]
108    pub fn fingerprint_for_parts(
109        &self,
110        instances: &[CloneInstance],
111        token_count: usize,
112        line_count: usize,
113    ) -> String {
114        let key = CloneFingerprintKey::from_parts(instances, token_count, line_count);
115        self.by_key
116            .get(&key)
117            .cloned()
118            .unwrap_or_else(|| clone_fingerprint(instances))
119    }
120
121    /// Find the group addressed by an assigned fingerprint.
122    ///
123    /// Ambiguous short handles created by low-32 collisions are intentionally
124    /// absent from the lookup table, so callers get `None` instead of the first
125    /// matching group.
126    #[must_use]
127    pub fn find_group<'a>(
128        &self,
129        groups: &'a [CloneGroup],
130        fingerprint: &str,
131    ) -> Option<&'a CloneGroup> {
132        let key = self.key_by_fingerprint.get(fingerprint)?;
133        groups
134            .iter()
135            .find(|group| CloneFingerprintKey::from_group(group) == *key)
136    }
137
138    fn from_hashed_entries(entries: &[(&CloneGroup, u64)]) -> Self {
139        let mut short_counts: FxHashMap<u32, usize> = FxHashMap::default();
140        let mut full_counts: FxHashMap<u64, usize> = FxHashMap::default();
141        for (_, hash) in entries {
142            *short_counts.entry(*hash as u32).or_insert(0) += 1;
143            *full_counts.entry(*hash).or_insert(0) += 1;
144        }
145
146        // Only full collisions need location ordering. Within one report, a
147        // shared checkout prefix cancels in lexical comparisons. Hashing that
148        // prefix instead would arbitrarily reorder groups after relocation.
149        let mut sorted_entries: Vec<_> = entries
150            .iter()
151            .map(|(group, hash)| {
152                let locations = if full_counts.get(hash).copied().unwrap_or(0) > 1 {
153                    sorted_locations(&group.instances)
154                } else {
155                    Vec::new()
156                };
157                (CloneFingerprintKey::from_group(group), *hash, locations)
158            })
159            .collect();
160        sorted_entries.sort_unstable_by(
161            |(left, left_hash, left_locations), (right, right_hash, right_locations)| {
162                left.fragments_digest
163                    .cmp(&right.fragments_digest)
164                    .then_with(|| left_locations.cmp(right_locations))
165                    .then_with(|| left.token_count.cmp(&right.token_count))
166                    .then_with(|| left.line_count.cmp(&right.line_count))
167                    .then_with(|| left.instance_count.cmp(&right.instance_count))
168                    .then_with(|| left_hash.cmp(right_hash))
169            },
170        );
171
172        let mut full_ordinals: FxHashMap<u64, usize> = FxHashMap::default();
173        let mut ambiguous_short_handles: FxHashSet<String> = FxHashSet::default();
174        let mut by_key = FxHashMap::default();
175        let mut key_by_fingerprint = FxHashMap::default();
176
177        for (key, hash, _) in &sorted_entries {
178            let short = *hash as u32;
179            let short_handle = format!("{FINGERPRINT_PREFIX}{short:08x}");
180            let fingerprint = if short_counts.get(&short).copied().unwrap_or(0) == 1 {
181                short_handle
182            } else {
183                ambiguous_short_handles.insert(short_handle);
184                let full_handle = format!("{FINGERPRINT_PREFIX}{hash:016x}");
185                if full_counts.get(hash).copied().unwrap_or(0) == 1 {
186                    full_handle
187                } else {
188                    let ordinal = full_ordinals.entry(*hash).or_insert(0);
189                    *ordinal += 1;
190                    format!("{full_handle}-r{ordinal}")
191                }
192            };
193
194            key_by_fingerprint.insert(fingerprint.clone(), key.clone());
195            by_key.insert(key.clone(), fingerprint);
196        }
197
198        for handle in ambiguous_short_handles {
199            key_by_fingerprint.remove(&handle);
200        }
201
202        Self {
203            by_key,
204            key_by_fingerprint,
205        }
206    }
207}
208
209/// Compute a mode-independent normalized short content fingerprint for a clone
210/// group from all distinct instance token sequences.
211///
212/// Whitespace, comments, and line endings are absent from the normalized token
213/// hashes. Sorting and deduplicating sequences makes the fingerprint independent
214/// of instance order while ensuring a token edit in any distinct instance
215/// changes the group identity. Instance count is deliberately excluded and is
216/// appended separately by [`CloneFingerprintSet::ignored_clone_key_for_group`].
217///
218/// Use [`CloneFingerprintSet`] for user-facing report output, since it widens
219/// only the rare colliding handles while preserving this short form for the
220/// common case.
221///
222/// Hashes the empty string for an empty group (never produced by the detector,
223/// which guarantees `>= 2` instances), so the result is still a well-formed
224/// `dup:<8hex>` handle.
225#[must_use]
226pub fn clone_fingerprint(instances: &[CloneInstance]) -> String {
227    fingerprint_for_hash(hash_instances(instances))
228}
229
230fn hash_distinct_fragments(instances: &[CloneInstance]) -> u128 {
231    let mut fragments = instances
232        .iter()
233        .map(|instance| instance.fragment.as_str())
234        .collect::<Vec<_>>();
235    fragments.sort_unstable();
236    fragments.dedup();
237
238    let mut hasher = Xxh3::new();
239    for fragment in fragments {
240        update_hash_bytes(&mut hasher, fragment.as_bytes());
241    }
242    hasher.digest128()
243}
244
245fn sorted_locations(instances: &[CloneInstance]) -> Vec<(&Path, usize, usize)> {
246    let mut locations = instances
247        .iter()
248        .map(|instance| {
249            (
250                instance.file.as_path(),
251                instance.start_line,
252                instance.end_line,
253            )
254        })
255        .collect::<Vec<_>>();
256    locations.sort_unstable();
257    locations
258}
259
260fn hash_sorted_locations(instances: &[CloneInstance]) -> u128 {
261    let mut hasher = Xxh3::new();
262    for (path, start_line, end_line) in sorted_locations(instances) {
263        update_hash_bytes(&mut hasher, path.as_os_str().as_encoded_bytes());
264        hasher.update(&start_line.to_le_bytes());
265        hasher.update(&end_line.to_le_bytes());
266    }
267    hasher.digest128()
268}
269
270fn update_hash_bytes(hasher: &mut Xxh3, bytes: &[u8]) {
271    hasher.update(&bytes.len().to_le_bytes());
272    hasher.update(bytes);
273}
274
275fn hash_instances(instances: &[CloneInstance]) -> u64 {
276    let mut sequences = distinct_fragment_inputs(instances)
277        .into_iter()
278        .map(|(kind, fragment)| normalized_fragment_sequence(kind.path(), fragment))
279        .collect::<Vec<_>>();
280    sequences.sort_unstable();
281    sequences.dedup();
282    hash_normalized_sequences(&sequences)
283}
284
285fn distinct_fragment_inputs(instances: &[CloneInstance]) -> Vec<(FragmentTokenizationKind, &str)> {
286    let mut fragments = instances
287        .iter()
288        .map(|instance| {
289            (
290                fragment_tokenization_kind(
291                    &instance.file,
292                    FragmentTokenizationStrategy::Fingerprint,
293                ),
294                instance.fragment.as_str(),
295            )
296        })
297        .collect::<Vec<_>>();
298    fragments.sort_unstable();
299    fragments.dedup();
300    fragments
301}
302
303fn normalized_fragment_sequence(path: &Path, fragment: &str) -> Vec<u64> {
304    let mut tokens = super::tokenize::tokenize_file(path, fragment, false).tokens;
305    if tokens.is_empty() {
306        // A clone can stop inside a block, so its fragment may not parse on its
307        // own. Without this fallback, every such group hashes to the same empty
308        // sequence and the baseline matches groups by report order.
309        tokens = super::tokenize::tokenize_unparsed_fragment(fragment);
310    }
311    super::normalize::normalize_and_hash(&tokens, DetectionMode::Strict)
312        .into_iter()
313        .map(|token| token.hash)
314        .collect()
315}
316
317fn hash_normalized_sequences(sequences: &[Vec<u64>]) -> u64 {
318    let byte_len = sequences
319        .iter()
320        .map(|sequence| sequence.len().saturating_add(1))
321        .sum::<usize>()
322        .saturating_mul(std::mem::size_of::<u64>());
323    let mut bytes = Vec::with_capacity(byte_len);
324    for sequence in sequences {
325        bytes.extend_from_slice(&(sequence.len() as u64).to_le_bytes());
326        for hash in sequence {
327            bytes.extend_from_slice(&hash.to_le_bytes());
328        }
329    }
330    xxh3_64(&bytes)
331}
332
333/// Report whether a baseline key comes from the shared empty-sequence bucket.
334///
335/// Versions before the fix for issue #3290 gave every clone group whose code
336/// did not parse on its own the hash of an empty token sequence, with a `-rN`
337/// suffix in report order. Such a key cannot address one group, so a baseline
338/// that contains one must be saved again. A report with only one such group
339/// had no suffix, so its key is not detected here. That group shows as new
340/// once, until the next save.
341#[must_use]
342pub fn is_unparsed_collision_key(key: &str) -> bool {
343    let bucket = format!(
344        "{FINGERPRINT_PREFIX}{:016x}-r",
345        hash_normalized_sequences(&[Vec::new()])
346    );
347    key.starts_with(&bucket)
348}
349
350fn fingerprint_for_hash(hash: u64) -> String {
351    format!("{FINGERPRINT_PREFIX}{:08x}", hash as u32)
352}
353
354/// Build a per-group `ExtractFunction` refactoring suggestion.
355///
356/// Mirrors the per-group branch of the families suggestion generator:
357/// the savings is `(instances - 1)` copies of the group's line count, since one
358/// copy survives as the extracted function and the rest collapse to call sites.
359#[must_use]
360pub fn group_refactoring_suggestion(group: &CloneGroup) -> RefactoringSuggestion {
361    let estimated_savings = group.line_count * group.instances.len().saturating_sub(1);
362    RefactoringSuggestion {
363        kind: RefactoringKind::ExtractFunction,
364        description: format!(
365            "Extract the shared {}-line block into one function and call it from {} sites",
366            group.line_count,
367            group.instances.len(),
368        ),
369        estimated_savings,
370    }
371}
372
373/// Best-effort name for the extracted function, derived from the most frequent
374/// non-generic identifier in the representative fragment.
375///
376/// Returns `None` when the dominant identifier is generic (`data`, `result`,
377/// loop counters), appears only once, or ties with another, so absence is the
378/// low-confidence signal for both human and agent consumers. This is a
379/// lexical heuristic over the raw fragment, not an AST analysis; it is advisory
380/// and consumers should verify before applying.
381#[must_use]
382pub fn dominant_identifier(group: &CloneGroup) -> Option<String> {
383    let fragment = group.instances.first().map(|inst| inst.fragment.as_str())?;
384    let mut counts: FxHashMap<&str, usize> = FxHashMap::default();
385    for word in identifier_words(fragment) {
386        if is_generic_identifier(word) {
387            continue;
388        }
389        *counts.entry(word).or_insert(0) += 1;
390    }
391
392    let mut candidates: Vec<_> = counts
393        .into_iter()
394        .map(|(word, count)| IdentifierCandidate {
395            word,
396            count,
397            score: identifier_score(word, count),
398        })
399        .collect();
400    candidates.sort_by(|a, b| {
401        b.score
402            .cmp(&a.score)
403            .then_with(|| b.count.cmp(&a.count))
404            .then_with(|| a.word.cmp(b.word))
405    });
406
407    let best = candidates.first()?;
408    if best.count < 2 {
409        return None;
410    }
411
412    let runner_up = candidates.get(1);
413    if runner_up.is_some_and(|next| best.score.saturating_sub(next.score) < 2) {
414        return None;
415    }
416
417    if is_plain_single_token(best.word) {
418        let next_count = runner_up.map_or(0, |candidate| candidate.count);
419        if best.count < 3 || best.count < next_count + 2 {
420            return None;
421        }
422    }
423
424    Some(best.word.to_string())
425}
426
427#[derive(Debug)]
428struct IdentifierCandidate<'a> {
429    word: &'a str,
430    count: usize,
431    score: usize,
432}
433
434fn identifier_score(word: &str, count: usize) -> usize {
435    let quality_bonus = if has_identifier_separator_or_case_transition(word) {
436        5
437    } else if word.chars().count() >= 8 {
438        2
439    } else {
440        0
441    };
442    count * 5 + quality_bonus
443}
444
445fn is_plain_single_token(word: &str) -> bool {
446    !has_identifier_separator_or_case_transition(word) && word.chars().count() < 8
447}
448
449fn has_identifier_separator_or_case_transition(word: &str) -> bool {
450    if word.contains('_') || word.contains('$') {
451        return true;
452    }
453
454    let mut previous = None;
455    for ch in word.chars() {
456        if previous.is_some_and(|prev: char| prev.is_ascii_lowercase() && ch.is_ascii_uppercase()) {
457            return true;
458        }
459        previous = Some(ch);
460    }
461    false
462}
463
464/// Yield identifier-like words (`[A-Za-z_$][A-Za-z0-9_$]*`) from raw source.
465fn identifier_words(source: &str) -> impl Iterator<Item = &str> {
466    source
467        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
468        .filter(|word| {
469            !word.is_empty()
470                && word
471                    .chars()
472                    .next()
473                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
474        })
475}
476
477/// Identifiers too generic to make a useful extracted-function name, plus the
478/// reserved words that show up as bare tokens in a fragment.
479const GENERIC_IDENTIFIERS: &[&str] = &[
480    "data",
481    "result",
482    "results",
483    "item",
484    "items",
485    "value",
486    "values",
487    "val",
488    "obj",
489    "object",
490    "arr",
491    "array",
492    "list",
493    "map",
494    "set",
495    "key",
496    "keys",
497    "tmp",
498    "temp",
499    "acc",
500    "cur",
501    "curr",
502    "prev",
503    "next",
504    "node",
505    "el",
506    "elem",
507    "element",
508    "args",
509    "arg",
510    "opts",
511    "options",
512    "params",
513    "param",
514    "props",
515    "ctx",
516    "context",
517    "res",
518    "req",
519    "err",
520    "error",
521    "fn",
522    "cb",
523    "callback",
524    "out",
525    "input",
526    "output",
527    "name",
528    "id",
529    "index",
530    "idx",
531    "x",
532    "y",
533    "z",
534    "i",
535    "j",
536    "k",
537    "n",
538    "m",
539    "a",
540    "b",
541    "c",
542    "e",
543    "_",
544    "const",
545    "let",
546    "var",
547    "function",
548    "return",
549    "if",
550    "else",
551    "for",
552    "while",
553    "do",
554    "switch",
555    "case",
556    "break",
557    "continue",
558    "new",
559    "this",
560    "true",
561    "false",
562    "null",
563    "undefined",
564    "void",
565    "typeof",
566    "instanceof",
567    "in",
568    "of",
569    "class",
570    "extends",
571    "super",
572    "import",
573    "export",
574    "from",
575    "default",
576    "async",
577    "await",
578    "yield",
579    "type",
580    "interface",
581    "enum",
582    "as",
583    "is",
584    "keyof",
585    "readonly",
586    "public",
587    "private",
588    "protected",
589    "static",
590    "get",
591    "delete",
592    "throw",
593    "try",
594    "catch",
595    "finally",
596    "string",
597    "number",
598    "boolean",
599    "any",
600    "unknown",
601    "never",
602    "bigint",
603    "symbol",
604    "Math",
605    "JSON",
606    "Object",
607    "Array",
608    "Promise",
609    "BigInt",
610    "Number",
611    "String",
612    "Boolean",
613    "Symbol",
614    "RegExp",
615    "Date",
616];
617
618fn is_generic_identifier(word: &str) -> bool {
619    word.chars().count() == 1 || GENERIC_IDENTIFIERS.contains(&word)
620}
621
622#[cfg(test)]
623mod tests {
624    use std::path::PathBuf;
625
626    use super::*;
627
628    fn instance(fragment: &str) -> CloneInstance {
629        CloneInstance {
630            is_symlink: false,
631            file: PathBuf::from("a.ts"),
632            start_line: 1,
633            end_line: 5,
634            start_col: 0,
635            end_col: 0,
636            fragment: fragment.to_string(),
637        }
638    }
639
640    fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
641        CloneGroup {
642            instances: fragments.iter().map(|f| instance(f)).collect(),
643            token_count: 40,
644            line_count,
645            similarity: None,
646        }
647    }
648
649    fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
650        ["src", "other"]
651            .into_iter()
652            .map(|directory| {
653                let mut clone = group(&["alpha()", "alpha()"], 2);
654                for (index, instance) in clone.instances.iter_mut().enumerate() {
655                    instance.file = root.join(directory).join(format!("file-{index}.ts"));
656                }
657                clone
658            })
659            .collect()
660    }
661
662    /// A clone that stops before the closing brace of its function does not
663    /// parse on its own.
664    fn unclosed_function_fragment(constant: u32) -> String {
665        format!(
666            "export function scale(items: number[]): number[] {{\n  const out = items.map((item) => item * {constant});\n  return out;\n"
667        )
668    }
669
670    #[test]
671    fn unparseable_fragments_keep_distinct_fingerprints() {
672        let first = unclosed_function_fragment(7);
673        let second = unclosed_function_fragment(13);
674        assert_ne!(
675            hash_instances(&group(&[&first, &first], 3).instances),
676            hash_instances(&group(&[&second, &second], 3).instances),
677            "different code must not share the empty-sequence hash"
678        );
679    }
680
681    #[test]
682    fn detects_keys_from_the_old_empty_sequence_bucket() {
683        assert!(is_unparsed_collision_key("dup:c77b3abb6f87acd9-r3:2"));
684        assert!(!is_unparsed_collision_key("dup:c77b3abb6f87acd9:2"));
685        assert!(!is_unparsed_collision_key("dup:6f87acd9:2"));
686        let fragment = unclosed_function_fragment(7);
687        let fingerprints = CloneFingerprintSet::from_groups(&[group(&[&fragment, &fragment], 3)]);
688        let key = fingerprints.ignored_clone_key_for_group(&group(&[&fragment, &fragment], 3));
689        assert!(!is_unparsed_collision_key(&key));
690    }
691
692    #[test]
693    fn unparseable_fragments_ignore_whitespace_and_comments() {
694        let compact = unclosed_function_fragment(7);
695        let spaced = compact.replace("  return out;", "  // done\n    return   out;");
696        assert_eq!(
697            hash_instances(&group(&[&compact], 3).instances),
698            hash_instances(&group(&[&spaced], 3).instances),
699        );
700    }
701
702    #[test]
703    fn baseline_reports_only_the_new_unparseable_clone() {
704        use super::super::types::DuplicationReport;
705        use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
706
707        let root = Path::new("/project");
708        let pair = |constant: u32, name: &str| {
709            let fragment = unclosed_function_fragment(constant);
710            let mut clone = group(&[&fragment, &fragment], 3);
711            for (index, instance) in clone.instances.iter_mut().enumerate() {
712                instance.file = root.join(format!("src/{name}-{index}.ts"));
713            }
714            clone
715        };
716        let baseline_report = DuplicationReport {
717            clone_groups: vec![pair(7, "a"), pair(13, "c"), pair(29, "e")],
718            ..Default::default()
719        };
720        let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
721
722        let mut current = baseline_report.clone_groups;
723        current.push(pair(3, "g"));
724        let filtered = filter_new_clone_groups(
725            DuplicationReport {
726                clone_groups: current,
727                ..Default::default()
728            },
729            &baseline,
730            root,
731        );
732        let files: Vec<_> = filtered
733            .clone_groups
734            .iter()
735            .flat_map(|clone| clone.instances.iter().map(|i| i.file.clone()))
736            .collect();
737        assert_eq!(
738            files,
739            vec![root.join("src/g-0.ts"), root.join("src/g-1.ts")]
740        );
741    }
742
743    #[test]
744    fn fingerprint_set_relocation_preserves_collision_identity() {
745        const RELOCATIONS: usize = 32;
746        let groups = relocated_collision_groups(Path::new("/original/checkout"));
747        let fingerprints = CloneFingerprintSet::from_groups(&groups);
748        let expected = fingerprints.fingerprint_for_group(&groups[0]);
749        for index in 0..RELOCATIONS {
750            let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
751            let mut relocated = relocated_collision_groups(&root);
752            relocated.reverse();
753            for group in &mut relocated {
754                group.instances.reverse();
755            }
756            let actual = CloneFingerprintSet::from_groups(&relocated);
757            assert_eq!(
758                actual.fingerprint_for_group(&relocated[1]),
759                expected,
760                "{root:?}"
761            );
762            assert!(std::ptr::eq(
763                actual
764                    .find_group(&relocated, &expected)
765                    .expect("relocated trace handle resolves"),
766                &raw const relocated[1],
767            ));
768            assert_eq!(
769                actual.fingerprint_for_parts(
770                    &relocated[1].instances,
771                    relocated[1].token_count,
772                    relocated[1].line_count
773                ),
774                expected,
775            );
776        }
777    }
778
779    #[test]
780    fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
781        let groups = relocated_collision_groups(Path::new("/project"));
782        let fingerprints = CloneFingerprintSet::from_groups(&groups);
783        for group in &groups {
784            let corrected = fingerprints.fingerprint_for_group(group);
785            assert!(corrected.contains("-r"));
786            let legacy = corrected.replacen("-r", "-", 1);
787            assert!(fingerprints.find_group(&groups, &legacy).is_none());
788            assert!(fingerprints.find_group(&groups, &corrected).is_some());
789        }
790    }
791
792    #[test]
793    fn relocated_collision_suppression_matches_only_corrected_handles() {
794        use super::super::types::DuplicationReport;
795        use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
796
797        let original = DuplicationReport {
798            clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
799            ..Default::default()
800        };
801        let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
802        let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
803        let baseline =
804            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
805        let root = Path::new("/relocated/with spaces/café");
806        let mut relocated = DuplicationReport {
807            clone_groups: relocated_collision_groups(root),
808            ..Default::default()
809        };
810        relocated.clone_groups.reverse();
811        for group in &mut relocated.clone_groups {
812            group.instances.reverse();
813        }
814        let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
815
816        let mut ignored = relocated.clone();
817        super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
818        assert_eq!(ignored.clone_groups.len(), 1);
819        assert_eq!(
820            ignored.clone_groups[0].instances[0].file,
821            remaining_location
822        );
823        assert_eq!(ignored.stats.clone_groups_ignored, 1);
824
825        let mut partial_baseline =
826            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
827        partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
828        // The baseline filter returns the report order, so the instance order
829        // of the reversed input does not survive. The location still names
830        // the group that stays.
831        let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
832        assert_eq!(filtered.clone_groups.len(), 1);
833        assert!(
834            filtered.clone_groups[0]
835                .instances
836                .iter()
837                .any(|instance| instance.file == remaining_location)
838        );
839        assert!(
840            filter_new_clone_groups(relocated.clone(), &baseline, root)
841                .clone_groups
842                .is_empty()
843        );
844
845        let legacy = reviewed.replacen("-r", "-", 1);
846        super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
847        assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
848        partial_baseline.normalized_clone_fingerprints = vec![legacy];
849        // Populated old raw/location fields must not turn a normalized-key miss
850        // into an ambiguous fallback suppression.
851        let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
852        assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
853    }
854
855    #[test]
856    fn fingerprint_is_stable_and_prefixed() {
857        let g = group(&["foo(bar)", "foo(baz)"], 3);
858        let fp1 = clone_fingerprint(&g.instances);
859        let fp2 = clone_fingerprint(&g.instances);
860        assert_eq!(fp1, fp2);
861        assert!(fp1.starts_with("dup:"));
862        assert_eq!(fp1.len(), "dup:".len() + 8);
863    }
864
865    #[test]
866    fn compact_fingerprint_key_is_independent_of_instance_order() {
867        let mut original = group(&["alpha()", "beta()"], 3);
868        original.instances[0].file = PathBuf::from("src/a.ts");
869        original.instances[0].start_line = 2;
870        original.instances[1].file = PathBuf::from("src/b.ts");
871        original.instances[1].start_line = 8;
872        let mut reordered = original.clone();
873        reordered.instances.reverse();
874
875        assert_eq!(
876            CloneFingerprintKey::from_group(&original),
877            CloneFingerprintKey::from_group(&reordered)
878        );
879    }
880
881    #[test]
882    fn duplicate_fragments_are_deduplicated_before_tokenization() {
883        let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
884        clones.instances[0].file = PathBuf::from("src/a.ts");
885        clones.instances[1].file = PathBuf::from("src/b.ts");
886        clones.instances[2].file = PathBuf::from("src/c.ts");
887        assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
888
889        clones.instances[2].file = PathBuf::from("src/c.css");
890        assert_eq!(
891            distinct_fragment_inputs(&clones.instances).len(),
892            2,
893            "equal source still needs separate tokenization when syntax differs"
894        );
895    }
896
897    #[test]
898    fn fingerprint_is_sibling_stable() {
899        let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
900        let before = clone_fingerprint(&group_a.instances);
901        let _group_b_edited = group(&["totallyDifferentBody()"], 2);
902        let after = clone_fingerprint(&group_a.instances);
903        assert_eq!(before, after);
904    }
905
906    #[test]
907    fn fingerprint_differs_for_different_content() {
908        let a = group(&["alpha()"], 2);
909        let b = group(&["beta()"], 2);
910        assert_ne!(
911            clone_fingerprint(&a.instances),
912            clone_fingerprint(&b.instances)
913        );
914    }
915
916    #[test]
917    fn fingerprint_ignores_formatting_comments_and_line_endings() {
918        let compact = group(
919            &["const total = left + right;", "const total = left + right;"],
920            2,
921        );
922        let formatted = group(
923            &[
924                "const  total=left + right; // reviewed\r\n",
925                "/* reviewed */\nconst total = left + right;",
926            ],
927            3,
928        );
929
930        assert_eq!(
931            clone_fingerprint(&compact.instances),
932            clone_fingerprint(&formatted.instances)
933        );
934    }
935
936    #[test]
937    fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
938        let reviewed = group(&["alpha()", "alpha()"], 2);
939        let edited = group(&["alpha()", "beta()"], 2);
940
941        assert_ne!(
942            clone_fingerprint(&reviewed.instances),
943            clone_fingerprint(&edited.instances)
944        );
945    }
946
947    #[test]
948    fn fingerprint_is_independent_of_instance_order_and_count() {
949        let two = group(&["alpha()", "beta()"], 2);
950        let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
951
952        assert_eq!(
953            clone_fingerprint(&two.instances),
954            clone_fingerprint(&three_reordered.instances)
955        );
956
957        let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
958        let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
959        assert_ne!(
960            two_set.ignored_clone_key_for_group(&two),
961            three_set.ignored_clone_key_for_group(&three_reordered)
962        );
963    }
964
965    #[test]
966    fn fingerprint_set_widens_only_colliding_short_handles() {
967        let a = group(&["alpha()"], 2);
968        let b = group(&["beta()"], 2);
969        let c = group(&["gamma()"], 2);
970        let entries = vec![
971            (&a, 0x0000_0001_1234_5678_u64),
972            (&b, 0x0000_0002_1234_5678_u64),
973            (&c, 0x0000_0003_8765_4321_u64),
974        ];
975
976        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
977
978        assert_eq!(
979            fingerprints.fingerprint_for_group(&a),
980            "dup:0000000112345678"
981        );
982        assert_eq!(
983            fingerprints.fingerprint_for_group(&b),
984            "dup:0000000212345678"
985        );
986        assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
987        assert!(
988            fingerprints
989                .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
990                .is_none()
991        );
992        assert_eq!(
993            fingerprints
994                .find_group(&[a, b, c], "dup:0000000212345678")
995                .and_then(|group| group.instances.first())
996                .map(|inst| inst.fragment.as_str()),
997            Some("beta()")
998        );
999    }
1000
1001    #[test]
1002    fn fingerprint_set_suffixes_full_hash_collisions() {
1003        let a = group(&["alpha()"], 2);
1004        let b = group(&["beta()"], 2);
1005        let mut entries = vec![
1006            (&a, 0x0000_0001_1234_5678_u64),
1007            (&b, 0x0000_0001_1234_5678_u64),
1008        ];
1009
1010        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1011        entries.reverse();
1012        let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1013
1014        let a_fingerprint = fingerprints.fingerprint_for_group(&a);
1015        let b_fingerprint = fingerprints.fingerprint_for_group(&b);
1016        assert_eq!(
1017            a_fingerprint,
1018            reversed_fingerprints.fingerprint_for_group(&a)
1019        );
1020        assert_eq!(
1021            b_fingerprint,
1022            reversed_fingerprints.fingerprint_for_group(&b)
1023        );
1024        let mut assigned = vec![a_fingerprint, b_fingerprint];
1025        assigned.sort_unstable();
1026        assert_eq!(
1027            assigned,
1028            ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
1029        );
1030        assert!(
1031            fingerprints
1032                .find_group(&[a.clone(), b.clone()], "dup:12345678")
1033                .is_none()
1034        );
1035        assert!(
1036            fingerprints
1037                .find_group(&[a, b], "dup:0000000112345678")
1038                .is_none()
1039        );
1040    }
1041
1042    #[test]
1043    fn group_suggestion_savings_is_lines_times_extra_copies() {
1044        let g = group(&["x", "x", "x"], 10); // 3 instances, 10 lines
1045        let suggestion = group_refactoring_suggestion(&g);
1046        assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
1047        assert_eq!(suggestion.estimated_savings, 20); // 10 * (3 - 1)
1048    }
1049
1050    #[test]
1051    fn dominant_identifier_picks_repeated_domain_name() {
1052        let g = group(
1053            &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
1054            3,
1055        );
1056        assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
1057    }
1058
1059    #[test]
1060    fn dominant_identifier_none_on_generic() {
1061        let g = group(&["const data = result.map((item) => item.value);"], 3);
1062        assert_eq!(dominant_identifier(&g), None);
1063    }
1064
1065    #[test]
1066    fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
1067        let g = group(
1068            &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
1069            4,
1070        );
1071        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
1072        let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
1073        assert_eq!(dominant_identifier(&only_keywords), None);
1074        let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
1075        assert_eq!(dominant_identifier(&g_global), None);
1076    }
1077
1078    #[test]
1079    fn dominant_identifier_none_on_single_letter_type_param() {
1080        let g = group(
1081            &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
1082            3,
1083        );
1084        assert_eq!(dominant_identifier(&g), None);
1085    }
1086
1087    #[test]
1088    fn dominant_identifier_none_on_tie() {
1089        let g = group(&["alpha(); beta();"], 2); // each appears once, no count >= 2
1090        assert_eq!(dominant_identifier(&g), None);
1091    }
1092
1093    #[test]
1094    fn dominant_identifier_prefers_structured_names() {
1095        let g = group(
1096            &["parseSchema(input); parseSchema(cache); helper(); helper();"],
1097            3,
1098        );
1099        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
1100    }
1101
1102    #[test]
1103    fn dominant_identifier_requires_plain_token_margin() {
1104        let low_signal = group(&["schema(); schema(); parseUser();"], 3);
1105        assert_eq!(dominant_identifier(&low_signal), None);
1106
1107        let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
1108        assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1109    }
1110
1111    #[test]
1112    fn dominant_identifier_is_stable_across_word_order() {
1113        let first = group(
1114            &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1115            3,
1116        );
1117        let second = group(
1118            &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1119            3,
1120        );
1121
1122        assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1123        assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1124    }
1125}