use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use cha_core::Finding;
pub fn detect_unstable_deps(
files: &[PathBuf],
cwd: &Path,
cache: &std::sync::Mutex<cha_core::ProjectCache>,
) -> Vec<Finding> {
let file_imports = build_file_imports(files, cwd, cache);
let known: HashSet<&str> = file_imports.keys().map(|s| s.as_str()).collect();
let ca = compute_afferent(&file_imports, &known);
let instability = |file: &str| -> f64 {
let ce = file_imports.get(file).map(|v| v.len()).unwrap_or(0) as f64;
let ca_val = ca.get(file).copied().unwrap_or(0) as f64;
if ce + ca_val == 0.0 {
0.0
} else {
ce / (ca_val + ce)
}
};
let mut name_to_path: HashMap<&str, &str> = HashMap::new();
for &k in &known {
let basename = k.rsplit('/').next().unwrap_or(k);
name_to_path.entry(basename).or_insert(k);
}
let resolve = |imp: &str| -> Option<&str> {
let basename = imp.rsplit('/').next().unwrap_or(imp);
name_to_path
.get(basename)
.copied()
.or_else(|| known.get(imp).copied())
};
file_imports
.iter()
.filter_map(|(file, imports)| {
let my_i = instability(file);
let (target, ti) = imports.iter().find_map(|imp| {
let t = resolve(imp)?;
let ti = instability(t);
(my_i < ti && (ti - my_i) > 0.2).then_some((t, ti))
})?;
Some(make_unstable_finding(file, my_i, target, ti))
})
.collect()
}
fn make_unstable_finding(file: &str, my_i: f64, target: &str, ti: f64) -> Finding {
Finding {
smell_name: "unstable_dependency".into(),
category: cha_core::SmellCategory::Couplers,
severity: cha_core::Severity::Hint,
location: cha_core::Location {
path: PathBuf::from(file),
start_line: 1,
end_line: 1,
name: None,
..Default::default()
},
message: format!(
"`{file}` (I={my_i:.2}) depends on `{target}` (I={ti:.2}) which is less stable"
),
suggested_refactorings: vec![
"Depend on abstractions".into(),
"Stable Dependencies Principle".into(),
],
..Default::default()
}
}
fn build_file_imports(
files: &[PathBuf],
cwd: &Path,
cache: &std::sync::Mutex<cha_core::ProjectCache>,
) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
let mut c = match cache.lock() {
Ok(c) => c,
Err(_) => return map,
};
for path in files {
let rel = path
.strip_prefix(cwd)
.unwrap_or(path)
.to_string_lossy()
.to_string();
if let Some(imports) = c.get_imports(&rel)
&& !imports.is_empty()
{
map.insert(rel, imports.to_vec());
continue;
}
if let Some((rel, model)) = crate::cached_parse(path, &mut c, cwd) {
map.insert(
rel,
model.imports.iter().map(|i| i.source.clone()).collect(),
);
}
}
map
}
fn compute_afferent<'a>(
file_imports: &'a HashMap<String, Vec<String>>,
known: &HashSet<&'a str>,
) -> HashMap<&'a str, usize> {
let mut name_to_path: HashMap<&str, &str> = HashMap::new();
for &k in known {
let basename = k.rsplit('/').next().unwrap_or(k);
name_to_path.entry(basename).or_insert(k);
}
let mut ca = HashMap::new();
for imports in file_imports.values() {
for imp in imports {
let basename = imp.rsplit('/').next().unwrap_or(imp.as_str());
if let Some(&k) = name_to_path.get(basename) {
*ca.entry(k).or_default() += 1;
} else if let Some(&k) = known.get(imp.as_str()) {
*ca.entry(k).or_default() += 1;
}
}
}
ca
}
pub fn detect_bus_factor(files: &[PathBuf], cwd: &Path) -> Vec<Finding> {
let file_authors = git_file_authors();
files
.iter()
.filter_map(|path| {
let rel = path.strip_prefix(cwd).unwrap_or(path);
let authors = file_authors.get(rel.to_str()?)?;
(authors.len() == 1 && path.metadata().map(|m| m.len() > 500).unwrap_or(false)).then(
|| Finding {
smell_name: "bus_factor".into(),
category: cha_core::SmellCategory::ChangePreventers,
severity: cha_core::Severity::Hint,
location: cha_core::Location {
path: rel.to_path_buf(),
start_line: 1,
end_line: 1,
name: None,
..Default::default()
},
message: format!(
"`{}` has only 1 contributor — bus factor risk",
rel.display()
),
suggested_refactorings: vec!["Pair programming".into(), "Code review".into()],
..Default::default()
},
)
})
.collect()
}
fn git_file_authors() -> HashMap<String, HashSet<String>> {
let output = std::process::Command::new("git")
.args(["log", "--format=%aN", "-n", "200", "--name-only"])
.output()
.ok();
let Some(output) = output else {
return Default::default();
};
let text = String::from_utf8_lossy(&output.stdout);
let mut file_authors: HashMap<String, HashSet<String>> = HashMap::new();
let mut current_author = String::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.contains('/') && !line.contains('.') {
current_author = line.to_string();
} else if !current_author.is_empty() {
file_authors
.entry(line.to_string())
.or_default()
.insert(current_author.clone());
}
}
file_authors
}
pub fn check_test_ratio(files: &[PathBuf]) -> Vec<Finding> {
let (mut test_lines, mut prod_lines) = (0usize, 0usize);
for f in files {
let lines = std::fs::read_to_string(f)
.map(|c| c.lines().count())
.unwrap_or(0);
if f.to_string_lossy().contains("test") || f.to_string_lossy().contains("spec") {
test_lines += lines;
} else {
prod_lines += lines;
}
}
if prod_lines == 0 || (test_lines as f64 / prod_lines as f64) >= 0.5 {
return vec![];
}
let ratio = test_lines as f64 / prod_lines as f64;
vec![Finding {
smell_name: "low_test_ratio".into(),
category: cha_core::SmellCategory::Dispensables,
severity: cha_core::Severity::Hint,
location: cha_core::Location {
path: PathBuf::from("."),
start_line: 1,
end_line: 1,
name: None,
..Default::default()
},
message: format!(
"Test-to-code ratio is {:.0}% ({test_lines} test / {prod_lines} production lines)",
ratio * 100.0
),
suggested_refactorings: vec!["Add unit tests".into()],
..Default::default()
}]
}