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            file: PathBuf::from("a.ts"),
608            start_line: 1,
609            end_line: 5,
610            start_col: 0,
611            end_col: 0,
612            fragment: fragment.to_string(),
613        }
614    }
615
616    fn group(fragments: &[&str], line_count: usize) -> CloneGroup {
617        CloneGroup {
618            instances: fragments.iter().map(|f| instance(f)).collect(),
619            token_count: 40,
620            line_count,
621            similarity: None,
622        }
623    }
624
625    fn relocated_collision_groups(root: &Path) -> Vec<CloneGroup> {
626        ["src", "other"]
627            .into_iter()
628            .map(|directory| {
629                let mut clone = group(&["alpha()", "alpha()"], 2);
630                for (index, instance) in clone.instances.iter_mut().enumerate() {
631                    instance.file = root.join(directory).join(format!("file-{index}.ts"));
632                }
633                clone
634            })
635            .collect()
636    }
637
638    #[test]
639    fn fingerprint_set_relocation_preserves_collision_identity() {
640        const RELOCATIONS: usize = 32;
641        let groups = relocated_collision_groups(Path::new("/original/checkout"));
642        let fingerprints = CloneFingerprintSet::from_groups(&groups);
643        let expected = fingerprints.fingerprint_for_group(&groups[0]);
644        for index in 0..RELOCATIONS {
645            let root = PathBuf::from(format!("/different/checkout-{index}/with spaces/café"));
646            let mut relocated = relocated_collision_groups(&root);
647            relocated.reverse();
648            for group in &mut relocated {
649                group.instances.reverse();
650            }
651            let actual = CloneFingerprintSet::from_groups(&relocated);
652            assert_eq!(
653                actual.fingerprint_for_group(&relocated[1]),
654                expected,
655                "{root:?}"
656            );
657            assert!(std::ptr::eq(
658                actual
659                    .find_group(&relocated, &expected)
660                    .expect("relocated trace handle resolves"),
661                &raw const relocated[1],
662            ));
663            assert_eq!(
664                actual.fingerprint_for_parts(
665                    &relocated[1].instances,
666                    relocated[1].token_count,
667                    relocated[1].line_count
668                ),
669                expected,
670            );
671        }
672    }
673
674    #[test]
675    fn corrected_collision_handles_do_not_alias_legacy_ordinals() {
676        let groups = relocated_collision_groups(Path::new("/project"));
677        let fingerprints = CloneFingerprintSet::from_groups(&groups);
678        for group in &groups {
679            let corrected = fingerprints.fingerprint_for_group(group);
680            assert!(corrected.contains("-r"));
681            let legacy = corrected.replacen("-r", "-", 1);
682            assert!(fingerprints.find_group(&groups, &legacy).is_none());
683            assert!(fingerprints.find_group(&groups, &corrected).is_some());
684        }
685    }
686
687    #[test]
688    fn relocated_collision_suppression_matches_only_corrected_handles() {
689        use super::super::types::DuplicationReport;
690        use crate::baseline::{DuplicationBaselineData, filter_new_clone_groups};
691
692        let original = DuplicationReport {
693            clone_groups: relocated_collision_groups(Path::new("/original/checkout")),
694            ..Default::default()
695        };
696        let fingerprints = CloneFingerprintSet::from_groups(&original.clone_groups);
697        let reviewed = fingerprints.ignored_clone_key_for_group(&original.clone_groups[0]);
698        let baseline =
699            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
700        let root = Path::new("/relocated/with spaces/café");
701        let mut relocated = DuplicationReport {
702            clone_groups: relocated_collision_groups(root),
703            ..Default::default()
704        };
705        relocated.clone_groups.reverse();
706        for group in &mut relocated.clone_groups {
707            group.instances.reverse();
708        }
709        let remaining_location = relocated.clone_groups[0].instances[0].file.clone();
710
711        let mut ignored = relocated.clone();
712        super::super::apply_ignored_clones_filter(&mut ignored, std::slice::from_ref(&reviewed));
713        assert_eq!(ignored.clone_groups.len(), 1);
714        assert_eq!(
715            ignored.clone_groups[0].instances[0].file,
716            remaining_location
717        );
718        assert_eq!(ignored.stats.clone_groups_ignored, 1);
719
720        let mut partial_baseline =
721            DuplicationBaselineData::from_report(&original, Path::new("/original/checkout"));
722        partial_baseline.normalized_clone_fingerprints = vec![reviewed.clone()];
723        let filtered = filter_new_clone_groups(relocated.clone(), &partial_baseline, root);
724        assert_eq!(filtered.clone_groups.len(), 1);
725        assert_eq!(
726            filtered.clone_groups[0].instances[0].file,
727            remaining_location
728        );
729        assert!(
730            filter_new_clone_groups(relocated.clone(), &baseline, root)
731                .clone_groups
732                .is_empty()
733        );
734
735        let legacy = reviewed.replacen("-r", "-", 1);
736        super::super::apply_ignored_clones_filter(&mut relocated, std::slice::from_ref(&legacy));
737        assert_eq!(relocated.clone_groups.len(), original.clone_groups.len());
738        partial_baseline.normalized_clone_fingerprints = vec![legacy];
739        // Populated old raw/location fields must not turn a normalized-key miss
740        // into an ambiguous fallback suppression.
741        let filtered = filter_new_clone_groups(relocated, &partial_baseline, root);
742        assert_eq!(filtered.clone_groups.len(), original.clone_groups.len());
743    }
744
745    #[test]
746    fn fingerprint_is_stable_and_prefixed() {
747        let g = group(&["foo(bar)", "foo(baz)"], 3);
748        let fp1 = clone_fingerprint(&g.instances);
749        let fp2 = clone_fingerprint(&g.instances);
750        assert_eq!(fp1, fp2);
751        assert!(fp1.starts_with("dup:"));
752        assert_eq!(fp1.len(), "dup:".len() + 8);
753    }
754
755    #[test]
756    fn compact_fingerprint_key_is_independent_of_instance_order() {
757        let mut original = group(&["alpha()", "beta()"], 3);
758        original.instances[0].file = PathBuf::from("src/a.ts");
759        original.instances[0].start_line = 2;
760        original.instances[1].file = PathBuf::from("src/b.ts");
761        original.instances[1].start_line = 8;
762        let mut reordered = original.clone();
763        reordered.instances.reverse();
764
765        assert_eq!(
766            CloneFingerprintKey::from_group(&original),
767            CloneFingerprintKey::from_group(&reordered)
768        );
769    }
770
771    #[test]
772    fn duplicate_fragments_are_deduplicated_before_tokenization() {
773        let mut clones = group(&["alpha()", "alpha()", "alpha()"], 2);
774        clones.instances[0].file = PathBuf::from("src/a.ts");
775        clones.instances[1].file = PathBuf::from("src/b.ts");
776        clones.instances[2].file = PathBuf::from("src/c.ts");
777        assert_eq!(distinct_fragment_inputs(&clones.instances).len(), 1);
778
779        clones.instances[2].file = PathBuf::from("src/c.css");
780        assert_eq!(
781            distinct_fragment_inputs(&clones.instances).len(),
782            2,
783            "equal source still needs separate tokenization when syntax differs"
784        );
785    }
786
787    #[test]
788    fn fingerprint_is_sibling_stable() {
789        let group_a = group(&["computeInvoiceTotal(order)", "computeInvoiceTotal(o)"], 4);
790        let before = clone_fingerprint(&group_a.instances);
791        let _group_b_edited = group(&["totallyDifferentBody()"], 2);
792        let after = clone_fingerprint(&group_a.instances);
793        assert_eq!(before, after);
794    }
795
796    #[test]
797    fn fingerprint_differs_for_different_content() {
798        let a = group(&["alpha()"], 2);
799        let b = group(&["beta()"], 2);
800        assert_ne!(
801            clone_fingerprint(&a.instances),
802            clone_fingerprint(&b.instances)
803        );
804    }
805
806    #[test]
807    fn fingerprint_ignores_formatting_comments_and_line_endings() {
808        let compact = group(
809            &["const total = left + right;", "const total = left + right;"],
810            2,
811        );
812        let formatted = group(
813            &[
814                "const  total=left + right; // reviewed\r\n",
815                "/* reviewed */\nconst total = left + right;",
816            ],
817            3,
818        );
819
820        assert_eq!(
821            clone_fingerprint(&compact.instances),
822            clone_fingerprint(&formatted.instances)
823        );
824    }
825
826    #[test]
827    fn fingerprint_changes_when_any_distinct_instance_changes_tokens() {
828        let reviewed = group(&["alpha()", "alpha()"], 2);
829        let edited = group(&["alpha()", "beta()"], 2);
830
831        assert_ne!(
832            clone_fingerprint(&reviewed.instances),
833            clone_fingerprint(&edited.instances)
834        );
835    }
836
837    #[test]
838    fn fingerprint_is_independent_of_instance_order_and_count() {
839        let two = group(&["alpha()", "beta()"], 2);
840        let three_reordered = group(&["beta()", "alpha()", "alpha()"], 2);
841
842        assert_eq!(
843            clone_fingerprint(&two.instances),
844            clone_fingerprint(&three_reordered.instances)
845        );
846
847        let two_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&two));
848        let three_set = CloneFingerprintSet::from_groups(std::slice::from_ref(&three_reordered));
849        assert_ne!(
850            two_set.ignored_clone_key_for_group(&two),
851            three_set.ignored_clone_key_for_group(&three_reordered)
852        );
853    }
854
855    #[test]
856    fn fingerprint_set_widens_only_colliding_short_handles() {
857        let a = group(&["alpha()"], 2);
858        let b = group(&["beta()"], 2);
859        let c = group(&["gamma()"], 2);
860        let entries = vec![
861            (&a, 0x0000_0001_1234_5678_u64),
862            (&b, 0x0000_0002_1234_5678_u64),
863            (&c, 0x0000_0003_8765_4321_u64),
864        ];
865
866        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
867
868        assert_eq!(
869            fingerprints.fingerprint_for_group(&a),
870            "dup:0000000112345678"
871        );
872        assert_eq!(
873            fingerprints.fingerprint_for_group(&b),
874            "dup:0000000212345678"
875        );
876        assert_eq!(fingerprints.fingerprint_for_group(&c), "dup:87654321");
877        assert!(
878            fingerprints
879                .find_group(&[a.clone(), b.clone(), c.clone()], "dup:12345678")
880                .is_none()
881        );
882        assert_eq!(
883            fingerprints
884                .find_group(&[a, b, c], "dup:0000000212345678")
885                .and_then(|group| group.instances.first())
886                .map(|inst| inst.fragment.as_str()),
887            Some("beta()")
888        );
889    }
890
891    #[test]
892    fn fingerprint_set_suffixes_full_hash_collisions() {
893        let a = group(&["alpha()"], 2);
894        let b = group(&["beta()"], 2);
895        let mut entries = vec![
896            (&a, 0x0000_0001_1234_5678_u64),
897            (&b, 0x0000_0001_1234_5678_u64),
898        ];
899
900        let fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
901        entries.reverse();
902        let reversed_fingerprints = CloneFingerprintSet::from_hashed_entries(&entries);
903
904        let a_fingerprint = fingerprints.fingerprint_for_group(&a);
905        let b_fingerprint = fingerprints.fingerprint_for_group(&b);
906        assert_eq!(
907            a_fingerprint,
908            reversed_fingerprints.fingerprint_for_group(&a)
909        );
910        assert_eq!(
911            b_fingerprint,
912            reversed_fingerprints.fingerprint_for_group(&b)
913        );
914        let mut assigned = vec![a_fingerprint, b_fingerprint];
915        assigned.sort_unstable();
916        assert_eq!(
917            assigned,
918            ["dup:0000000112345678-r1", "dup:0000000112345678-r2"]
919        );
920        assert!(
921            fingerprints
922                .find_group(&[a.clone(), b.clone()], "dup:12345678")
923                .is_none()
924        );
925        assert!(
926            fingerprints
927                .find_group(&[a, b], "dup:0000000112345678")
928                .is_none()
929        );
930    }
931
932    #[test]
933    fn group_suggestion_savings_is_lines_times_extra_copies() {
934        let g = group(&["x", "x", "x"], 10); // 3 instances, 10 lines
935        let suggestion = group_refactoring_suggestion(&g);
936        assert_eq!(suggestion.kind, RefactoringKind::ExtractFunction);
937        assert_eq!(suggestion.estimated_savings, 20); // 10 * (3 - 1)
938    }
939
940    #[test]
941    fn dominant_identifier_picks_repeated_domain_name() {
942        let g = group(
943            &["function buildInvoice(invoice) { return invoice.total + invoice.tax; }"],
944            3,
945        );
946        assert_eq!(dominant_identifier(&g).as_deref(), Some("invoice"));
947    }
948
949    #[test]
950    fn dominant_identifier_none_on_generic() {
951        let g = group(&["const data = result.map((item) => item.value);"], 3);
952        assert_eq!(dominant_identifier(&g), None);
953    }
954
955    #[test]
956    fn dominant_identifier_skips_ts_primitive_keywords_and_globals() {
957        let g = group(
958            &["const parseUser = z.string(); parseUser(z.number()); parseUser.or(z.string());"],
959            4,
960        );
961        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseUser"));
962        let only_keywords = group(&["const x: string = y as string; return x as any;"], 3);
963        assert_eq!(dominant_identifier(&only_keywords), None);
964        let g_global = group(&["Math.max(Math.floor(Math.abs(v)), 0)"], 3);
965        assert_eq!(dominant_identifier(&g_global), None);
966    }
967
968    #[test]
969    fn dominant_identifier_none_on_single_letter_type_param() {
970        let g = group(
971            &["function id<T>(x: T): T { const a: T = x; return a as T; }"],
972            3,
973        );
974        assert_eq!(dominant_identifier(&g), None);
975    }
976
977    #[test]
978    fn dominant_identifier_none_on_tie() {
979        let g = group(&["alpha(); beta();"], 2); // each appears once, no count >= 2
980        assert_eq!(dominant_identifier(&g), None);
981    }
982
983    #[test]
984    fn dominant_identifier_prefers_structured_names() {
985        let g = group(
986            &["parseSchema(input); parseSchema(cache); helper(); helper();"],
987            3,
988        );
989        assert_eq!(dominant_identifier(&g).as_deref(), Some("parseSchema"));
990    }
991
992    #[test]
993    fn dominant_identifier_requires_plain_token_margin() {
994        let low_signal = group(&["schema(); schema(); parseUser();"], 3);
995        assert_eq!(dominant_identifier(&low_signal), None);
996
997        let strong = group(&["schema(); schema(); schema(); schema(); parseUser();"], 3);
998        assert_eq!(dominant_identifier(&strong).as_deref(), Some("schema"));
999    }
1000
1001    #[test]
1002    fn dominant_identifier_is_stable_across_word_order() {
1003        let first = group(
1004            &["helper(); parseSchema(input); helper(); parseSchema(cache);"],
1005            3,
1006        );
1007        let second = group(
1008            &["parseSchema(input); helper(); parseSchema(cache); helper();"],
1009            3,
1010        );
1011
1012        assert_eq!(dominant_identifier(&first), dominant_identifier(&second));
1013        assert_eq!(dominant_identifier(&first).as_deref(), Some("parseSchema"));
1014    }
1015}