use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::{debug, info};
static CHAIN_PATTERN: OnceLock<Regex> = OnceLock::new();
fn chain_pattern() -> &'static Regex {
CHAIN_PATTERN.get_or_init(|| {
Regex::new(r"(\.[a-zA-Z_][a-zA-Z0-9_]*\s*\([^)]*\)){4,}").expect("valid regex")
})
}
#[derive(Debug, Clone)]
pub struct MessageChainThresholds {
pub min_chain_depth: usize,
pub high_severity_depth: usize,
}
impl Default for MessageChainThresholds {
fn default() -> Self {
Self {
min_chain_depth: 5, high_severity_depth: 8, }
}
}
const EXCLUDE_PATTERNS: &[&str] = &[
"builder", "with_", "set_", "add_", "and_", "or_", "filter", "map", "reduce", "collect",
"iter", "select", "where", "order_by", "group_by", "join", "expect", "unwrap", "ok", "err",
"and_then",
];
pub struct MessageChainDetector {
config: DetectorConfig,
thresholds: MessageChainThresholds,
repository_path: PathBuf,
}
impl MessageChainDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self {
config: DetectorConfig::new(),
thresholds: MessageChainThresholds::default(),
repository_path: repository_path.into(),
}
}
#[allow(dead_code)] pub fn with_config(config: DetectorConfig, repository_path: impl Into<PathBuf>) -> Self {
let thresholds = MessageChainThresholds {
min_chain_depth: config.get_option_or("min_chain_depth", 4),
high_severity_depth: config.get_option_or("high_severity_depth", 6),
};
Self {
config,
thresholds,
repository_path: repository_path.into(),
}
}
fn is_fluent_pattern(&self, chain: &str) -> bool {
let lower = chain.to_lowercase();
EXCLUDE_PATTERNS.iter().any(|p| lower.contains(p))
}
fn count_chain_depth(&self, chain: &str) -> usize {
chain.matches(").").count() + 1
}
fn calculate_severity(&self, depth: usize) -> Severity {
if depth >= self.thresholds.high_severity_depth {
Severity::High
} else {
Severity::Medium
}
}
fn scan_source_files(&self) -> Vec<Finding> {
let mut findings = Vec::new();
let mut seen: HashSet<(String, u32)> = HashSet::new();
let walker = ignore::WalkBuilder::new(&self.repository_path)
.hidden(false)
.git_ignore(true)
.build();
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "py" | "js" | "ts" | "java" | "go" | "rs" | "rb") {
continue;
}
let path_str = path.to_string_lossy();
if path_str.contains("/test") || path_str.contains("_test.") {
continue;
}
if crate::detectors::content_classifier::is_non_production_path(&path_str) {
continue;
}
let rel_path = path
.strip_prefix(&self.repository_path)
.unwrap_or(path)
.to_path_buf();
if let Some(content) = crate::cache::global_cache().get_content(path) {
for (i, line) in content.lines().enumerate() {
let line_num = (i + 1) as u32;
let trimmed = line.trim();
if trimmed.starts_with("//")
|| trimmed.starts_with("#")
|| trimmed.starts_with("*")
{
continue;
}
if let Some(m) = chain_pattern().find(line) {
let chain = m.as_str();
if self.is_fluent_pattern(chain) {
continue;
}
let depth = self.count_chain_depth(chain);
if depth < self.thresholds.min_chain_depth {
continue;
}
let key = (rel_path.to_string_lossy().to_string(), line_num);
if seen.contains(&key) {
continue;
}
seen.insert(key);
let severity = self.calculate_severity(depth);
findings.push(Finding {
id: String::new(),
detector: "MessageChainDetector".to_string(),
severity,
title: format!("Law of Demeter violation: {}-level chain", depth),
description: format!(
"Method chain with **{} levels** found:\n```\n{}\n```\n\n\
This violates the Law of Demeter by coupling to internal object structure.",
depth, chain.trim()
),
affected_files: vec![rel_path.clone()],
line_start: Some(line_num),
line_end: Some(line_num),
suggested_fix: Some(
"Options:\n\
1. Add a delegate method on the first object\n\
2. Use Tell, Don't Ask - have the object do the work\n\
3. Create a Facade to hide the chain"
.to_string()
),
estimated_effort: Some("Small (30 min)".to_string()),
category: Some("coupling".to_string()),
cwe_id: None,
why_it_matters: Some(
"Long method chains couple your code to internal object structure. \
Changes to intermediate objects break the chain."
.to_string()
),
..Default::default()
});
}
}
}
}
findings
}
fn find_delegation_chains(&self, graph: &dyn crate::graph::GraphQuery) -> Vec<Finding> {
let mut findings = Vec::new();
let mut reported_in_chain: HashSet<String> = HashSet::new();
for func in graph.get_functions() {
if reported_in_chain.contains(&func.qualified_name) {
continue;
}
let callees = graph.get_callees(&func.qualified_name);
let callers = graph.get_callers(&func.qualified_name);
let is_chain_head = callers.len() != 1 && callees.len() == 1;
if !is_chain_head {
continue;
}
let complexity = func.complexity().unwrap_or(1);
if complexity > 3 {
continue; }
let (chain_depth, chain_members) =
self.trace_chain_with_members(graph, &func.qualified_name, 0);
if chain_depth < self.thresholds.min_chain_depth as i32 {
continue;
}
if self.is_trait_delegation_chain(&chain_members) {
debug!(
"Skipping trait delegation chain starting at {} ({} levels, same-name forwarding)",
func.name, chain_depth
);
for member in &chain_members {
reported_in_chain.insert(member.clone());
}
continue;
}
let all_funcs = graph.get_functions();
let files_in_chain: HashSet<String> = chain_members
.iter()
.filter_map(|qn| {
all_funcs
.iter()
.find(|f| f.qualified_name == *qn)
.map(|f| f.file_path.clone())
})
.collect();
if files_in_chain.len() <= 1 {
continue; }
for member in &chain_members {
reported_in_chain.insert(member.clone());
}
let severity = if chain_depth >= self.thresholds.high_severity_depth as i32 {
Severity::Medium
} else {
Severity::Low
};
findings.push(Finding {
id: String::new(),
detector: "MessageChainDetector".to_string(),
severity,
title: format!("Delegation chain: {} starts a {}-level chain", func.name, chain_depth),
description: format!(
"Function '{}' is the entry point of a {}-level delegation chain across {} files.\n\n\
Each function in the chain just delegates to the next with minimal logic. \
Consider collapsing intermediate layers.",
func.name, chain_depth, files_in_chain.len()
),
affected_files: vec![func.file_path.clone().into()],
line_start: Some(func.line_start),
line_end: Some(func.line_end),
suggested_fix: Some("Consider collapsing the delegation chain or using direct access".to_string()),
estimated_effort: Some("Medium (1-2 hours)".to_string()),
category: Some("coupling".to_string()),
cwe_id: None,
why_it_matters: Some("Deep delegation chains add indirection without value".to_string()),
..Default::default()
});
}
findings
}
fn is_trait_delegation_chain(&self, chain_members: &[String]) -> bool {
if chain_members.len() < 3 {
return false;
}
let names: Vec<&str> = chain_members
.iter()
.filter_map(|qn| qn.rsplit("::").next())
.collect();
if names.is_empty() {
return false;
}
let mut freq: HashMap<&str, usize> = HashMap::new();
for name in &names {
*freq.entry(name).or_default() += 1;
}
let max_freq = freq.values().copied().max().unwrap_or(0);
max_freq * 2 > names.len()
}
#[allow(clippy::only_used_in_recursion)]
fn trace_chain_with_members(
&self,
graph: &dyn crate::graph::GraphQuery,
qn: &str,
depth: i32,
) -> (i32, Vec<String>) {
if depth > 10 {
return (depth, vec![qn.to_string()]);
}
let callees = graph.get_callees(qn);
if callees.len() != 1 {
return (depth, vec![qn.to_string()]);
}
let callee = &callees[0];
let complexity = callee.complexity().unwrap_or(1);
if complexity > 3 {
return (
depth + 1,
vec![qn.to_string(), callee.qualified_name.clone()],
);
}
let (sub_depth, mut members) =
self.trace_chain_with_members(graph, &callee.qualified_name, depth + 1);
members.insert(0, qn.to_string());
(sub_depth, members)
}
}
impl Default for MessageChainDetector {
fn default() -> Self {
Self::new(".")
}
}
impl Detector for MessageChainDetector {
fn name(&self) -> &'static str {
"MessageChainDetector"
}
fn description(&self) -> &'static str {
"Detects Law of Demeter violations through long method chains"
}
fn category(&self) -> &'static str {
"coupling"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
fn detect(&self, graph: &dyn crate::graph::GraphQuery) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
findings.extend(self.scan_source_files());
findings.extend(self.find_delegation_chains(graph));
info!("MessageChainDetector found {} findings", findings.len());
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_fluent_pattern() {
let detector = MessageChainDetector::new(".");
assert!(detector.is_fluent_pattern(".filter().map().collect()"));
assert!(detector.is_fluent_pattern(".with_name().with_age().build()"));
assert!(!detector.is_fluent_pattern(".get_user().get_profile().get_settings()"));
}
#[test]
fn test_count_chain_depth() {
let detector = MessageChainDetector::new(".");
assert_eq!(detector.count_chain_depth(".a().b()"), 2);
assert_eq!(detector.count_chain_depth(".a().b().c().d()"), 4);
}
#[test]
fn test_severity() {
let detector = MessageChainDetector::new(".");
assert_eq!(detector.calculate_severity(5), Severity::Medium);
assert_eq!(detector.calculate_severity(7), Severity::Medium);
assert_eq!(detector.calculate_severity(8), Severity::High);
}
}