use crate::detectors::base::{Detector, DetectorConfig, DetectorScope};
use crate::models::{Finding, Severity};
use anyhow::Result;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::debug;
pub struct HiddenCouplingDetector {
config: DetectorConfig,
}
detector_constructors! {
HiddenCouplingDetector { }
}
impl Detector for HiddenCouplingDetector {
fn name(&self) -> &'static str {
"HiddenCouplingDetector"
}
fn description(&self) -> &'static str {
"Detects file pairs that co-change frequently but have no structural dependency"
}
fn category(&self) -> &'static str {
"architecture"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
fn detector_scope(&self) -> DetectorScope {
DetectorScope::GraphWide
}
fn is_deterministic(&self) -> bool {
true
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let graph = ctx.graph;
let gi = graph.interner();
let pairs = &graph.primitives().hidden_coupling;
if pairs.is_empty() {
return Ok(vec![]);
}
debug!(
"HiddenCouplingDetector: examining {} hidden coupling pairs",
pairs.len()
);
let mut findings = Vec::new();
let min_weight: f32 = self.config.get_option_or("min_weight", 1.0);
for &(file_a_idx, file_b_idx, weight, lift, confidence) in pairs {
if weight < min_weight {
continue;
}
let score = confidence * lift * weight.sqrt();
let severity = if score >= 8.0 {
Severity::High
} else if score >= 4.0 {
Severity::Medium
} else {
Severity::Low
};
let file_a = graph
.node_idx(file_a_idx)
.map(|n| n.path(gi).to_string())
.unwrap_or_default();
let file_b = graph
.node_idx(file_b_idx)
.map(|n| n.path(gi).to_string())
.unwrap_or_default();
let dir_a = file_a.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
let dir_b = file_b.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
if dir_a == dir_b {
continue;
}
fn is_test_file(path: &str) -> bool {
let lower = path.to_lowercase();
lower.contains("/test")
|| lower.contains("test_")
|| lower.contains("_test.")
|| lower.contains("/tests/")
|| lower.contains("/spec/")
|| lower.contains(".test.")
}
if is_test_file(&file_a) || is_test_file(&file_b) {
continue;
}
findings.push(Finding {
id: String::new(),
detector: "hidden-coupling".to_string(),
severity,
confidence: Some(confidence.min(0.95) as f64),
deterministic: true,
title: format!(
"Hidden coupling: {} and {} (confidence: {:.0}%, lift: {:.1}x)",
file_a.rsplit('/').next().unwrap_or(&file_a),
file_b.rsplit('/').next().unwrap_or(&file_b),
confidence * 100.0,
lift
),
description: format!(
"Hidden coupling detected: `{}` and `{}` co-change {:.0}% of the time \
({:.1}x more than expected by chance). They have no import or call \
relationship. Consider adding an explicit dependency or extracting shared logic.",
file_a, file_b, confidence * 100.0, lift
),
affected_files: vec![PathBuf::from(&file_a), PathBuf::from(&file_b)],
suggested_fix: Some(
"Consider one of: (1) Add an explicit import if there's a real dependency, \
(2) Extract shared logic into a common module, \
(3) Document the implicit coupling if it's intentional."
.to_string(),
),
category: Some("architecture".to_string()),
why_it_matters: Some(
"Files that always change together but have no static dependency create \
'change A, forget to change B' risks. These hidden couplings are invisible \
to static analysis and IDE navigation."
.to_string(),
),
..Default::default()
});
}
findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
debug!("HiddenCouplingDetector found {} findings", findings.len());
Ok(findings)
}
}
impl crate::detectors::RegisteredDetector for HiddenCouplingDetector {
fn create(init: &crate::detectors::DetectorInit) -> Arc<dyn Detector> {
Arc::new(Self::with_config(init.config_for("HiddenCouplingDetector")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{CodeEdge, CodeNode, GraphBuilder};
#[test]
fn test_detects_hidden_coupling() {
let mut builder = GraphBuilder::new();
let f1 = builder.add_node(CodeNode::function("handler", "src/api/routes.rs"));
let f2 = builder.add_node(CodeNode::function("model_update", "src/db/models.rs"));
let file_api = builder.add_node(CodeNode::file("src/api/routes.rs"));
let file_db = builder.add_node(CodeNode::file("src/db/models.rs"));
builder.add_edge(file_api, f1, CodeEdge::contains());
builder.add_edge(file_db, f2, CodeEdge::contains());
let f3 = builder.add_node(CodeNode::function("helper_a", "src/util/helpers_a.rs"));
let file_util_a = builder.add_node(CodeNode::file("src/util/helpers_a.rs"));
builder.add_edge(file_util_a, f3, CodeEdge::contains());
builder.add_edge(f1, f3, CodeEdge::calls());
let f4 = builder.add_node(CodeNode::function("helper_b", "src/util/helpers_b.rs"));
let file_util_b = builder.add_node(CodeNode::file("src/util/helpers_b.rs"));
builder.add_edge(file_util_b, f4, CodeEdge::contains());
builder.add_edge(f2, f4, CodeEdge::calls());
let now = chrono::Utc::now();
let config = crate::git::co_change::CoChangeConfig {
min_weight: 0.01,
..Default::default()
};
let commits = vec![
(
now,
vec![
"src/api/routes.rs".to_string(),
"src/db/models.rs".to_string(),
],
),
(
now,
vec![
"src/api/routes.rs".to_string(),
"src/db/models.rs".to_string(),
],
),
(
now,
vec![
"src/api/routes.rs".to_string(),
"src/db/models.rs".to_string(),
],
),
(
now,
vec![
"src/unrelated/alpha.rs".to_string(),
"src/unrelated/beta.rs".to_string(),
],
),
(
now,
vec![
"src/unrelated/alpha.rs".to_string(),
"src/unrelated/beta.rs".to_string(),
],
),
(
now,
vec![
"src/other/foo.rs".to_string(),
"src/other/bar.rs".to_string(),
],
),
(
now,
vec![
"src/other/foo.rs".to_string(),
"src/other/bar.rs".to_string(),
],
),
(now, vec!["src/other/baz.rs".to_string()]),
(now, vec!["src/other/qux.rs".to_string()]),
(now, vec!["src/other/quux.rs".to_string()]),
(now, vec!["src/other/corge.rs".to_string()]),
(now, vec!["src/other/grault.rs".to_string()]),
(now, vec!["src/other/garply.rs".to_string()]),
];
let co_change = crate::git::co_change::CoChangeMatrix::from_commits(&commits, &config, now);
let graph = builder.freeze_with_co_change(&co_change);
let detector = HiddenCouplingDetector::new();
let ctx = crate::detectors::analysis_context::AnalysisContext::test(&graph);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect hidden coupling between routes.rs and models.rs"
);
assert_eq!(findings[0].detector, "hidden-coupling");
assert!(
findings[0].description.contains("routes.rs"),
"Should mention routes.rs: {}",
findings[0].description
);
assert!(
findings[0].description.contains("models.rs"),
"Should mention models.rs: {}",
findings[0].description
);
assert!(
findings[0].title.contains("confidence:"),
"Title should show confidence: {}",
findings[0].title
);
assert!(
findings[0].title.contains("lift:"),
"Title should show lift: {}",
findings[0].title
);
assert!(
findings[0].description.contains("more than expected"),
"Description should reference statistical surprise: {}",
findings[0].description
);
}
#[test]
fn test_no_findings_when_empty() {
let mut builder = GraphBuilder::new();
let f1 = builder.add_node(CodeNode::function("f1", "a.py"));
let f2 = builder.add_node(CodeNode::function("f2", "b.py"));
builder.add_edge(f1, f2, CodeEdge::calls());
let graph = builder.freeze();
let detector = HiddenCouplingDetector::new();
let ctx = crate::detectors::analysis_context::AnalysisContext::test(&graph);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should have no findings without co-change data"
);
}
#[test]
fn test_severity_thresholds() {
let detector = HiddenCouplingDetector::new();
let builder = GraphBuilder::new();
let graph = builder.freeze();
let ctx = crate::detectors::analysis_context::AnalysisContext::test(&graph);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(findings.is_empty());
}
#[test]
fn test_scope_is_graph_wide() {
let detector = HiddenCouplingDetector::new();
assert_eq!(detector.detector_scope(), DetectorScope::GraphWide);
}
#[test]
fn test_category_is_architecture() {
let detector = HiddenCouplingDetector::new();
assert_eq!(detector.category(), "architecture");
}
#[test]
fn test_is_deterministic() {
let detector = HiddenCouplingDetector::new();
assert!(detector.is_deterministic());
}
}