Skip to main content

cha_core/plugins/
divergent_change.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 changed for many different reasons (divergent change).
8/// Uses git log to count distinct change "clusters" by commit message keywords.
9pub 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
23/// Cached per-file distinct-reason counts: built once from a single `git log` call.
24static 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    // Parse: alternating subject line + file list, separated by blank lines
41    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    // Try conventional commit: "type(scope): ..."
114    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    // Fallback: first word
120    msg.split_whitespace()
121        .next()
122        .unwrap_or("unknown")
123        .to_lowercase()
124}