cha_core/plugins/
divergent_change.rs1use std::collections::HashMap;
2use std::process::Command;
3use std::sync::OnceLock;
4
5use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
6
7pub struct DivergentChangeAnalyzer {
10 pub min_distinct_reasons: usize,
11 pub max_commits: usize,
12}
13
14impl Default for DivergentChangeAnalyzer {
15 fn default() -> Self {
16 Self {
17 min_distinct_reasons: 4,
18 max_commits: 50,
19 }
20 }
21}
22
23static REASON_CACHE: OnceLock<HashMap<String, usize>> = OnceLock::new();
25
26fn build_reason_cache(max_commits: usize) -> HashMap<String, usize> {
27 let output = Command::new("git")
28 .args([
29 "log",
30 "--format=%s",
31 "--name-only",
32 &format!("-{max_commits}"),
33 ])
34 .output();
35 let text = match output {
36 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
37 _ => return HashMap::new(),
38 };
39
40 let mut file_scopes: HashMap<String, HashMap<String, usize>> = HashMap::new();
42 let mut current_scope = String::new();
43 let mut in_files = false;
44
45 for line in text.lines() {
46 let line = line.trim();
47 if line.is_empty() {
48 in_files = false;
49 continue;
50 }
51 if !in_files {
52 current_scope = extract_scope(line);
53 in_files = true;
54 } else {
55 *file_scopes
56 .entry(line.to_string())
57 .or_default()
58 .entry(current_scope.clone())
59 .or_default() += 1;
60 }
61 }
62
63 file_scopes
64 .into_iter()
65 .map(|(file, scopes)| (file, scopes.len()))
66 .collect()
67}
68
69impl Plugin for DivergentChangeAnalyzer {
70 fn name(&self) -> &str {
71 "divergent_change"
72 }
73
74 fn description(&self) -> &str {
75 "File changed for many different reasons"
76 }
77
78 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
79 let cache = REASON_CACHE.get_or_init(|| build_reason_cache(self.max_commits));
80 let path_str = ctx.file.path.to_string_lossy();
81 let reasons = cache.get(path_str.as_ref()).copied().unwrap_or(0);
82 if reasons < self.min_distinct_reasons {
83 return vec![];
84 }
85 vec![Finding {
86 smell_name: "divergent_change".into(),
87 category: SmellCategory::ChangePreventers,
88 severity: Severity::Hint,
89 location: Location {
90 path: ctx.file.path.clone(),
91 start_line: 1,
92 end_line: ctx.model.total_lines,
93 name: None,
94 },
95 message: format!(
96 "`{}` was changed for ~{} distinct reasons in last {} commits, consider Extract Class",
97 path_str, reasons, self.max_commits
98 ),
99 suggested_refactorings: vec!["Extract Class".into()],
100 actual_value: Some(reasons as f64),
101 threshold: Some(self.min_distinct_reasons as f64),
102 }]
103 }
104}
105
106fn extract_scope(msg: &str) -> String {
107 if let Some(start) = msg.find('(')
109 && let Some(end) = msg[start..].find(')')
110 {
111 return msg[start + 1..start + end].to_lowercase();
112 }
113 msg.split_whitespace()
115 .next()
116 .unwrap_or("unknown")
117 .to_lowercase()
118}