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 tokens = super::tokenize::tokenize_file(path, fragment, false);
305    super::normalize::normalize_and_hash(&tokens.tokens, DetectionMode::Strict)
306        .into_iter()
307        .map(|token| token.hash)
308        .collect()
309}
310
311fn hash_normalized_sequences(sequences: &[Vec<u64>]) -> u64 {
312    let byte_len = sequences
313        .iter()
314        .map(|sequence| sequence.len().saturating_add(1))
315        .sum::<usize>()
316        .saturating_mul(std::mem::size_of::<u64>());
317    let mut bytes = Vec::with_capacity(byte_len);
318    for sequence in sequences {
319        bytes.extend_from_slice(&(sequence.len() as u64).to_le_bytes());
320        for hash in sequence {
321            bytes.extend_from_slice(&hash.to_le_bytes());
322        }
323    }
324    xxh3_64(&bytes)
325}
326
327fn fingerprint_for_hash(hash: u64) -> String {
328    format!("{FINGERPRINT_PREFIX}{:08x}", hash as u32)
329}
330
331/// Build a per-group `ExtractFunction` refactoring suggestion.
332///
333/// Mirrors the per-group branch of the families suggestion generator:
334/// the savings is `(instances - 1)` copies of the group's line count, since one
335/// copy survives as the extracted function and the rest collapse to call sites.
336#[must_use]
337pub fn group_refactoring_suggestion(group: &CloneGroup) -> RefactoringSuggestion {
338    let estimated_savings = group.line_count * group.instances.len().saturating_sub(1);
339    RefactoringSuggestion {
340        kind: RefactoringKind::ExtractFunction,
341        description: format!(
342            "Extract the shared {}-line block into one function and call it from {} sites",
343            group.line_count,
344            group.instances.len(),
345        ),
346        estimated_savings,
347    }
348}
349
350/// Best-effort name for the extracted function, derived from the most frequent
351/// non-generic identifier in the representative fragment.
352///
353/// Returns `None` when the dominant identifier is generic (`data`, `result`,
354/// loop counters), appears only once, or ties with another, so absence is the
355/// low-confidence signal for both human and agent consumers. This is a
356/// lexical heuristic over the raw fragment, not an AST analysis; it is advisory
357/// and consumers should verify before applying.
358#[must_use]
359pub fn dominant_identifier(group: &CloneGroup) -> Option<String> {
360    let fragment = group.instances.first().map(|inst| inst.fragment.as_str())?;
361    let mut counts: FxHashMap<&str, usize> = FxHashMap::default();
362    for word in identifier_words(fragment) {
363        if is_generic_identifier(word) {
364            continue;
365        }
366        *counts.entry(word).or_insert(0) += 1;
367    }
368
369    let mut candidates: Vec<_> = counts
370        .into_iter()
371        .map(|(word, count)| IdentifierCandidate {
372            word,
373            count,
374            score: identifier_score(word, count),
375        })
376        .collect();
377    candidates.sort_by(|a, b| {
378        b.score
379            .cmp(&a.score)
380            .then_with(|| b.count.cmp(&a.count))
381            .then_with(|| a.word.cmp(b.word))
382    });
383
384    let best = candidates.first()?;
385    if best.count < 2 {
386        return None;
387    }
388
389    let runner_up = candidates.get(1);
390    if runner_up.is_some_and(|next| best.score.saturating_sub(next.score) < 2) {
391        return None;
392    }
393
394    if is_plain_single_token(best.word) {
395        let next_count = runner_up.map_or(0, |candidate| candidate.count);
396        if best.count < 3 || best.count < next_count + 2 {
397            return None;
398        }
399    }
400
401    Some(best.word.to_string())
402}
403
404#[derive(Debug)]
405struct IdentifierCandidate<'a> {
406    word: &'a str,
407    count: usize,
408    score: usize,
409}
410
411fn identifier_score(word: &str, count: usize) -> usize {
412    let quality_bonus = if has_identifier_separator_or_case_transition(word) {
413        5
414    } else if word.chars().count() >= 8 {
415        2
416    } else {
417        0
418    };
419    count * 5 + quality_bonus
420}
421
422fn is_plain_single_token(word: &str) -> bool {
423    !has_identifier_separator_or_case_transition(word) && word.chars().count() < 8
424}
425
426fn has_identifier_separator_or_case_transition(word: &str) -> bool {
427    if word.contains('_') || word.contains('$') {
428        return true;
429    }
430
431    let mut previous = None;
432    for ch in word.chars() {
433        if previous.is_some_and(|prev: char| prev.is_ascii_lowercase() && ch.is_ascii_uppercase()) {
434            return true;
435        }
436        previous = Some(ch);
437    }
438    false
439}
440
441/// Yield identifier-like words (`[A-Za-z_$][A-Za-z0-9_$]*`) from raw source.
442fn identifier_words(source: &str) -> impl Iterator<Item = &str> {
443    source
444        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
445        .filter(|word| {
446            !word.is_empty()
447                && word
448                    .chars()
449                    .next()
450                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
451        })
452}
453
454/// Identifiers too generic to make a useful extracted-function name, plus the
455/// reserved words that show up as bare tokens in a fragment.
456const GENERIC_IDENTIFIERS: &[&str] = &[
457    "data",
458    "result",
459    "results",
460    "item",
461    "items",
462    "value",
463    "values",
464    "val",
465    "obj",
466    "object",
467    "arr",
468    "array",
469    "list",
470    "map",
471    "set",
472    "key",
473    "keys",
474    "tmp",
475    "temp",
476    "acc",
477    "cur",
478    "curr",
479    "prev",
480    "next",
481    "node",
482    "el",
483    "elem",
484    "element",
485    "args",
486    "arg",
487    "opts",
488    "options",
489    "params",
490    "param",
491    "props",
492    "ctx",
493    "context",
494    "res",
495    "req",
496    "err",
497    "error",
498    "fn",
499    "cb",
500    "callback",
501    "out",
502    "input",
503    "output",
504    "name",
505    "id",
506    "index",
507    "idx",
508    "x",
509    "y",
510    "z",
511    "i",
512    "j",
513    "k",
514    "n",
515    "m",
516    "a",
517    "b",
518    "c",
519    "e",
520    "_",
521    "const",
522    "let",
523    "var",
524    "function",
525    "return",
526    "if",
527    "else",
528    "for",
529    "while",
530    "do",
531    "switch",
532    "case",
533    "break",
534    "continue",
535    "new",
536    "this",
537    "true",
538    "false",
539    "null",
540    "undefined",
541    "void",
542    "typeof",
543    "instanceof",
544    "in",
545    "of",
546    "class",
547    "extends",
548    "super",
549    "import",
550    "export",
551    "from",
552    "default",
553    "async",
554    "await",
555    "yield",
556    "type",
557    "interface",
558    "enum",
559    "as",
560    "is",
561    "keyof",
562    "readonly",
563    "public",
564    "private",
565    "protected",
566    "static",
567    "get",
568    "delete",
569    "throw",
570    "try",
571    "catch",
572    "finally",
573    "string",
574    "number",
575    "boolean",
576    "any",
577    "unknown",
578    "never",
579    "bigint",
580    "symbol",
581    "Math",
582    "JSON",
583    "Object",
584    "Array",
585    "Promise",
586    "BigInt",
587    "Number",
588    "String",
589    "Boolean",
590    "Symbol",
591    "RegExp",
592    "Date",
593];
594
595fn is_generic_identifier(word: &str) -> bool {
596    word.chars().count() == 1 || GENERIC_IDENTIFIERS.contains(&word)
597}
598
599#[cfg(test)]
600mod tests {
601    use std::path::PathBuf;
602
603    use super::*;
604
605    fn instance(fragment: &str) -> CloneInstance {
606        CloneInstance {
607            is_symlink: false,
608            file: PathBuf::from("a.ts"),
609            start_line: 1,
610            end_line: 5,
611            start_col: 0,
612            end_col: 0,
613            fragment: fragment.to_string(),
614        }
615    }
616
617    fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
618        CloneGroup {
619            instances: fragments.iter().map(|f| instance(f)).collect(),
620            token_count: 40,
621            line_count,
622            similarity: None,
623        }
624    }
625
626    fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
627        ["src", "other"]
628            .into_iter()
629            .map(|directory| {
630                let mut clone = group(&["alpha()", "alpha()"], 2);
631                for (index, instance) in clone.instances.iter_mut().enumerate() {
632                    instance.file = root.join(directory).join(format!("file-{index}.ts"));
633                }
634                clone
635            })
636            .collect()
637    }
638
639    #[test]
640    fn fingerprint_set_relocation_preserves_collision_identity() {
641        const RELOCATIONS: usize = 32;
642        let groups = relocated_collision_groups(Path::new("/original/checkout"));
643        let fingerprints = CloneFingerprintSet::from_groups(&groups);
644        let expected = fingerprints.fingerprint_for_group(&groups[0]);
645        for index in 0..RELOCATIONS {
646            let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
647            let mut relocated = relocated_collision_groups(&root);
648            relocated.reverse();
649            for group in &mut relocated {
650                group.instances.reverse();
651            }
652            let actual = CloneFingerprintSet::from_groups(&relocated);
653            assert_eq!(
654                actual.fingerprint_for_group(&relocated[1]),
655                expected,
656                "{root:?}"
657            );
658            assert!(std::ptr::eq(
659                actual
660                    .find_group(&relocated, &expected)
661                    .expect("relocated trace handle resolves"),
662                &raw const relocated[1],
663            ));
664            assert_eq!(
665                actual.fingerprint_for_parts(
666                    &relocated[1].instances,
667                    relocated[1].token_count,
668                    relocated[1].line_count
669                ),
670                expected,
671            );
672        }
673    }
674
675    #[test]
676    fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
677        let groups = relocated_collision_groups(Path::new("/project"));
678        let fingerprints = CloneFingerprintSet::from_groups(&groups);
679        for group in &groups {
680            let corrected = fingerprints.fingerprint_for_group(group);
681            assert!(corrected.contains("-r"));
682            let legacy = corrected.replacen("-r", "-", 1);
683            assert!(fingerprints.find_group(&groups, &legacy).is_none());
684            assert!(fingerprints.find_group(&groups, &corrected).is_some());
685        }
686    }
687
688    #[test]
689    fn relocated_collision_suppression_matches_only_corrected_handles() {
690        use super::super::types::DuplicationReport;
691        use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
692
693        let original = DuplicationReport {
694            clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
695            ..Default::default()
696        };
697        let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
698        let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
699        let baseline =
700            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
701        let root = Path::new("/relocated/with spaces/café");
702        let mut relocated = DuplicationReport {
703            clone_groups: relocated_collision_groups(root),
704            ..Default::default()
705        };
706        relocated.clone_groups.reverse();
707        for group in &mut relocated.clone_groups {
708            group.instances.reverse();
709        }
710        let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
711
712        let mut ignored = relocated.clone();
713        super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
714        assert_eq!(ignored.clone_groups.len(), 1);
715        assert_eq!(
716            ignored.clone_groups[0].instances[0].file,
717            remaining_location
718        );
719        assert_eq!(ignored.stats.clone_groups_ignored, 1);
720
721        let mut partial_baseline =
722            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
723        partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
724        let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
725        assert_eq!(filtered.clone_groups.len(), 1);
726        assert_eq!(
727            filtered.clone_groups[0].instances[0].file,
728            remaining_location
729        );
730        assert!(
731            filter_new_clone_groups(relocated.clone(), &baseline, root)
732                .clone_groups
733                .is_empty()
734        );
735
736        let legacy = reviewed.replacen("-r", "-", 1);
737        super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
738        assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
739        partial_baseline.normalized_clone_fingerprints = vec![legacy];
740        // Populated old raw/location fields must not turn a normalized-key miss
741        // into an ambiguous fallback suppression.
742        let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
743        assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
744    }
745
746    #[test]
747    fn fingerprint_is_stable_and_prefixed() {
748        let g = group(&["foo(bar)", "foo(baz)"], 3);
749        let fp1 = clone_fingerprint(&g.instances);
750        let fp2 = clone_fingerprint(&g.instances);
751        assert_eq!(fp1, fp2);
752        assert!(fp1.starts_with("dup:"));
753        assert_eq!(fp1.len(), "dup:".len() + 8);
754    }
755
756    #[test]
757    fn compact_fingerprint_key_is_independent_of_instance_order() {
758        let mut original = group(&["alpha()", "beta()"], 3);
759        original.instances[0].file = PathBuf::from("src/a.ts");
760        original.instances[0].start_line = 2;
761        original.instances[1].file = PathBuf::from("src/b.ts");
762        original.instances[1].start_line = 8;
763        let mut reordered = original.clone();
764        reordered.instances.reverse();
765
766        assert_eq!(
767            CloneFingerprintKey::from_group(&original),
768            CloneFingerprintKey::from_group(&reordered)
769        );
770    }
771
772    #[test]
773    fn duplicate_fragments_are_deduplicated_before_tokenization() {
774        let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
775        clones.instances[0].file = PathBuf::from("src/a.ts");
776        clones.instances[1].file = PathBuf::from("src/b.ts");
777        clones.instances[2].file = PathBuf::from("src/c.ts");
778        assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
779
780        clones.instances[2].file = PathBuf::from("src/c.css");
781        assert_eq!(
782            distinct_fragment_inputs(&clones.instances).len(),
783            2,
784            "equal source still needs separate tokenization when syntax differs"
785        );
786    }
787
788    #[test]
789    fn fingerprint_is_sibling_stable() {
790        let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
791        let before = clone_fingerprint(&group_a.instances);
792        let _group_b_edited = group(&["totallyDifferentBody()"], 2);
793        let after = clone_fingerprint(&group_a.instances);
794        assert_eq!(before, after);
795    }
796
797    #[test]
798    fn fingerprint_differs_for_different_content() {
799        let a = group(&["alpha()"], 2);
800        let b = group(&["beta()"], 2);
801        assert_ne!(
802            clone_fingerprint(&a.instances),
803            clone_fingerprint(&b.instances)
804        );
805    }
806
807    #[test]
808    fn fingerprint_ignores_formatting_comments_and_line_endings() {
809        let compact = group(
810            &["const total = left + right;", "const total = left + right;"],
811            2,
812        );
813        let formatted = group(
814            &[
815                "const  total=left + right; // reviewed\r\n",
816                "/* reviewed */\nconst total = left + right;",
817            ],
818            3,
819        );
820
821        assert_eq!(
822            clone_fingerprint(&compact.instances),
823            clone_fingerprint(&formatted.instances)
824        );
825    }
826
827    #[test]
828    fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
829        let reviewed = group(&["alpha()", "alpha()"], 2);
830        let edited = group(&["alpha()", "beta()"], 2);
831
832        assert_ne!(
833            clone_fingerprint(&reviewed.instances),
834            clone_fingerprint(&edited.instances)
835        );
836    }
837
838    #[test]
839    fn fingerprint_is_independent_of_instance_order_and_count() {
840        let two = group(&["alpha()", "beta()"], 2);
841        let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
842
843        assert_eq!(
844            clone_fingerprint(&two.instances),
845            clone_fingerprint(&three_reordered.instances)
846        );
847
848        let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
849        let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
850        assert_ne!(
851            two_set.ignored_clone_key_for_group(&two),
852            three_set.ignored_clone_key_for_group(&three_reordered)
853        );
854    }
855
856    #[test]
857    fn fingerprint_set_widens_only_colliding_short_handles() {
858        let a = group(&["alpha()"], 2);
859        let b = group(&["beta()"], 2);
860        let c = group(&["gamma()"], 2);
861        let entries = vec![
862            (&a, 0x0000_0001_1234_5678_u64),
863            (&b, 0x0000_0002_1234_5678_u64),
864            (&c, 0x0000_0003_8765_4321_u64),
865        ];
866
867        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
868
869        assert_eq!(
870            fingerprints.fingerprint_for_group(&a),
871            "dup:0000000112345678"
872        );
873        assert_eq!(
874            fingerprints.fingerprint_for_group(&b),
875            "dup:0000000212345678"
876        );
877        assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
878        assert!(
879            fingerprints
880                .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
881                .is_none()
882        );
883        assert_eq!(
884            fingerprints
885                .find_group(&[a, b, c], "dup:0000000212345678")
886                .and_then(|group| group.instances.first())
887                .map(|inst| inst.fragment.as_str()),
888            Some("beta()")
889        );
890    }
891
892    #[test]
893    fn fingerprint_set_suffixes_full_hash_collisions() {
894        let a = group(&["alpha()"], 2);
895        let b = group(&["beta()"], 2);
896        let mut entries = vec![
897            (&a, 0x0000_0001_1234_5678_u64),
898            (&b, 0x0000_0001_1234_5678_u64),
899        ];
900
901        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
902        entries.reverse();
903        let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
904
905        let a_fingerprint = fingerprints.fingerprint_for_group(&a);
906        let b_fingerprint = fingerprints.fingerprint_for_group(&b);
907        assert_eq!(
908            a_fingerprint,
909            reversed_fingerprints.fingerprint_for_group(&a)
910        );
911        assert_eq!(
912            b_fingerprint,
913            reversed_fingerprints.fingerprint_for_group(&b)
914        );
915        let mut assigned = vec![a_fingerprint, b_fingerprint];
916        assigned.sort_unstable();
917        assert_eq!(
918            assigned,
919            ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
920        );
921        assert!(
922            fingerprints
923                .find_group(&[a.clone(), b.clone()], "dup:12345678")
924                .is_none()
925        );
926        assert!(
927            fingerprints
928                .find_group(&[a, b], "dup:0000000112345678")
929                .is_none()
930        );
931    }
932
933    #[test]
934    fn group_suggestion_savings_is_lines_times_extra_copies() {
935        let g = group(&["x", "x", "x"], 10); // 3 instances, 10 lines
936        let suggestion = group_refactoring_suggestion(&g);
937        assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
938        assert_eq!(suggestion.estimated_savings, 20); // 10 * (3 - 1)
939    }
940
941    #[test]
942    fn dominant_identifier_picks_repeated_domain_name() {
943        let g = group(
944            &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
945            3,
946        );
947        assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
948    }
949
950    #[test]
951    fn dominant_identifier_none_on_generic() {
952        let g = group(&["const data = result.map((item) => item.value);"], 3);
953        assert_eq!(dominant_identifier(&g), None);
954    }
955
956    #[test]
957    fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
958        let g = group(
959            &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
960            4,
961        );
962        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
963        let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
964        assert_eq!(dominant_identifier(&only_keywords), None);
965        let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
966        assert_eq!(dominant_identifier(&g_global), None);
967    }
968
969    #[test]
970    fn dominant_identifier_none_on_single_letter_type_param() {
971        let g = group(
972            &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
973            3,
974        );
975        assert_eq!(dominant_identifier(&g), None);
976    }
977
978    #[test]
979    fn dominant_identifier_none_on_tie() {
980        let g = group(&["alpha(); beta();"], 2); // each appears once, no count >= 2
981        assert_eq!(dominant_identifier(&g), None);
982    }
983
984    #[test]
985    fn dominant_identifier_prefers_structured_names() {
986        let g = group(
987            &["parseSchema(input); parseSchema(cache); helper(); helper();"],
988            3,
989        );
990        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
991    }
992
993    #[test]
994    fn dominant_identifier_requires_plain_token_margin() {
995        let low_signal = group(&["schema(); schema(); parseUser();"], 3);
996        assert_eq!(dominant_identifier(&low_signal), None);
997
998        let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
999        assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
1000    }
1001
1002    #[test]
1003    fn dominant_identifier_is_stable_across_word_order() {
1004        let first = group(
1005            &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1006            3,
1007        );
1008        let second = group(
1009            &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1010            3,
1011        );
1012
1013        assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1014        assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1015    }
1016}