Skip to main content

cha_core/plugins/
shotgun_surgery.rs

1use std::collections::HashMap;
2use std::process::Command;
3use std::sync::OnceLock;
4
5use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
6
7/// Detect files that always change together (shotgun surgery).
8/// Uses git log to find co-change patterns.
9pub 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
23/// Cached co-change data: built once from a single `git log` call.
24static 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}