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        let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
829        assert_eq!(filtered.clone_groups.len(), 1);
830        assert_eq!(
831            filtered.clone_groups[0].instances[0].file,
832            remaining_location
833        );
834        assert!(
835            filter_new_clone_groups(relocated.clone(), &baseline, root)
836                .clone_groups
837                .is_empty()
838        );
839
840        let legacy = reviewed.replacen("-r", "-", 1);
841        super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
842        assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
843        partial_baseline.normalized_clone_fingerprints = vec![legacy];
844        // Populated old raw/location fields must not turn a normalized-key miss
845        // into an ambiguous fallback suppression.
846        let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
847        assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
848    }
849
850    #[test]
851    fn fingerprint_is_stable_and_prefixed() {
852        let g = group(&["foo(bar)", "foo(baz)"], 3);
853        let fp1 = clone_fingerprint(&g.instances);
854        let fp2 = clone_fingerprint(&g.instances);
855        assert_eq!(fp1, fp2);
856        assert!(fp1.starts_with("dup:"));
857        assert_eq!(fp1.len(), "dup:".len() + 8);
858    }
859
860    #[test]
861    fn compact_fingerprint_key_is_independent_of_instance_order() {
862        let mut original = group(&["alpha()", "beta()"], 3);
863        original.instances[0].file = PathBuf::from("src/a.ts");
864        original.instances[0].start_line = 2;
865        original.instances[1].file = PathBuf::from("src/b.ts");
866        original.instances[1].start_line = 8;
867        let mut reordered = original.clone();
868        reordered.instances.reverse();
869
870        assert_eq!(
871            CloneFingerprintKey::from_group(&original),
872            CloneFingerprintKey::from_group(&reordered)
873        );
874    }
875
876    #[test]
877    fn duplicate_fragments_are_deduplicated_before_tokenization() {
878        let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
879        clones.instances[0].file = PathBuf::from("src/a.ts");
880        clones.instances[1].file = PathBuf::from("src/b.ts");
881        clones.instances[2].file = PathBuf::from("src/c.ts");
882        assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
883
884        clones.instances[2].file = PathBuf::from("src/c.css");
885        assert_eq!(
886            distinct_fragment_inputs(&clones.instances).len(),
887            2,
888            "equal source still needs separate tokenization when syntax differs"
889        );
890    }
891
892    #[test]
893    fn fingerprint_is_sibling_stable() {
894        let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
895        let before = clone_fingerprint(&group_a.instances);
896        let _group_b_edited = group(&["totallyDifferentBody()"], 2);
897        let after = clone_fingerprint(&group_a.instances);
898        assert_eq!(before, after);
899    }
900
901    #[test]
902    fn fingerprint_differs_for_different_content() {
903        let a = group(&["alpha()"], 2);
904        let b = group(&["beta()"], 2);
905        assert_ne!(
906            clone_fingerprint(&a.instances),
907            clone_fingerprint(&b.instances)
908        );
909    }
910
911    #[test]
912    fn fingerprint_ignores_formatting_comments_and_line_endings() {
913        let compact = group(
914            &["const total = left + right;", "const total = left + right;"],
915            2,
916        );
917        let formatted = group(
918            &[
919                "const  total=left + right; // reviewed\r\n",
920                "/* reviewed */\nconst total = left + right;",
921            ],
922            3,
923        );
924
925        assert_eq!(
926            clone_fingerprint(&compact.instances),
927            clone_fingerprint(&formatted.instances)
928        );
929    }
930
931    #[test]
932    fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
933        let reviewed = group(&["alpha()", "alpha()"], 2);
934        let edited = group(&["alpha()", "beta()"], 2);
935
936        assert_ne!(
937            clone_fingerprint(&reviewed.instances),
938            clone_fingerprint(&edited.instances)
939        );
940    }
941
942    #[test]
943    fn fingerprint_is_independent_of_instance_order_and_count() {
944        let two = group(&["alpha()", "beta()"], 2);
945        let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
946
947        assert_eq!(
948            clone_fingerprint(&two.instances),
949            clone_fingerprint(&three_reordered.instances)
950        );
951
952        let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
953        let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
954        assert_ne!(
955            two_set.ignored_clone_key_for_group(&two),
956            three_set.ignored_clone_key_for_group(&three_reordered)
957        );
958    }
959
960    #[test]
961    fn fingerprint_set_widens_only_colliding_short_handles() {
962        let a = group(&["alpha()"], 2);
963        let b = group(&["beta()"], 2);
964        let c = group(&["gamma()"], 2);
965        let entries = vec![
966            (&a, 0x0000_0001_1234_5678_u64),
967            (&b, 0x0000_0002_1234_5678_u64),
968            (&c, 0x0000_0003_8765_4321_u64),
969        ];
970
971        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
972
973        assert_eq!(
974            fingerprints.fingerprint_for_group(&a),
975            "dup:0000000112345678"
976        );
977        assert_eq!(
978            fingerprints.fingerprint_for_group(&b),
979            "dup:0000000212345678"
980        );
981        assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
982        assert!(
983            fingerprints
984                .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
985                .is_none()
986        );
987        assert_eq!(
988            fingerprints
989                .find_group(&[a, b, c], "dup:0000000212345678")
990                .and_then(|group| group.instances.first())
991                .map(|inst| inst.fragment.as_str()),
992            Some("beta()")
993        );
994    }
995
996    #[test]
997    fn fingerprint_set_suffixes_full_hash_collisions() {
998        let a = group(&["alpha()"], 2);
999        let b = group(&["beta()"], 2);
1000        let mut entries = vec![
1001            (&a, 0x0000_0001_1234_5678_u64),
1002            (&b, 0x0000_0001_1234_5678_u64),
1003        ];
1004
1005        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1006        entries.reverse();
1007        let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
1008
1009        let a_fingerprint = fingerprints.fingerprint_for_group(&a);
1010        let b_fingerprint = fingerprints.fingerprint_for_group(&b);
1011        assert_eq!(
1012            a_fingerprint,
1013            reversed_fingerprints.fingerprint_for_group(&a)
1014        );
1015        assert_eq!(
1016            b_fingerprint,
1017            reversed_fingerprints.fingerprint_for_group(&b)
1018        );
1019        let mut assigned = vec![a_fingerprint, b_fingerprint];
1020        assigned.sort_unstable();
1021        assert_eq!(
1022            assigned,
1023            ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
1024        );
1025        assert!(
1026            fingerprints
1027                .find_group(&[a.clone(), b.clone()], "dup:12345678")
1028                .is_none()
1029        );
1030        assert!(
1031            fingerprints
1032                .find_group(&[a, b], "dup:0000000112345678")
1033                .is_none()
1034        );
1035    }
1036
1037    #[test]
1038    fn group_suggestion_savings_is_lines_times_extra_copies() {
1039        let g = group(&["x", "x", "x"], 10); // 3 instances, 10 lines
1040        let suggestion = group_refactoring_suggestion(&g);
1041        assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
1042        assert_eq!(suggestion.estimated_savings, 20); // 10 * (3 - 1)
1043    }
1044
1045    #[test]
1046    fn dominant_identifier_picks_repeated_domain_name() {
1047        let g = group(
1048            &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
1049            3,
1050        );
1051        assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
1052    }
1053
1054    #[test]
1055    fn dominant_identifier_none_on_generic() {
1056        let g = group(&["const data = result.map((item) => item.value);"], 3);
1057        assert_eq!(dominant_identifier(&g), None);
1058    }
1059
1060    #[test]
1061    fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
1062        let g = group(
1063            &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
1064            4,
1065        );
1066        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
1067        let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
1068        assert_eq!(dominant_identifier(&only_keywords), None);
1069        let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
1070        assert_eq!(dominant_identifier(&g_global), None);
1071    }
1072
1073    #[test]
1074    fn dominant_identifier_none_on_single_letter_type_param() {
1075        let g = group(
1076            &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
1077            3,
1078        );
1079        assert_eq!(dominant_identifier(&g), None);
1080    }
1081
1082    #[test]
1083    fn dominant_identifier_none_on_tie() {
1084        let g = group(&["alpha(); beta();"], 2); // each appears once, no count >= 2
1085        assert_eq!(dominant_identifier(&g), None);
1086    }
1087
1088    #[test]
1089    fn dominant_identifier_prefers_structured_names() {
1090        let g = group(
1091            &["parseSchema(input); parseSchema(cache); helper(); helper();"],
1092            3,
1093        );
1094        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
1095    }
1096
1097    #[test]
1098    fn dominant_identifier_requires_plain_token_margin() {
1099        let low_signal = group(&["schema(); schema(); parseUser();"], 3);
1100        assert_eq!(dominant_identifier(&low_signal), None);
1101
1102        let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
1103        assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1104    }
1105
1106    #[test]
1107    fn dominant_identifier_is_stable_across_word_order() {
1108        let first = group(
1109            &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1110            3,
1111        );
1112        let second = group(
1113            &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1114            3,
1115        );
1116
1117        assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1118        assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1119    }
1120}