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