1use std::path::{Path, PathBuf};
14
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use crate::churn::ChurnCommit;
18
19pub const DEFAULT_MAX_FILES_PER_COMMIT: usize = 30;
23pub const DEFAULT_MIN_REVISIONS: u32 = 5;
25pub const DEFAULT_MIN_SHARED_REVISIONS: u32 = 5;
27pub const DEFAULT_MIN_DEGREE: f64 = 0.3;
29pub const STRONG_COUPLING_DEGREE: f64 = 0.5;
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ChangeCouplingSettings {
36 pub max_files_per_commit: usize,
38 pub min_revisions: u32,
40 pub min_shared_revisions: u32,
42 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#[derive(Debug, Clone, PartialEq)]
70pub struct CoChangePair {
71 pub path_a: PathBuf,
73 pub path_b: PathBuf,
75 pub revisions_a: u32,
77 pub revisions_b: u32,
79 pub shared_revisions: u32,
81 pub degree: f64,
84}
85
86impl CoChangePair {
87 #[must_use]
89 pub fn is_strong(&self) -> bool {
90 self.degree >= STRONG_COUPLING_DEGREE
91 }
92
93 #[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#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct FileDispersion {
109 pub median_files_per_commit: f64,
111 pub median_lines_per_commit: f64,
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct ChangeHistoryAnalysis {
119 pub commits_analyzed: usize,
121 pub commits_skipped: usize,
123 pub dispersion: FxHashMap<PathBuf, FileDispersion>,
125 pub hidden_pairs: Vec<CoChangePair>,
128}
129
130impl ChangeHistoryAnalysis {
131 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#[derive(Debug, Clone, Default)]
147pub struct StaticLinkIndex {
148 ids: FxHashMap<PathBuf, u32>,
149 targets: Vec<Vec<u32>>,
151 re_export_sources: Vec<Vec<u32>>,
153}
154
155impl StaticLinkIndex {
156 #[must_use]
158 pub fn from_retained_graph(graph: &crate::module_graph::RetainedModuleGraph) -> Self {
159 Self::from_graph(graph.as_graph())
160 }
161
162 #[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 #[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 #[must_use]
229 pub fn contains(&self, path: &Path) -> bool {
230 self.ids.contains_key(path)
231 }
232
233 #[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 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#[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#[derive(Debug, Clone, PartialEq)]
381pub struct CoChangeGap {
382 pub changed: PathBuf,
384 pub partner: PathBuf,
386 pub shared_revisions: u32,
388 pub degree: f64,
390}
391
392pub struct PairScope<'a> {
395 pub root: &'a Path,
397 pub ignore_set: &'a globset::GlobSet,
399 pub ws_roots: Option<&'a [PathBuf]>,
401 pub diff_index: Option<&'a fallow_output::DiffIndex>,
403}
404
405impl PairScope<'_> {
406 #[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#[must_use]
439pub fn health_ignore_set(patterns: &[String]) -> globset::GlobSet {
440 crate::health::ignore::build_ignore_set(patterns)
441}
442
443#[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
486fn 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
495fn 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 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 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 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 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 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 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}