cha_core/plugins/
shotgun_surgery.rs1use std::collections::HashMap;
2use std::process::Command;
3use std::sync::OnceLock;
4
5use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
6
7pub struct ShotgunSurgeryAnalyzer {
10 pub min_co_changes: usize,
11 pub max_commits: usize,
12}
13
14impl Default for ShotgunSurgeryAnalyzer {
15 fn default() -> Self {
16 Self {
17 min_co_changes: 5,
18 max_commits: 100,
19 }
20 }
21}
22
23static CO_CHANGE_CACHE: OnceLock<HashMap<String, Vec<(String, usize)>>> = OnceLock::new();
25
26fn build_co_change_cache(max_commits: usize) -> HashMap<String, Vec<(String, usize)>> {
27 let commits = parse_commit_file_groups(max_commits);
28 let mut per_file: HashMap<String, HashMap<String, usize>> = HashMap::new();
29 for files in &commits {
30 for f in files {
31 for other in files {
32 if f != other {
33 *per_file
34 .entry(f.clone())
35 .or_default()
36 .entry(other.clone())
37 .or_default() += 1;
38 }
39 }
40 }
41 }
42 per_file
43 .into_iter()
44 .map(|(file, counts)| {
45 let mut top: Vec<_> = counts.into_iter().collect();
46 top.sort_by_key(|a| std::cmp::Reverse(a.1));
47 top.truncate(3);
48 (file, top)
49 })
50 .collect()
51}
52
53fn parse_commit_file_groups(max_commits: usize) -> Vec<Vec<String>> {
54 let output = Command::new("git")
55 .args([
56 "log",
57 "--pretty=format:",
58 "--name-only",
59 &format!("-{max_commits}"),
60 ])
61 .output();
62 let text = match output {
63 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
64 _ => return Vec::new(),
65 };
66 let mut commits = Vec::new();
67 let mut current: Vec<String> = Vec::new();
68 for line in text.lines() {
69 let line = line.trim();
70 if line.is_empty() {
71 if !current.is_empty() {
72 commits.push(std::mem::take(&mut current));
73 }
74 } else {
75 current.push(line.to_string());
76 }
77 }
78 if !current.is_empty() {
79 commits.push(current);
80 }
81 commits
82}
83
84impl Plugin for ShotgunSurgeryAnalyzer {
85 fn name(&self) -> &str {
86 "shotgun_surgery"
87 }
88
89 fn description(&self) -> &str {
90 "Files that always change together"
91 }
92
93 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
94 let cache = CO_CHANGE_CACHE.get_or_init(|| build_co_change_cache(self.max_commits));
95 let path_str = ctx.file.path.to_string_lossy();
96 let co_changes = match cache.get(path_str.as_ref()) {
97 Some(v) => v,
98 None => return vec![],
99 };
100 co_changes
101 .iter()
102 .filter(|(_, count)| *count >= self.min_co_changes)
103 .map(|(other, count)| Finding {
104 smell_name: "shotgun_surgery".into(),
105 category: SmellCategory::ChangePreventers,
106 severity: Severity::Hint,
107 location: Location {
108 path: ctx.file.path.clone(),
109 start_line: 1,
110 end_line: ctx.model.total_lines,
111 name: None,
112 },
113 message: format!(
114 "`{}` changed together with `{}` in {} commits, consider Move Method/Field",
115 path_str, other, count
116 ),
117 suggested_refactorings: vec!["Move Method".into(), "Move Field".into()],
118 actual_value: Some(*count as f64),
119 threshold: Some(self.min_co_changes as f64),
120 })
121 .collect()
122 }
123}