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 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    // Try conventional commit: "type(scope): ..."
108    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    // Fallback: first word
114    msg.split_whitespace()
115        .next()
116        .unwrap_or("unknown")
117        .to_lowercase()
118}