Skip to main content

fallow_engine/
change_coupling.rs

1//! Change coupling and change dispersion from the commits of the churn pass.
2//!
3//! Change dispersion is the number of files and lines in the commits that
4//! touch a file. Change coupling is a pair of files that change together in
5//! many commits. A coupled pair is hidden when no static edge (an import or a
6//! re-export chain) connects the two files, so an agent that reads the import
7//! graph cannot find the second file.
8//!
9//! The thresholds follow the Code Maat defaults: a file needs 5 revisions, a
10//! pair needs 5 shared revisions and a coupling degree of at least 30%, and a
11//! commit that touches more than 30 files is a bulk commit and is skipped.
12
13use std::path::{Path, PathBuf};
14
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use crate::churn::ChurnCommit;
18
19/// Default maximum number of analyzed files in one commit. A larger commit
20/// is a bulk change (a rename, a format pass, a dependency bump) and creates
21/// pairs that are not real coupling.
22pub const DEFAULT_MAX_FILES_PER_COMMIT: usize = 30;
23/// Default minimum number of commits for each file of a pair.
24pub const DEFAULT_MIN_REVISIONS: u32 = 5;
25/// Default minimum number of commits that change both files of a pair.
26pub const DEFAULT_MIN_SHARED_REVISIONS: u32 = 5;
27/// Default minimum coupling degree, as a ratio in `[0, 1]`.
28pub const DEFAULT_MIN_DEGREE: f64 = 0.3;
29/// Coupling degree at or above which a hidden pair is strong. A strong pair
30/// becomes a refactoring target.
31pub const STRONG_COUPLING_DEGREE: f64 = 0.5;
32
33/// Thresholds for change coupling.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ChangeCouplingSettings {
36    /// Commits with more analyzed files than this are skipped.
37    pub max_files_per_commit: usize,
38    /// Minimum commits for each file of a pair.
39    pub min_revisions: u32,
40    /// Minimum commits that change both files.
41    pub min_shared_revisions: u32,
42    /// Minimum coupling degree in `[0, 1]`.
43    pub min_degree: f64,
44}
45
46impl Default for ChangeCouplingSettings {
47    fn default() -> Self {
48        Self {
49            max_files_per_commit: DEFAULT_MAX_FILES_PER_COMMIT,
50            min_revisions: DEFAULT_MIN_REVISIONS,
51            min_shared_revisions: DEFAULT_MIN_SHARED_REVISIONS,
52            min_degree: DEFAULT_MIN_DEGREE,
53        }
54    }
55}
56
57impl From<&fallow_config::ChangeCouplingConfig> for ChangeCouplingSettings {
58    fn from(config: &fallow_config::ChangeCouplingConfig) -> Self {
59        Self {
60            max_files_per_commit: config.max_files_per_commit,
61            min_revisions: config.min_revisions,
62            min_shared_revisions: config.min_shared_revisions,
63            min_degree: config.min_degree,
64        }
65    }
66}
67
68/// A pair of files that change together. `path_a` sorts before `path_b`.
69#[derive(Debug, Clone, PartialEq)]
70pub struct CoChangePair {
71    /// First file (absolute path).
72    pub path_a: PathBuf,
73    /// Second file (absolute path).
74    pub path_b: PathBuf,
75    /// Commits that change `path_a`.
76    pub revisions_a: u32,
77    /// Commits that change `path_b`.
78    pub revisions_b: u32,
79    /// Commits that change both files.
80    pub shared_revisions: u32,
81    /// `shared_revisions` divided by the mean of the two revision counts, in
82    /// `[0, 1]`, rounded to two decimals.
83    pub degree: f64,
84}
85
86impl CoChangePair {
87    /// True when the degree reaches [`STRONG_COUPLING_DEGREE`].
88    #[must_use]
89    pub fn is_strong(&self) -> bool {
90        self.degree >= STRONG_COUPLING_DEGREE
91    }
92
93    /// The other file of the pair, when `path` is one of the two files.
94    #[must_use]
95    pub fn partner_of(&self, path: &Path) -> Option<&Path> {
96        if self.path_a == path {
97            Some(&self.path_b)
98        } else if self.path_b == path {
99            Some(&self.path_a)
100        } else {
101            None
102        }
103    }
104}
105
106/// Change dispersion of one file.
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct FileDispersion {
109    /// Median number of analyzed files in the commits that touch the file.
110    pub median_files_per_commit: f64,
111    /// Median number of changed lines (added plus deleted, analyzed files
112    /// only) in the commits that touch the file.
113    pub median_lines_per_commit: f64,
114}
115
116/// Result of one pass over the commit history.
117#[derive(Debug, Clone, Default)]
118pub struct ChangeHistoryAnalysis {
119    /// Commits that touch at least one analyzed file and are not bulk commits.
120    pub commits_analyzed: usize,
121    /// Commits skipped because they touch more analyzed files than the cap.
122    pub commits_skipped: usize,
123    /// Dispersion per analyzed file (absolute path).
124    pub dispersion: FxHashMap<PathBuf, FileDispersion>,
125    /// Hidden pairs: thresholds met and no static edge. Sorted by degree,
126    /// then shared revisions (both descending), then by path.
127    pub hidden_pairs: Vec<CoChangePair>,
128}
129
130impl ChangeHistoryAnalysis {
131    /// Hidden partners of `path`, strongest first.
132    pub fn partners_of<'a>(
133        &'a self,
134        path: &'a Path,
135    ) -> impl Iterator<Item = (&'a Path, &'a CoChangePair)> {
136        self.hidden_pairs
137            .iter()
138            .filter_map(move |pair| pair.partner_of(path).map(|partner| (partner, pair)))
139    }
140}
141
142/// Static edges between analyzed files, for the hidden-pair check.
143///
144/// Two files are statically linked when one imports the other, or imports a
145/// file that re-exports the other through a re-export chain.
146#[derive(Debug, Clone, Default)]
147pub struct StaticLinkIndex {
148    ids: FxHashMap<PathBuf, u32>,
149    /// Direct import and re-export targets per file.
150    targets: Vec<Vec<u32>>,
151    /// Re-export sources per file, for chain traversal.
152    re_export_sources: Vec<Vec<u32>>,
153}
154
155impl StaticLinkIndex {
156    /// Build the index from the module graph that a dead-code pass retained.
157    #[must_use]
158    pub fn from_retained_graph(graph: &crate::module_graph::RetainedModuleGraph) -> Self {
159        Self::from_graph(graph.as_graph())
160    }
161
162    /// Build the index from a module graph.
163    #[must_use]
164    pub(crate) fn from_graph(graph: &fallow_graph::graph::ModuleGraph) -> Self {
165        let mut ids = FxHashMap::default();
166        let mut targets = Vec::with_capacity(graph.modules.len());
167        let mut re_export_sources = Vec::with_capacity(graph.modules.len());
168        for node in &graph.modules {
169            ids.insert(node.path.clone(), node.file_id.0);
170            let mut node_targets: Vec<u32> = graph
171                .edges_for(node.file_id)
172                .iter()
173                .map(|id| id.0)
174                .collect();
175            let sources: Vec<u32> = node
176                .re_exports
177                .iter()
178                .map(|edge| edge.source_file.0)
179                .collect();
180            node_targets.extend(sources.iter().copied());
181            node_targets.sort_unstable();
182            node_targets.dedup();
183            targets.push(node_targets);
184            re_export_sources.push(sources);
185        }
186        Self {
187            ids,
188            targets,
189            re_export_sources,
190        }
191    }
192
193    /// Build an index from explicit links, for tests and embedders.
194    #[must_use]
195    pub fn from_links(
196        files: &[PathBuf],
197        imports: &[(usize, usize)],
198        re_exports: &[(usize, usize)],
199    ) -> Self {
200        let ids = files
201            .iter()
202            .enumerate()
203            .filter_map(|(idx, path)| u32::try_from(idx).ok().map(|idx| (path.clone(), idx)))
204            .collect();
205        let mut targets = vec![Vec::new(); files.len()];
206        let mut re_export_sources = vec![Vec::new(); files.len()];
207        for &(from, to) in imports {
208            if let (Some(list), Ok(to)) = (targets.get_mut(from), u32::try_from(to)) {
209                list.push(to);
210            }
211        }
212        for &(from, to) in re_exports {
213            if let (Some(list), Ok(to)) = (targets.get_mut(from), u32::try_from(to)) {
214                list.push(to);
215            }
216            if let (Some(list), Ok(to)) = (re_export_sources.get_mut(from), u32::try_from(to)) {
217                list.push(to);
218            }
219        }
220        Self {
221            ids,
222            targets,
223            re_export_sources,
224        }
225    }
226
227    /// True when `path` is an analyzed file.
228    #[must_use]
229    pub fn contains(&self, path: &Path) -> bool {
230        self.ids.contains_key(path)
231    }
232
233    /// True when a static edge connects `a` and `b` in either direction.
234    #[must_use]
235    pub fn linked(&self, a: &Path, b: &Path) -> bool {
236        let (Some(&a), Some(&b)) = (self.ids.get(a), self.ids.get(b)) else {
237            return false;
238        };
239        self.reaches(a, b) || self.reaches(b, a)
240    }
241
242    /// True when `from` imports `to`, or imports a file whose re-export chain
243    /// reaches `to`.
244    fn reaches(&self, from: u32, to: u32) -> bool {
245        let Some(direct) = self.targets.get(from as usize) else {
246            return false;
247        };
248        let mut seen: FxHashSet<u32> = FxHashSet::default();
249        let mut queue: Vec<u32> = direct.clone();
250        while let Some(current) = queue.pop() {
251            if current == to {
252                return true;
253            }
254            if !seen.insert(current) {
255                continue;
256            }
257            if let Some(sources) = self.re_export_sources.get(current as usize) {
258                queue.extend(sources.iter().copied().filter(|id| !seen.contains(id)));
259            }
260        }
261        false
262    }
263}
264
265/// Analyze change dispersion and hidden change coupling over `commits`.
266///
267/// Only files in `links` count. A commit with more analyzed files than
268/// [`ChangeCouplingSettings::max_files_per_commit`] is skipped for both
269/// dispersion and coupling.
270#[must_use]
271pub fn analyze_change_history(
272    commits: &[ChurnCommit],
273    links: &StaticLinkIndex,
274    settings: &ChangeCouplingSettings,
275) -> ChangeHistoryAnalysis {
276    let mut interned: FxHashMap<&Path, u32> = FxHashMap::default();
277    let mut paths: Vec<&Path> = Vec::new();
278    let mut revisions: Vec<u32> = Vec::new();
279    let mut samples: Vec<Vec<(u32, u32)>> = Vec::new();
280    let mut shared: FxHashMap<(u32, u32), u32> = FxHashMap::default();
281    let mut analysis = ChangeHistoryAnalysis::default();
282
283    for commit in commits {
284        let files: Vec<&crate::churn::ChurnCommitFile> = commit
285            .files
286            .iter()
287            .filter(|file| links.contains(&file.path))
288            .collect();
289        if files.is_empty() {
290            continue;
291        }
292        if files.len() > settings.max_files_per_commit {
293            analysis.commits_skipped += 1;
294            continue;
295        }
296        analysis.commits_analyzed += 1;
297        let file_count = u32::try_from(files.len()).unwrap_or(u32::MAX);
298        let lines = files
299            .iter()
300            .fold(0_u32, |sum, file| sum.saturating_add(file.lines_changed));
301        let mut ids: Vec<u32> = files
302            .iter()
303            .map(|file| {
304                *interned.entry(file.path.as_path()).or_insert_with(|| {
305                    paths.push(file.path.as_path());
306                    revisions.push(0);
307                    samples.push(Vec::new());
308                    u32::try_from(paths.len() - 1).unwrap_or(u32::MAX)
309                })
310            })
311            .collect();
312        ids.sort_unstable();
313        ids.dedup();
314        for &id in &ids {
315            revisions[id as usize] += 1;
316            samples[id as usize].push((file_count, lines));
317        }
318        for (index, &a) in ids.iter().enumerate() {
319            for &b in &ids[index + 1..] {
320                *shared.entry((a, b)).or_default() += 1;
321            }
322        }
323    }
324
325    for (id, file_samples) in samples.into_iter().enumerate() {
326        let mut file_counts: Vec<u32> = file_samples.iter().map(|(files, _)| *files).collect();
327        let mut line_counts: Vec<u32> = file_samples.iter().map(|(_, lines)| *lines).collect();
328        analysis.dispersion.insert(
329            paths[id].to_path_buf(),
330            FileDispersion {
331                median_files_per_commit: median(&mut file_counts),
332                median_lines_per_commit: median(&mut line_counts),
333            },
334        );
335    }
336
337    for ((a, b), shared_revisions) in shared {
338        let revisions_a = revisions[a as usize];
339        let revisions_b = revisions[b as usize];
340        if revisions_a < settings.min_revisions
341            || revisions_b < settings.min_revisions
342            || shared_revisions < settings.min_shared_revisions
343        {
344            continue;
345        }
346        let degree = coupling_degree(shared_revisions, revisions_a, revisions_b);
347        if degree < settings.min_degree {
348            continue;
349        }
350        let (path_a, path_b) = (paths[a as usize], paths[b as usize]);
351        if links.linked(path_a, path_b) {
352            continue;
353        }
354        let (path_a, path_b, revisions_a, revisions_b) = if path_a <= path_b {
355            (path_a, path_b, revisions_a, revisions_b)
356        } else {
357            (path_b, path_a, revisions_b, revisions_a)
358        };
359        analysis.hidden_pairs.push(CoChangePair {
360            path_a: path_a.to_path_buf(),
361            path_b: path_b.to_path_buf(),
362            revisions_a,
363            revisions_b,
364            shared_revisions,
365            degree: (degree * 100.0).round() / 100.0,
366        });
367    }
368    analysis.hidden_pairs.sort_by(|x, y| {
369        y.degree
370            .partial_cmp(&x.degree)
371            .unwrap_or(std::cmp::Ordering::Equal)
372            .then_with(|| y.shared_revisions.cmp(&x.shared_revisions))
373            .then_with(|| x.path_a.cmp(&y.path_a))
374            .then_with(|| x.path_b.cmp(&y.path_b))
375    });
376    analysis
377}
378
379/// A changed file whose hidden change-coupling partner is not in the change.
380#[derive(Debug, Clone, PartialEq)]
381pub struct CoChangeGap {
382    /// The changed file (absolute path).
383    pub changed: PathBuf,
384    /// The hidden partner that the change does not touch (absolute path).
385    pub partner: PathBuf,
386    /// Commits that changed both files.
387    pub shared_revisions: u32,
388    /// Coupling degree in `[0, 1]`.
389    pub degree: f64,
390}
391
392/// Scope filters for hidden pairs: `health.ignore`, the workspace filter, and
393/// an optional diff.
394pub struct PairScope<'a> {
395    /// Project root. The ignore globs and the diff keys are relative to it.
396    pub root: &'a Path,
397    /// Compiled `health.ignore` globs, from [`health_ignore_set`].
398    pub ignore_set: &'a globset::GlobSet,
399    /// Workspace roots of `--workspace` or `--changed-workspaces`.
400    pub ws_roots: Option<&'a [PathBuf]>,
401    /// Changed lines of the diff, when the run is diff-scoped.
402    pub diff_index: Option<&'a fallow_output::DiffIndex>,
403}
404
405impl PairScope<'_> {
406    /// Keep a pair when neither file is ignored, at least one file is in the
407    /// workspace scope, and (with a diff) at least one file is in the diff.
408    #[must_use]
409    pub fn contains(&self, path_a: &Path, path_b: &Path) -> bool {
410        let ignored = |path: &Path| {
411            !self.ignore_set.is_empty()
412                && self
413                    .ignore_set
414                    .is_match(path.strip_prefix(self.root).unwrap_or(path))
415        };
416        if ignored(path_a) || ignored(path_b) {
417            return false;
418        }
419        let in_workspace = |path: &Path| {
420            self.ws_roots
421                .is_none_or(|roots| roots.iter().any(|root| path.starts_with(root)))
422        };
423        if !in_workspace(path_a) && !in_workspace(path_b) {
424            return false;
425        }
426        let Some(diff_index) = self.diff_index else {
427            return true;
428        };
429        let in_diff = |path: &Path| match diff_index.key_for(path, self.root) {
430            Some(rel) => diff_index.touches_file(&rel),
431            None => true,
432        };
433        in_diff(path_a) || in_diff(path_b)
434    }
435}
436
437/// Compile the `health.ignore` globs that [`PairScope`] applies.
438#[must_use]
439pub fn health_ignore_set(patterns: &[String]) -> globset::GlobSet {
440    crate::health::ignore::build_ignore_set(patterns)
441}
442
443/// Hidden pairs with exactly one file in `changed`. Sorted by degree and then
444/// shared revisions (both descending), then by changed file and partner, so a
445/// cap keeps the strongest gaps.
446#[must_use]
447#[allow(
448    clippy::implicit_hasher,
449    reason = "callers pass the audit changed-file FxHashSet"
450)]
451pub fn co_change_gaps(
452    analysis: &ChangeHistoryAnalysis,
453    changed: &FxHashSet<PathBuf>,
454) -> Vec<CoChangeGap> {
455    let mut gaps: Vec<CoChangeGap> = analysis
456        .hidden_pairs
457        .iter()
458        .filter_map(|pair| {
459            let (changed_path, partner) = match (
460                changed.contains(&pair.path_a),
461                changed.contains(&pair.path_b),
462            ) {
463                (true, false) => (&pair.path_a, &pair.path_b),
464                (false, true) => (&pair.path_b, &pair.path_a),
465                _ => return None,
466            };
467            Some(CoChangeGap {
468                changed: changed_path.clone(),
469                partner: partner.clone(),
470                shared_revisions: pair.shared_revisions,
471                degree: pair.degree,
472            })
473        })
474        .collect();
475    gaps.sort_by(|a, b| {
476        b.degree
477            .partial_cmp(&a.degree)
478            .unwrap_or(std::cmp::Ordering::Equal)
479            .then_with(|| b.shared_revisions.cmp(&a.shared_revisions))
480            .then_with(|| a.changed.cmp(&b.changed))
481            .then_with(|| a.partner.cmp(&b.partner))
482    });
483    gaps
484}
485
486/// Code Maat coupling degree: shared commits over the mean revision count.
487fn coupling_degree(shared: u32, revisions_a: u32, revisions_b: u32) -> f64 {
488    let mean = f64::midpoint(f64::from(revisions_a), f64::from(revisions_b));
489    if mean <= 0.0 {
490        return 0.0;
491    }
492    (f64::from(shared) / mean).min(1.0)
493}
494
495/// Median of `values`; the mean of the two middle values for an even count.
496/// Zero for an empty slice.
497fn median(values: &mut [u32]) -> f64 {
498    if values.is_empty() {
499        return 0.0;
500    }
501    values.sort_unstable();
502    let mid = values.len() / 2;
503    if values.len() % 2 == 1 {
504        f64::from(values[mid])
505    } else {
506        f64::midpoint(f64::from(values[mid - 1]), f64::from(values[mid]))
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::churn::{ChurnCommit, ChurnCommitFile};
514
515    fn path(name: &str) -> PathBuf {
516        PathBuf::from(format!("/project/src/{name}.ts"))
517    }
518
519    fn commit(id: usize, files: &[(&str, u32)]) -> ChurnCommit {
520        ChurnCommit {
521            sha: format!("{id:040x}"),
522            committed_at: 1_700_000_000 + id as u64,
523            files: files
524                .iter()
525                .map(|(name, lines)| ChurnCommitFile {
526                    path: path(name),
527                    lines_changed: *lines,
528                })
529                .collect(),
530        }
531    }
532
533    fn index(names: &[&str]) -> StaticLinkIndex {
534        let files: Vec<PathBuf> = names.iter().map(|name| path(name)).collect();
535        StaticLinkIndex::from_links(&files, &[], &[])
536    }
537
538    /// `count` commits that change `a` and `b` together.
539    fn together(start: usize, count: usize, a: &str, b: &str) -> Vec<ChurnCommit> {
540        (start..start + count)
541            .map(|id| commit(id, &[(a, 1), (b, 1)]))
542            .collect()
543    }
544
545    #[test]
546    fn a_pair_that_meets_every_threshold_without_a_static_edge_is_hidden() {
547        let commits = together(0, 5, "a", "b");
548        let analysis = analyze_change_history(
549            &commits,
550            &index(&["a", "b"]),
551            &ChangeCouplingSettings::default(),
552        );
553
554        assert_eq!(analysis.hidden_pairs.len(), 1);
555        let pair = &analysis.hidden_pairs[0];
556        assert_eq!(
557            (pair.path_a.clone(), pair.path_b.clone()),
558            (path("a"), path("b"))
559        );
560        assert_eq!(pair.shared_revisions, 5);
561        assert!((pair.degree - 1.0).abs() < f64::EPSILON);
562        assert!(pair.is_strong());
563    }
564
565    #[test]
566    fn a_pair_below_the_shared_revision_minimum_is_not_reported() {
567        let commits = together(0, 4, "a", "b");
568        let analysis = analyze_change_history(
569            &commits,
570            &index(&["a", "b"]),
571            &ChangeCouplingSettings::default(),
572        );
573
574        assert!(analysis.hidden_pairs.is_empty());
575    }
576
577    #[test]
578    fn a_pair_below_the_degree_minimum_is_not_reported() {
579        // a: 5 shared + 30 solo = 35 revisions, b: 5. Degree 5 / 20 = 0.25.
580        let mut commits = together(0, 5, "a", "b");
581        commits.extend((100..130).map(|id| commit(id, &[("a", 1)])));
582        let analysis = analyze_change_history(
583            &commits,
584            &index(&["a", "b"]),
585            &ChangeCouplingSettings::default(),
586        );
587
588        assert!(analysis.hidden_pairs.is_empty());
589    }
590
591    #[test]
592    fn an_import_in_either_direction_hides_nothing() {
593        let commits = together(0, 6, "a", "b");
594        let files = vec![path("a"), path("b")];
595        let forward = StaticLinkIndex::from_links(&files, &[(0, 1)], &[]);
596        let backward = StaticLinkIndex::from_links(&files, &[(1, 0)], &[]);
597
598        for links in [forward, backward] {
599            let analysis =
600                analyze_change_history(&commits, &links, &ChangeCouplingSettings::default());
601            assert!(analysis.hidden_pairs.is_empty());
602        }
603    }
604
605    #[test]
606    fn a_re_export_chain_counts_as_a_static_edge() {
607        // a imports the barrel, the barrel re-exports mid, mid re-exports b.
608        let commits = together(0, 6, "a", "b");
609        let files = vec![path("a"), path("barrel"), path("mid"), path("b")];
610        let links = StaticLinkIndex::from_links(&files, &[(0, 1)], &[(1, 2), (2, 3)]);
611
612        let analysis = analyze_change_history(&commits, &links, &ChangeCouplingSettings::default());
613        assert!(analysis.hidden_pairs.is_empty());
614    }
615
616    #[test]
617    fn a_plain_import_of_an_importer_is_not_a_static_edge() {
618        // a imports mid, mid imports b: no re-export, so a and b stay hidden.
619        let commits = together(0, 6, "a", "b");
620        let files = vec![path("a"), path("mid"), path("b")];
621        let links = StaticLinkIndex::from_links(&files, &[(0, 1), (1, 2)], &[]);
622
623        let analysis = analyze_change_history(&commits, &links, &ChangeCouplingSettings::default());
624        assert_eq!(analysis.hidden_pairs.len(), 1);
625    }
626
627    #[test]
628    fn bulk_commits_are_skipped_and_counted() {
629        let mut commits = together(0, 4, "a", "b");
630        let names: Vec<String> = (0..31).map(|i| format!("f{i}")).collect();
631        let mut bulk: Vec<(&str, u32)> = names.iter().map(|name| (name.as_str(), 1)).collect();
632        bulk.push(("a", 1));
633        bulk.push(("b", 1));
634        commits.push(commit(50, &bulk));
635        let mut all: Vec<&str> = names.iter().map(String::as_str).collect();
636        all.extend(["a", "b"]);
637
638        let analysis =
639            analyze_change_history(&commits, &index(&all), &ChangeCouplingSettings::default());
640        assert_eq!(analysis.commits_skipped, 1);
641        assert_eq!(analysis.commits_analyzed, 4);
642        assert!(
643            analysis.hidden_pairs.is_empty(),
644            "the bulk commit must not lift the pair to 5 shared revisions"
645        );
646    }
647
648    #[test]
649    fn files_outside_the_analyzed_set_do_not_count() {
650        // The lockfile is not analyzed: it neither pairs nor raises dispersion.
651        let commits: Vec<ChurnCommit> = (0..5)
652            .map(|id| commit(id, &[("a", 2), ("b", 3), ("lock", 500)]))
653            .collect();
654        let analysis = analyze_change_history(
655            &commits,
656            &index(&["a", "b"]),
657            &ChangeCouplingSettings::default(),
658        );
659
660        assert_eq!(analysis.hidden_pairs.len(), 1);
661        let dispersion = analysis.dispersion[&path("a")];
662        assert!((dispersion.median_files_per_commit - 2.0).abs() < f64::EPSILON);
663        assert!((dispersion.median_lines_per_commit - 5.0).abs() < f64::EPSILON);
664        assert!(!analysis.dispersion.contains_key(&path("lock")));
665    }
666
667    #[test]
668    fn dispersion_is_the_median_over_the_commits_that_touch_the_file() {
669        let commits = vec![
670            commit(0, &[("a", 1)]),
671            commit(1, &[("a", 1), ("b", 1), ("c", 1)]),
672            commit(2, &[("a", 10), ("b", 10)]),
673            commit(3, &[("a", 1), ("b", 1), ("c", 1), ("d", 1)]),
674        ];
675        let analysis = analyze_change_history(
676            &commits,
677            &index(&["a", "b", "c", "d"]),
678            &ChangeCouplingSettings::default(),
679        );
680
681        let a = analysis.dispersion[&path("a")];
682        // File counts 1, 3, 2, 4: median 2.5. Line counts 1, 3, 20, 4: median 3.5.
683        assert!((a.median_files_per_commit - 2.5).abs() < f64::EPSILON);
684        assert!((a.median_lines_per_commit - 3.5).abs() < f64::EPSILON);
685        let d = analysis.dispersion[&path("d")];
686        assert!((d.median_files_per_commit - 4.0).abs() < f64::EPSILON);
687    }
688
689    #[test]
690    fn custom_settings_lower_the_thresholds() {
691        let commits = together(0, 2, "a", "b");
692        let settings = ChangeCouplingSettings {
693            max_files_per_commit: 30,
694            min_revisions: 2,
695            min_shared_revisions: 2,
696            min_degree: 0.3,
697        };
698        let analysis = analyze_change_history(&commits, &index(&["a", "b"]), &settings);
699        assert_eq!(analysis.hidden_pairs.len(), 1);
700    }
701
702    #[test]
703    fn a_gap_needs_exactly_one_side_of_a_hidden_pair_in_the_change() {
704        let mut commits = together(0, 5, "a", "b");
705        commits.extend(together(10, 5, "c", "d"));
706        let analysis = analyze_change_history(
707            &commits,
708            &index(&["a", "b", "c", "d"]),
709            &ChangeCouplingSettings::default(),
710        );
711        let changed: FxHashSet<PathBuf> = [path("a"), path("c"), path("d")].into_iter().collect();
712
713        let gaps = co_change_gaps(&analysis, &changed);
714        assert_eq!(
715            gaps.len(),
716            1,
717            "c and d changed together, so only a misses b"
718        );
719        assert_eq!(gaps[0].changed, path("a"));
720        assert_eq!(gaps[0].partner, path("b"));
721        assert_eq!(gaps[0].shared_revisions, 5);
722    }
723
724    #[test]
725    fn pairs_sort_by_degree_then_shared_revisions_then_path() {
726        let mut commits = together(0, 6, "c", "d");
727        commits.extend(together(10, 5, "a", "b"));
728        commits.extend((20..25).map(|id| commit(id, &[("e", 1), ("f", 1)])));
729        commits.extend((30..35).map(|id| commit(id, &[("e", 1)])));
730        let analysis = analyze_change_history(
731            &commits,
732            &index(&["a", "b", "c", "d", "e", "f"]),
733            &ChangeCouplingSettings::default(),
734        );
735
736        let order: Vec<PathBuf> = analysis
737            .hidden_pairs
738            .iter()
739            .map(|pair| pair.path_a.clone())
740            .collect();
741        assert_eq!(order, vec![path("c"), path("a"), path("e")]);
742        assert!((analysis.hidden_pairs[2].degree - 0.67).abs() < f64::EPSILON);
743    }
744}