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 smells(&self) -> Vec<String> {
75 vec!["divergent_change".into()]
76 }
77
78 fn description(&self) -> &str {
79 "File changed for many different reasons"
80 }
81
82 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
83 let cache = REASON_CACHE.get_or_init(|| build_reason_cache(self.max_commits));
84 let path_str = ctx.file.path.to_string_lossy();
85 let reasons = cache.get(path_str.as_ref()).copied().unwrap_or(0);
86 if reasons < self.min_distinct_reasons {
87 return vec![];
88 }
89 vec![Finding {
90 smell_name: "divergent_change".into(),
91 category: SmellCategory::ChangePreventers,
92 severity: Severity::Hint,
93 location: Location {
94 path: ctx.file.path.clone(),
95 start_line: 1,
96 end_line: 1,
97 name: None,
98 ..Default::default()
99 },
100 message: format!(
101 "`{}` was changed for ~{} distinct reasons in last {} commits, consider Extract Class",
102 path_str, reasons, self.max_commits
103 ),
104 suggested_refactorings: vec!["Extract Class".into()],
105 actual_value: Some(reasons as f64),
106 threshold: Some(self.min_distinct_reasons as f64),
107 risk_score: None,
108 }]
109 }
110}
111
112fn extract_scope(msg: &str) -> String {
113 if let Some(start) = msg.find('(')
115 && let Some(end) = msg[start..].find(')')
116 {
117 return msg[start + 1..start + end].to_lowercase();
118 }
119 msg.split_whitespace()
121 .next()
122 .unwrap_or("unknown")
123 .to_lowercase()
124}