use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::ast::extract::extract_file;
use crate::ast::parser::ParserPool;
use crate::callgraph::cross_file_types::{CallGraphIR, CallSite, CallType, FileIR, FuncDef};
use crate::metrics::calculate_all_complexities_file;
use crate::types::Language;
use crate::types::inheritance::InheritanceReport;
use crate::TldrResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SmellType {
GodClass,
LongMethod,
LongParameterList,
FeatureEnvy,
DataClumps,
LowCohesion,
TightCoupling,
DeadCode,
CodeClone,
HighCognitiveComplexity,
DeepNesting,
DataClass,
LazyElement,
MessageChain,
PrimitiveObsession,
MiddleMan,
RefusedBequest,
InappropriateIntimacy,
}
impl std::fmt::Display for SmellType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SmellType::GodClass => write!(f, "God Class"),
SmellType::LongMethod => write!(f, "Long Method"),
SmellType::LongParameterList => write!(f, "Long Parameter List"),
SmellType::FeatureEnvy => write!(f, "Feature Envy"),
SmellType::DataClumps => write!(f, "Data Clumps"),
SmellType::LowCohesion => write!(f, "Low Cohesion"),
SmellType::TightCoupling => write!(f, "Tight Coupling"),
SmellType::DeadCode => write!(f, "Dead Code"),
SmellType::CodeClone => write!(f, "Code Clone"),
SmellType::HighCognitiveComplexity => write!(f, "High Cognitive Complexity"),
SmellType::DeepNesting => write!(f, "Deep Nesting"),
SmellType::DataClass => write!(f, "Data Class"),
SmellType::LazyElement => write!(f, "Lazy Element"),
SmellType::MessageChain => write!(f, "Message Chain"),
SmellType::PrimitiveObsession => write!(f, "Primitive Obsession"),
SmellType::MiddleMan => write!(f, "Middle Man"),
SmellType::RefusedBequest => write!(f, "Refused Bequest"),
SmellType::InappropriateIntimacy => write!(f, "Inappropriate Intimacy"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ThresholdPreset {
Strict,
#[default]
Default,
Relaxed,
}
const MM_DELEGATION_RATIO_STRICT: f64 = 0.50;
const MM_DELEGATION_RATIO_DEFAULT: f64 = 0.60;
const MM_DELEGATION_RATIO_RELAXED: f64 = 0.75;
const MM_MIN_METHODS: usize = 3;
const RB_USAGE_RATIO_STRICT: f64 = 0.33;
const RB_USAGE_RATIO_DEFAULT: f64 = 0.33;
const RB_USAGE_RATIO_RELAXED: f64 = 0.15;
const RB_MIN_INHERITED_STRICT: usize = 3;
const RB_MIN_INHERITED_DEFAULT: usize = 3;
const RB_MIN_INHERITED_RELAXED: usize = 5;
const FE_MIN_FOREIGN_STRICT: usize = 3;
const FE_MIN_FOREIGN_DEFAULT: usize = 4;
const FE_MIN_FOREIGN_RELAXED: usize = 5;
const FE_RATIO_STRICT: f64 = 1.5;
const FE_RATIO_DEFAULT: f64 = 2.0;
const FE_RATIO_RELAXED: f64 = 3.0;
const II_MIN_TOTAL_STRICT: usize = 6;
const II_MIN_TOTAL_DEFAULT: usize = 10;
const II_MIN_TOTAL_RELAXED: usize = 15;
const II_MIN_PER_DIR_STRICT: usize = 2;
const II_MIN_PER_DIR_DEFAULT: usize = 3;
const II_MIN_PER_DIR_RELAXED: usize = 4;
#[derive(Debug, Clone)]
pub struct Thresholds {
pub god_class_methods: usize,
pub god_class_loc: usize,
pub long_method_loc: usize,
pub long_method_complexity: u32,
pub long_param_count: usize,
pub middle_man_delegation_ratio: f64,
pub middle_man_min_methods: usize,
pub refused_bequest_usage_ratio: f64,
pub refused_bequest_min_inherited: usize,
pub feature_envy_min_foreign: usize,
pub feature_envy_ratio: f64,
pub intimacy_min_total: usize,
pub intimacy_min_per_direction: usize,
}
impl Thresholds {
pub fn from_preset(preset: ThresholdPreset) -> Self {
match preset {
ThresholdPreset::Strict => Self {
god_class_methods: 10,
god_class_loc: 250,
long_method_loc: 25,
long_method_complexity: 5,
long_param_count: 3,
middle_man_delegation_ratio: MM_DELEGATION_RATIO_STRICT,
middle_man_min_methods: MM_MIN_METHODS,
refused_bequest_usage_ratio: RB_USAGE_RATIO_STRICT,
refused_bequest_min_inherited: RB_MIN_INHERITED_STRICT,
feature_envy_min_foreign: FE_MIN_FOREIGN_STRICT,
feature_envy_ratio: FE_RATIO_STRICT,
intimacy_min_total: II_MIN_TOTAL_STRICT,
intimacy_min_per_direction: II_MIN_PER_DIR_STRICT,
},
ThresholdPreset::Default => Self {
god_class_methods: 20,
god_class_loc: 500,
long_method_loc: 50,
long_method_complexity: 10,
long_param_count: 5,
middle_man_delegation_ratio: MM_DELEGATION_RATIO_DEFAULT,
middle_man_min_methods: MM_MIN_METHODS,
refused_bequest_usage_ratio: RB_USAGE_RATIO_DEFAULT,
refused_bequest_min_inherited: RB_MIN_INHERITED_DEFAULT,
feature_envy_min_foreign: FE_MIN_FOREIGN_DEFAULT,
feature_envy_ratio: FE_RATIO_DEFAULT,
intimacy_min_total: II_MIN_TOTAL_DEFAULT,
intimacy_min_per_direction: II_MIN_PER_DIR_DEFAULT,
},
ThresholdPreset::Relaxed => Self {
god_class_methods: 30,
god_class_loc: 1000,
long_method_loc: 100,
long_method_complexity: 15,
long_param_count: 7,
middle_man_delegation_ratio: MM_DELEGATION_RATIO_RELAXED,
middle_man_min_methods: MM_MIN_METHODS,
refused_bequest_usage_ratio: RB_USAGE_RATIO_RELAXED,
refused_bequest_min_inherited: RB_MIN_INHERITED_RELAXED,
feature_envy_min_foreign: FE_MIN_FOREIGN_RELAXED,
feature_envy_ratio: FE_RATIO_RELAXED,
intimacy_min_total: II_MIN_TOTAL_RELAXED,
intimacy_min_per_direction: II_MIN_PER_DIR_RELAXED,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmellFinding {
pub smell_type: SmellType,
pub file: PathBuf,
pub name: String,
pub line: u32,
pub reason: String,
pub severity: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmellsReport {
pub smells: Vec<SmellFinding>,
pub files_scanned: usize,
pub by_file: HashMap<PathBuf, Vec<SmellFinding>>,
pub summary: SmellsSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmellsSummary {
pub total_smells: usize,
pub by_type: HashMap<String, usize>,
pub avg_smells_per_file: f64,
}
pub fn detect_smells(
path: &Path,
threshold: ThresholdPreset,
smell_type: Option<SmellType>,
suggest: bool,
) -> TldrResult<SmellsReport> {
let thresholds = Thresholds::from_preset(threshold);
const MAX_FILE_SIZE: u64 = 500 * 1024;
let files: Vec<PathBuf> = if path.is_file() {
vec![path.to_path_buf()]
} else {
WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| Language::from_path(e.path()).is_some())
.filter(|e| e.metadata().map(|m| m.len() <= MAX_FILE_SIZE).unwrap_or(true))
.map(|e| e.path().to_path_buf())
.collect()
};
let file_results: Vec<Vec<SmellFinding>> = files
.par_iter()
.filter_map(|file_path| {
analyze_file(file_path, &thresholds, smell_type, suggest).ok()
})
.collect();
let files_scanned = file_results.len();
let smells: Vec<SmellFinding> = file_results.into_iter().flatten().collect();
let mut by_file: HashMap<PathBuf, Vec<SmellFinding>> = HashMap::new();
for smell in &smells {
by_file
.entry(smell.file.clone())
.or_default()
.push(smell.clone());
}
let mut by_type: HashMap<String, usize> = HashMap::new();
for smell in &smells {
*by_type.entry(smell.smell_type.to_string()).or_insert(0) += 1;
}
let summary = SmellsSummary {
total_smells: smells.len(),
by_type,
avg_smells_per_file: if files_scanned > 0 {
smells.len() as f64 / files_scanned as f64
} else {
0.0
},
};
Ok(SmellsReport {
smells,
files_scanned,
by_file,
summary,
})
}
fn analyze_file(
path: &Path,
thresholds: &Thresholds,
smell_filter: Option<SmellType>,
suggest: bool,
) -> TldrResult<Vec<SmellFinding>> {
let mut smells = Vec::new();
let module_info = extract_file(path, None)?;
if should_analyze_smell(smell_filter, SmellType::GodClass) {
collect_god_class_smells(path, &module_info.classes, thresholds, suggest, &mut smells);
}
let complexity_map = calculate_all_complexities_file(path).unwrap_or_default();
let all_functions = module_info.functions.iter()
.chain(module_info.classes.iter().flat_map(|c| c.methods.iter()));
for func in all_functions {
if should_analyze_smell(smell_filter, SmellType::LongParameterList) {
maybe_add_long_parameter_smell(path, func, thresholds, suggest, &mut smells);
}
if should_analyze_smell(smell_filter, SmellType::LongMethod) {
maybe_add_long_method_smell(
path,
func,
thresholds,
suggest,
&complexity_map,
&mut smells,
);
}
}
let source = std::fs::read_to_string(path).unwrap_or_default();
let lang_str = Language::from_path(path)
.map(|l| format!("{:?}", l).to_lowercase())
.unwrap_or_default();
collect_tier1_ast_smells(
path,
&source,
&lang_str,
smell_filter,
suggest,
&mut smells,
);
Ok(smells)
}
fn collect_god_class_smells(
path: &Path,
classes: &[crate::types::ClassInfo],
thresholds: &Thresholds,
suggest: bool,
smells: &mut Vec<SmellFinding>,
) {
for class in classes {
let method_count = class.methods.len();
let class_loc = estimate_class_loc(class);
if method_count > thresholds.god_class_methods {
smells.push(SmellFinding {
smell_type: SmellType::GodClass,
file: path.to_path_buf(),
name: class.name.clone(),
line: class.line_number,
reason: format!(
"Class has {} methods (threshold: {})",
method_count, thresholds.god_class_methods
),
severity: calculate_severity(method_count, thresholds.god_class_methods),
suggestion: if suggest {
Some("Consider splitting this class into smaller, focused classes using the Single Responsibility Principle".to_string())
} else {
None
},
});
continue;
}
if class_loc > thresholds.god_class_loc {
smells.push(SmellFinding {
smell_type: SmellType::GodClass,
file: path.to_path_buf(),
name: class.name.clone(),
line: class.line_number,
reason: format!(
"Class has {} lines of code (threshold: {})",
class_loc, thresholds.god_class_loc
),
severity: calculate_severity(class_loc, thresholds.god_class_loc),
suggestion: if suggest {
Some("Consider extracting methods and responsibilities into separate classes".to_string())
} else {
None
},
});
}
}
}
fn maybe_add_long_parameter_smell(
path: &Path,
func: &crate::types::FunctionInfo,
thresholds: &Thresholds,
suggest: bool,
smells: &mut Vec<SmellFinding>,
) {
let param_count = func.params.len();
if param_count <= thresholds.long_param_count {
return;
}
smells.push(SmellFinding {
smell_type: SmellType::LongParameterList,
file: path.to_path_buf(),
name: func.name.clone(),
line: func.line_number,
reason: format!(
"Function has {} parameters (threshold: {})",
param_count, thresholds.long_param_count
),
severity: calculate_severity(param_count, thresholds.long_param_count),
suggestion: if suggest {
Some("Consider using a parameter object or builder pattern to reduce parameters".to_string())
} else {
None
},
});
}
fn maybe_add_long_method_smell(
path: &Path,
func: &crate::types::FunctionInfo,
thresholds: &Thresholds,
suggest: bool,
complexity_map: &std::collections::HashMap<String, crate::types::ComplexityMetrics>,
smells: &mut Vec<SmellFinding>,
) {
let Some(metrics) = complexity_map.get(&func.name) else {
return;
};
if metrics.lines_of_code as usize > thresholds.long_method_loc {
smells.push(SmellFinding {
smell_type: SmellType::LongMethod,
file: path.to_path_buf(),
name: func.name.clone(),
line: func.line_number,
reason: format!(
"Method has {} lines of code (threshold: {})",
metrics.lines_of_code, thresholds.long_method_loc
),
severity: calculate_severity(
metrics.lines_of_code as usize,
thresholds.long_method_loc,
),
suggestion: if suggest {
Some("Consider extracting parts of this method into smaller helper methods".to_string())
} else {
None
},
});
return;
}
if metrics.cyclomatic > thresholds.long_method_complexity {
smells.push(SmellFinding {
smell_type: SmellType::LongMethod,
file: path.to_path_buf(),
name: func.name.clone(),
line: func.line_number,
reason: format!(
"Method has cyclomatic complexity {} (threshold: {})",
metrics.cyclomatic, thresholds.long_method_complexity
),
severity: calculate_severity(
metrics.cyclomatic as usize,
thresholds.long_method_complexity as usize,
),
suggestion: if suggest {
Some("Consider simplifying control flow or extracting complex conditions into methods".to_string())
} else {
None
},
});
}
}
fn collect_tier1_ast_smells(
path: &Path,
source: &str,
lang_str: &str,
smell_filter: Option<SmellType>,
suggest: bool,
smells: &mut Vec<SmellFinding>,
) {
if should_analyze_smell(smell_filter, SmellType::DeepNesting) {
append_ast_findings(
smells,
detect_deep_nesting(source, lang_str),
path,
suggest,
"Reduce nesting by extracting inner blocks into helper functions or using early returns",
);
}
if should_analyze_smell(smell_filter, SmellType::DataClass) {
append_ast_findings(
smells,
detect_data_classes(source, lang_str),
path,
suggest,
"Consider adding behavior methods or converting to a plain data structure (dataclass, struct, record)",
);
}
if should_analyze_smell(smell_filter, SmellType::LazyElement) {
append_ast_findings(
smells,
detect_lazy_elements(source, lang_str),
path,
suggest,
"Consider inlining this class into its caller or merging with a related class",
);
}
if should_analyze_smell(smell_filter, SmellType::MessageChain) {
append_ast_findings(
smells,
detect_message_chains(source, lang_str),
path,
suggest,
"Apply the Law of Demeter: hide the chain behind a single method call",
);
}
if should_analyze_smell(smell_filter, SmellType::PrimitiveObsession) {
append_ast_findings(
smells,
detect_primitive_obsession(source, lang_str),
path,
suggest,
"Introduce domain types (value objects) instead of passing raw primitives",
);
}
}
fn append_ast_findings(
smells: &mut Vec<SmellFinding>,
mut findings: Vec<SmellFinding>,
path: &Path,
suggest: bool,
suggestion: &str,
) {
for finding in &mut findings {
finding.file = path.to_path_buf();
if suggest {
finding.suggestion = Some(suggestion.to_string());
}
}
smells.extend(findings);
}
fn estimate_class_loc(class: &crate::types::ClassInfo) -> usize {
if class.methods.is_empty() {
return 0;
}
let min_line = class.line_number;
let max_line = class.methods.iter()
.map(|m| m.line_number)
.max()
.unwrap_or(min_line);
(max_line - min_line + 20) as usize
}
fn calculate_severity(value: usize, threshold: usize) -> u8 {
let ratio = value as f64 / threshold as f64;
if ratio > 2.0 {
3 } else if ratio > 1.5 {
2 } else {
1 }
}
pub(crate) fn cohesion_severity(lcom4: usize) -> u8 {
if lcom4 >= 6 {
3
} else if lcom4 >= 4 {
2
} else {
1
}
}
pub(crate) fn coupling_severity(score: f64) -> u8 {
if score >= 0.8 {
2
} else {
1
}
}
pub(crate) fn cognitive_severity(cognitive: usize) -> u8 {
if cognitive >= 30 {
3
} else if cognitive >= 20 {
2
} else {
1
}
}
pub(crate) fn clone_severity(score: f64) -> u8 {
if score > 0.8 {
2
} else {
1
}
}
pub(crate) fn nesting_severity(depth: usize) -> u8 {
if depth >= 8 {
3
} else if depth >= 6 {
2
} else {
1
}
}
pub(crate) fn data_class_severity(field_count: usize, method_count: usize) -> u8 {
if field_count >= 8 && method_count == 0 {
2
} else {
1
}
}
pub(crate) fn chain_severity(chain_length: usize) -> u8 {
if chain_length >= 6 {
2
} else {
1
}
}
pub(crate) fn primitive_obsession_severity(primitive_count: usize) -> u8 {
if primitive_count >= 6 {
2
} else {
1
}
}
pub(crate) fn middle_man_severity(delegation_ratio: f64, delegation_count: usize) -> u8 {
if delegation_ratio >= 0.90 && delegation_count >= 5 {
3
} else if delegation_ratio >= 0.75 || delegation_count >= 4 {
2
} else {
1
}
}
pub(crate) fn refused_bequest_severity(usage_ratio: f64, total_inherited: usize) -> u8 {
if usage_ratio == 0.0 && total_inherited >= 5 {
3
} else if usage_ratio < 0.10 || (usage_ratio == 0.0 && total_inherited >= 3) {
2
} else {
1
}
}
pub(crate) fn feature_envy_severity(foreign: usize, own: usize) -> u8 {
let ratio = foreign as f64 / (own.max(1)) as f64;
if foreign >= 8 && ratio > 4.0 {
3
} else if foreign >= 5 && ratio > 2.5 {
2
} else {
1
}
}
pub(crate) fn intimacy_severity(total_accesses: usize, min_direction_count: usize) -> u8 {
if total_accesses >= 20 && min_direction_count >= 5 {
3
} else if total_accesses >= 12 && min_direction_count >= 3 {
2
} else {
1
}
}
fn get_class_methods_robust<'a>(file_ir: &'a FileIR, class_name: &str) -> Vec<&'a FuncDef> {
let class_def = file_ir.get_class(class_name);
let has_methods_list = class_def
.map(|c| !c.methods.is_empty())
.unwrap_or(false);
if has_methods_list {
let method_names: HashSet<&str> = class_def
.unwrap()
.methods
.iter()
.map(|m| m.as_str())
.collect();
let mut seen = HashSet::new();
file_ir
.funcs
.iter()
.filter(|f| {
f.class_name.as_deref() == Some(class_name)
|| method_names.contains(f.name.as_str())
})
.filter(|f| seen.insert(&f.name))
.collect()
} else {
file_ir
.funcs
.iter()
.filter(|f| f.class_name.as_deref() == Some(class_name))
.collect()
}
}
fn is_constructor(name: &str, language: &str) -> bool {
match language {
"python" | "py" => name == "__init__",
"javascript" | "typescript" | "tsx" | "jsx" | "js" | "ts" => name == "constructor",
"rust" | "rs" => name == "new",
"go" => name.starts_with("New"),
"ruby" | "rb" => name == "initialize",
"php" => name == "__construct",
"swift" => name == "init",
"scala" => name == "<init>" || name == "this",
"java" | "csharp" | "cs" | "kotlin" | "kt" => false,
"c" | "cpp" | "c++" => false,
"elixir" | "ex" | "lua" => false,
_ => {
name == "__init__"
|| name == "constructor"
|| name == "new"
|| name == "initialize"
|| name == "__construct"
|| name == "init"
}
}
}
fn is_self_reference(receiver: &str, language: &str) -> bool {
match language {
"python" | "py" | "rust" | "rs" | "ruby" | "rb" | "swift" => receiver == "self",
"typescript" | "javascript" | "tsx" | "jsx" | "ts" | "js" | "java" | "csharp" | "cs"
| "kotlin" | "kt" | "scala" | "cpp" | "c++" | "php" => receiver == "this",
"go" | "c" | "elixir" | "ex" | "lua" => false,
_ => receiver == "self" || receiver == "this",
}
}
fn resolve_language(lang_str: &str) -> Option<Language> {
match lang_str.to_lowercase().as_str() {
"python" | "py" => Some(Language::Python),
"rust" | "rs" => Some(Language::Rust),
"typescript" | "ts" => Some(Language::TypeScript),
"javascript" | "js" => Some(Language::JavaScript),
"go" => Some(Language::Go),
"java" => Some(Language::Java),
"c" => Some(Language::C),
"cpp" | "c++" => Some(Language::Cpp),
"ruby" | "rb" => Some(Language::Ruby),
"csharp" | "c#" | "cs" => Some(Language::CSharp),
"scala" => Some(Language::Scala),
"php" => Some(Language::Php),
"lua" => Some(Language::Lua),
"kotlin" | "kt" => Some(Language::Kotlin),
"elixir" | "ex" => Some(Language::Elixir),
_ => None,
}
}
fn parse_source(source: &str, lang_str: &str) -> Option<(tree_sitter::Tree, Language)> {
let lang = resolve_language(lang_str)?;
let pool = ParserPool::new();
pool.parse(source, lang).ok().map(|tree| (tree, lang))
}
fn is_nesting_node(kind: &str) -> bool {
matches!(kind,
"if_statement" | "if_expression" |
"for_statement" | "for_expression" |
"while_statement" | "while_expression" |
"try_statement" | "try_expression" |
"with_statement" |
"match_statement" | "match_expression" |
"if_let_expression" |
"loop_expression" |
"for_clause" |
"for_in_statement" |
"switch_statement" | "switch_expression" |
"do_statement" |
"catch_clause" |
"try_catch_statement" |
"except_clause"
)
}
pub fn detect_deep_nesting(source: &str, language: &str) -> Vec<SmellFinding> {
let (tree, _lang) = match parse_source(source, language) {
Some(v) => v,
None => return Vec::new(),
};
let root = tree.root_node();
let mut findings = Vec::new();
find_functions_and_measure_nesting(root, source, &mut findings);
findings
}
fn find_functions_and_measure_nesting(
node: tree_sitter::Node,
source: &str,
findings: &mut Vec<SmellFinding>,
) {
let kind = node.kind();
let is_function = matches!(kind,
"function_definition" | "function_declaration" | "function_item" |
"method_definition" | "method_declaration" |
"arrow_function" | "function" | "closure_expression" |
"function_expression" | "generator_function" |
"async_function" | "function_def"
);
if is_function {
let func_name = extract_function_name(node, source).unwrap_or_else(|| "<anonymous>".to_string());
let line = node.start_position().row as u32 + 1;
let max_depth = measure_max_nesting_depth(node, 0);
if max_depth >= 5 {
findings.push(SmellFinding {
smell_type: SmellType::DeepNesting,
file: PathBuf::from("<source>"),
name: func_name,
line,
reason: format!(
"Function has nesting depth {} (threshold: 5)",
max_depth
),
severity: nesting_severity(max_depth),
suggestion: None,
});
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if !is_function || !matches!(child.kind(),
"function_definition" | "function_declaration" | "function_item" |
"method_definition" | "method_declaration"
) {
find_functions_and_measure_nesting(child, source, findings);
}
}
}
fn measure_max_nesting_depth(node: tree_sitter::Node, current_depth: usize) -> usize {
let kind = node.kind();
let new_depth = if is_nesting_node(kind) {
current_depth + 1
} else {
current_depth
};
let mut max_depth = new_depth;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let child_max = measure_max_nesting_depth(child, new_depth);
if child_max > max_depth {
max_depth = child_max;
}
}
max_depth
}
fn extract_function_name(node: tree_sitter::Node, source: &str) -> Option<String> {
if let Some(name_node) = node.child_by_field_name("name") {
let name = &source[name_node.byte_range()];
return Some(name.to_string());
}
None
}
pub fn detect_data_classes(source: &str, language: &str) -> Vec<SmellFinding> {
let (tree, _lang) = match parse_source(source, language) {
Some(v) => v,
None => return Vec::new(),
};
let root = tree.root_node();
let mut findings = Vec::new();
find_classes_and_check_data_class(root, source, &mut findings);
findings
}
fn find_classes_and_check_data_class(
node: tree_sitter::Node,
source: &str,
findings: &mut Vec<SmellFinding>,
) {
let kind = node.kind();
let is_class = matches!(kind,
"class_definition" | "class_declaration" |
"struct_item" | "struct_declaration" |
"interface_declaration"
);
if is_class {
let class_name = extract_class_name(node, source)
.unwrap_or_else(|| "<unknown>".to_string());
let line = node.start_position().row as u32 + 1;
let (field_count, method_count) = count_class_members(node, source);
if field_count >= 4 && method_count <= 2 {
let ratio = if field_count > 0 {
method_count as f64 / field_count as f64
} else {
0.0
};
if ratio < 0.5 {
findings.push(SmellFinding {
smell_type: SmellType::DataClass,
file: PathBuf::from("<source>"),
name: class_name,
line,
reason: format!(
"Class has {} fields and {} methods (data bag, ratio {:.2})",
field_count, method_count, ratio
),
severity: data_class_severity(field_count, method_count),
suggestion: None,
});
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_classes_and_check_data_class(child, source, findings);
}
}
fn extract_class_name(node: tree_sitter::Node, source: &str) -> Option<String> {
if let Some(name_node) = node.child_by_field_name("name") {
let name = &source[name_node.byte_range()];
return Some(name.to_string());
}
None
}
fn count_class_members(node: tree_sitter::Node, source: &str) -> (usize, usize) {
let mut field_count = 0;
let mut method_count = 0;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
match kind {
"function_definition" | "function_declaration" |
"method_definition" | "method_declaration" |
"function_item" => {
method_count += 1;
let func_name = extract_function_name(child, source);
if func_name.as_deref() == Some("__init__") {
field_count += count_self_assignments(child, source);
}
}
"field_declaration" | "field_definition" |
"property_declaration" | "public_field_definition" |
"class_variable" => {
field_count += 1;
}
"field_declaration_list" => {
let mut inner_cursor = child.walk();
for inner in child.children(&mut inner_cursor) {
if inner.kind() == "field_declaration" {
field_count += 1;
}
}
}
"class_body" | "block" | "declaration_list" | "class_heritage" => {
let (f, m) = count_class_members(child, source);
field_count += f;
method_count += m;
}
_ => {}
}
}
(field_count, method_count)
}
fn count_self_assignments(node: tree_sitter::Node, source: &str) -> usize {
let mut count = 0;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "expression_statement" || child.kind() == "assignment" {
let text = &source[child.byte_range()];
if text.starts_with("self.") || text.contains("self.") {
count += text.matches("self.").count().min(1);
}
}
count += count_self_assignments(child, source);
}
count
}
pub fn detect_lazy_elements(source: &str, language: &str) -> Vec<SmellFinding> {
let (tree, _lang) = match parse_source(source, language) {
Some(v) => v,
None => return Vec::new(),
};
let root = tree.root_node();
let mut findings = Vec::new();
find_classes_and_check_lazy(root, source, &mut findings);
findings
}
fn find_classes_and_check_lazy(
node: tree_sitter::Node,
source: &str,
findings: &mut Vec<SmellFinding>,
) {
let kind = node.kind();
let is_class = matches!(kind,
"class_definition" | "class_declaration" |
"struct_item" | "struct_declaration"
);
if is_class {
let class_name = extract_class_name(node, source)
.unwrap_or_else(|| "<unknown>".to_string());
let line = node.start_position().row as u32 + 1;
let (field_count, method_count) = count_class_members(node, source);
if method_count <= 1 && field_count <= 1 {
findings.push(SmellFinding {
smell_type: SmellType::LazyElement,
file: PathBuf::from("<source>"),
name: class_name,
line,
reason: format!(
"Class has only {} method(s) and {} field(s) - may not justify its own class",
method_count, field_count
),
severity: 1, suggestion: None,
});
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_classes_and_check_lazy(child, source, findings);
}
}
pub fn detect_message_chains(source: &str, language: &str) -> Vec<SmellFinding> {
let (tree, _lang) = match parse_source(source, language) {
Some(v) => v,
None => return Vec::new(),
};
let root = tree.root_node();
let mut findings = Vec::new();
let mut visited_lines: std::collections::HashSet<u32> = std::collections::HashSet::new();
find_message_chains(root, source, &mut findings, &mut visited_lines);
findings
}
fn find_message_chains(
node: tree_sitter::Node,
source: &str,
findings: &mut Vec<SmellFinding>,
visited_lines: &mut std::collections::HashSet<u32>,
) {
let kind = node.kind();
let is_chain_node = matches!(kind,
"attribute" | "member_expression" | "field_expression" |
"call_expression" | "method_invocation" | "call"
);
if is_chain_node {
let chain_length = measure_chain_length(node);
let line = node.start_position().row as u32 + 1;
if chain_length > 3 && !visited_lines.contains(&line) {
visited_lines.insert(line);
let chain_text = &source[node.byte_range()];
let truncated = if chain_text.len() > 60 {
format!("{}...", &chain_text[..57])
} else {
chain_text.to_string()
};
findings.push(SmellFinding {
smell_type: SmellType::MessageChain,
file: PathBuf::from("<source>"),
name: truncated,
line,
reason: format!(
"Method chain of length {} (threshold: 3)",
chain_length
),
severity: chain_severity(chain_length),
suggestion: None,
});
return;
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_message_chains(child, source, findings, visited_lines);
}
}
fn measure_chain_length(node: tree_sitter::Node) -> usize {
let kind = node.kind();
let is_access = matches!(kind,
"attribute" | "member_expression" | "field_expression" |
"call_expression" | "method_invocation" | "call"
);
if !is_access {
return 0;
}
let child_chain = node.child_by_field_name("object")
.or_else(|| node.child_by_field_name("value"))
.or_else(|| node.child_by_field_name("function"))
.map(|c| measure_chain_length(c))
.unwrap_or(0);
if kind == "call_expression" || kind == "call" {
if let Some(func) = node.child_by_field_name("function") {
return measure_chain_length(func);
}
if let Some(first) = node.child(0) {
return measure_chain_length(first);
}
}
1 + child_chain
}
const PRIMITIVE_TYPES: &[&str] = &[
"int", "float", "str", "bool", "bytes",
"i8", "i16", "i32", "i64", "i128", "isize",
"u8", "u16", "u32", "u64", "u128", "usize",
"f32", "f64", "String", "&str", "char",
"number", "string", "boolean",
"byte", "short", "long", "double",
"Integer", "Long", "Double", "Float", "Boolean",
"int8", "int16", "int32", "int64",
"uint8", "uint16", "uint32", "uint64",
"float32", "float64",
];
fn is_primitive_type(type_str: &str) -> bool {
let trimmed = type_str.trim();
let base = trimmed.trim_start_matches('&').trim_start_matches("mut ");
PRIMITIVE_TYPES.contains(&base)
}
pub fn detect_primitive_obsession(source: &str, language: &str) -> Vec<SmellFinding> {
let (tree, _lang) = match parse_source(source, language) {
Some(v) => v,
None => return Vec::new(),
};
let root = tree.root_node();
let mut findings = Vec::new();
find_functions_and_check_primitives(root, source, &mut findings);
findings
}
fn find_functions_and_check_primitives(
node: tree_sitter::Node,
source: &str,
findings: &mut Vec<SmellFinding>,
) {
let kind = node.kind();
let is_function = matches!(kind,
"function_definition" | "function_declaration" | "function_item" |
"method_definition" | "method_declaration" |
"arrow_function" | "function"
);
if is_function {
let func_name = extract_function_name(node, source)
.unwrap_or_else(|| "<anonymous>".to_string());
let line = node.start_position().row as u32 + 1;
let primitive_count = count_primitive_params(node, source);
if primitive_count > 3 {
findings.push(SmellFinding {
smell_type: SmellType::PrimitiveObsession,
file: PathBuf::from("<source>"),
name: func_name,
line,
reason: format!(
"Function has {} primitive parameters (threshold: 3)",
primitive_count
),
severity: primitive_obsession_severity(primitive_count),
suggestion: None,
});
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
find_functions_and_check_primitives(child, source, findings);
}
}
fn count_primitive_params(node: tree_sitter::Node, source: &str) -> usize {
let params_node = match node.child_by_field_name("parameters") {
Some(p) => p,
None => return 0,
};
let mut count = 0;
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
let param_kind = param.kind();
if param_kind == "self" || param_kind == "," || param_kind == "(" || param_kind == ")" {
continue;
}
if let Some(type_node) = param.child_by_field_name("type") {
let type_text = &source[type_node.byte_range()];
if is_primitive_type(type_text) {
count += 1;
}
}
else if param_kind == "typed_parameter" || param_kind == "typed_default_parameter" {
let mut inner_cursor = param.walk();
for child in param.children(&mut inner_cursor) {
if child.kind() == "type" {
let type_text = &source[child.byte_range()];
if is_primitive_type(type_text) {
count += 1;
}
}
}
}
}
count
}
#[deprecated(since = "0.2.0", note = "Use detect_middle_man_from_callgraph() with --deep mode instead")]
pub fn detect_middle_man(_source: &str, _language: &str) -> Vec<SmellFinding> {
Vec::new()
}
#[deprecated(since = "0.2.0", note = "Use detect_refused_bequest_from_callgraph() with --deep mode instead")]
pub fn detect_refused_bequest(_source: &str, _language: &str) -> Vec<SmellFinding> {
Vec::new()
}
#[deprecated(since = "0.2.0", note = "Use detect_feature_envy_from_callgraph() with --deep mode instead")]
pub fn detect_feature_envy(_source: &str, _language: &str) -> Vec<SmellFinding> {
Vec::new()
}
#[deprecated(since = "0.2.0", note = "Use detect_inappropriate_intimacy_from_callgraph() with --deep mode instead")]
pub fn detect_inappropriate_intimacy(_source: &str, _language: &str) -> Vec<SmellFinding> {
Vec::new()
}
pub fn detect_middle_man_from_callgraph(
file_ir: &FileIR,
thresholds: &Thresholds,
language: &str,
suggest: bool,
) -> Vec<SmellFinding> {
const EXCLUDED_PATTERNS: &[&str] = &[
"facade", "adapter", "wrapper", "proxy", "bridge", "decorator", "gateway",
];
let mut findings = Vec::new();
for class in &file_ir.classes {
let methods = get_class_methods_robust(file_ir, &class.name);
let non_constructor_methods: Vec<&FuncDef> = methods
.iter()
.filter(|m| !is_constructor(&m.name, language))
.copied()
.collect();
let total = non_constructor_methods.len();
if total < thresholds.middle_man_min_methods {
continue;
}
let name_lower = class.name.to_lowercase();
if EXCLUDED_PATTERNS.iter().any(|p| name_lower.contains(p)) {
continue;
}
let mut delegation_count: usize = 0;
let mut delegate_targets: HashMap<String, usize> = HashMap::new();
for method in &non_constructor_methods {
let qualified = format!("{}.{}", class.name, method.name);
let calls = file_ir
.calls
.get(&qualified)
.or_else(|| file_ir.calls.get(&method.name));
if let Some(calls) = calls {
let method_calls: Vec<&CallSite> = calls
.iter()
.filter(|c| matches!(c.call_type, CallType::Method | CallType::Attr))
.collect();
if method_calls.len() == 1 {
let call = method_calls[0];
let receiver_is_self = call
.receiver
.as_ref()
.map(|r| {
is_self_reference(r, language)
|| r.starts_with("self.")
|| r.starts_with("this.")
})
.unwrap_or(false);
let non_method_calls = calls
.iter()
.filter(|c| !matches!(c.call_type, CallType::Method | CallType::Attr))
.count();
if !receiver_is_self && non_method_calls == 0 {
delegation_count += 1;
if let Some(ref rt) = call.receiver_type {
*delegate_targets.entry(rt.clone()).or_insert(0) += 1;
}
}
}
}
}
let ratio = delegation_count as f64 / total as f64;
if ratio >= thresholds.middle_man_delegation_ratio {
let primary_delegate = delegate_targets
.iter()
.max_by_key(|(_, count)| *count)
.map(|(name, _)| name.clone())
.unwrap_or_else(|| "unknown".to_string());
findings.push(SmellFinding {
smell_type: SmellType::MiddleMan,
file: file_ir.path.clone(),
name: class.name.clone(),
line: class.line,
reason: format!(
"Class delegates {}/{} methods ({:.0}%) to {}",
delegation_count,
total,
ratio * 100.0,
primary_delegate
),
severity: middle_man_severity(ratio, delegation_count),
suggestion: if suggest {
Some(format!(
"Consider removing {} and using {} directly",
class.name, primary_delegate
))
} else {
None
},
});
}
}
findings
}
pub fn detect_refused_bequest_from_callgraph(
call_graph: &CallGraphIR,
inheritance_report: &InheritanceReport,
thresholds: &Thresholds,
suggest: bool,
) -> Vec<SmellFinding> {
use crate::types::inheritance::{InheritanceKind, BaseResolution};
let mut findings = Vec::new();
for edge in &inheritance_report.edges {
if edge.external || edge.resolution == BaseResolution::Unresolved {
continue;
}
if edge.kind == InheritanceKind::Embeds {
continue;
}
if edge.kind == InheritanceKind::Implements {
continue;
}
let parent_node = inheritance_report
.nodes
.iter()
.find(|n| n.name == edge.parent);
if let Some(parent) = parent_node {
if parent.is_abstract == Some(true)
|| parent.protocol == Some(true)
|| parent.mixin == Some(true)
{
continue;
}
}
let parent_concrete_methods = get_parent_concrete_methods(call_graph, &edge.parent);
if parent_concrete_methods.len() < thresholds.refused_bequest_min_inherited {
continue;
}
let child_file_ir = call_graph.files.get(&edge.child_file);
let child_methods = child_file_ir
.map(|fir| get_class_methods_robust(fir, &edge.child))
.unwrap_or_default();
let child_method_names: HashSet<&str> = child_methods
.iter()
.map(|f| f.name.as_str())
.collect();
let child_call_targets: HashSet<String> = if let Some(fir) = child_file_ir {
child_methods
.iter()
.flat_map(|method| {
let qualified = format!("{}.{}", edge.child, method.name);
fir.calls
.get(&qualified)
.into_iter()
.chain(fir.calls.get(&method.name).into_iter())
.flatten()
.map(|c| c.target.clone())
})
.collect()
} else {
HashSet::new()
};
let mut used_count = 0usize;
let mut unused_methods = Vec::new();
for inherited_method in &parent_concrete_methods {
let is_overridden = child_method_names.contains(inherited_method.as_str());
let is_called = child_call_targets.contains(inherited_method);
if is_overridden || is_called {
used_count += 1;
} else {
unused_methods.push(inherited_method.clone());
}
}
let total = parent_concrete_methods.len();
let usage_ratio = used_count as f64 / total as f64;
if usage_ratio < thresholds.refused_bequest_usage_ratio {
let child_line = edge.child_line;
findings.push(SmellFinding {
smell_type: SmellType::RefusedBequest,
file: edge.child_file.clone(),
name: edge.child.clone(),
line: child_line,
reason: format!(
"Uses {}/{} ({:.0}%) inherited methods from {}. Unused: {}",
used_count,
total,
usage_ratio * 100.0,
edge.parent,
if unused_methods.len() <= 5 {
unused_methods.join(", ")
} else {
format!(
"{}, ... and {} more",
unused_methods[..5].join(", "),
unused_methods.len() - 5
)
}
),
severity: refused_bequest_severity(usage_ratio, total),
suggestion: if suggest {
Some(format!(
"Consider composition over inheritance, or remove {} as a base of {}",
edge.parent, edge.child
))
} else {
None
},
});
}
}
findings
}
fn get_parent_concrete_methods(call_graph: &CallGraphIR, parent_name: &str) -> Vec<String> {
let language = &call_graph.language;
for file_ir in call_graph.files.values() {
let methods = get_class_methods_robust(file_ir, parent_name);
if !methods.is_empty() {
return methods
.iter()
.filter(|m| !is_constructor(&m.name, language))
.map(|m| m.name.clone())
.collect();
}
}
Vec::new()
}
pub fn detect_feature_envy_from_callgraph(
file_ir: &FileIR,
thresholds: &Thresholds,
language: &str,
suggest: bool,
) -> Vec<SmellFinding> {
const EXCLUDED_ROLES: &[&str] = &[
"format", "formatter", "serialize", "serializer", "deserialize",
"handler", "visitor", "render", "renderer", "builder",
"validator", "converter", "mapper", "adapter",
"factory", "transformer", "presenter",
];
let mut findings = Vec::new();
for class in &file_ir.classes {
let name_lower = class.name.to_lowercase();
if EXCLUDED_ROLES.iter().any(|r| name_lower.contains(r)) {
continue;
}
let methods = get_class_methods_robust(file_ir, &class.name);
for method in &methods {
if is_constructor(&method.name, language) {
continue;
}
if !method.is_method {
continue;
}
let qualified = format!("{}.{}", class.name, method.name);
let calls = file_ir
.calls
.get(&qualified)
.or_else(|| file_ir.calls.get(&method.name));
let calls = match calls {
Some(c) if !c.is_empty() => c,
_ => continue, };
let mut own_count: usize = 0;
let mut foreign_counts: HashMap<String, usize> = HashMap::new();
for call in calls {
if !matches!(call.call_type, CallType::Method | CallType::Attr) {
continue;
}
let is_own = call
.receiver
.as_ref()
.map(|r| {
is_self_reference(r, language)
|| r.starts_with("self.")
|| r.starts_with("this.")
})
.unwrap_or(false);
if is_own {
own_count += 1;
} else if let Some(ref rt) = call.receiver_type {
if rt == &class.name {
own_count += 1; } else {
*foreign_counts.entry(rt.clone()).or_insert(0) += 1;
}
}
}
for (foreign_class, foreign_count) in &foreign_counts {
if *foreign_count < thresholds.feature_envy_min_foreign {
continue;
}
let ratio = *foreign_count as f64 / own_count.max(1) as f64;
if ratio < thresholds.feature_envy_ratio {
continue;
}
findings.push(SmellFinding {
smell_type: SmellType::FeatureEnvy,
file: file_ir.path.clone(),
name: format!("{}::{}", class.name, method.name),
line: method.line,
reason: format!(
"Accesses {} features of {} but only {} of own class {} (ratio {:.1}:1)",
foreign_count, foreign_class, own_count, class.name, ratio
),
severity: feature_envy_severity(*foreign_count, own_count),
suggestion: if suggest {
Some(format!(
"Consider moving {} to {} or extracting shared logic",
method.name, foreign_class
))
} else {
None
},
});
}
}
}
findings
}
struct IntimacyPairMetrics {
a_to_b: usize,
b_to_a: usize,
a_to_b_private: usize,
b_to_a_private: usize,
}
impl IntimacyPairMetrics {
fn new() -> Self {
Self {
a_to_b: 0,
b_to_a: 0,
a_to_b_private: 0,
b_to_a_private: 0,
}
}
fn total(&self) -> usize {
self.a_to_b + self.b_to_a
}
fn min_direction(&self) -> usize {
self.a_to_b.min(self.b_to_a)
}
fn is_bidirectional_enough(&self, min_per_dir: usize) -> bool {
self.a_to_b >= min_per_dir && self.b_to_a >= min_per_dir
}
}
fn normalize_class_pair(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
}
}
pub fn detect_inappropriate_intimacy_from_callgraph(
call_graph: &CallGraphIR,
inheritance_report: &InheritanceReport,
thresholds: &Thresholds,
suggest: bool,
) -> Vec<SmellFinding> {
let mut findings = Vec::new();
let mut pair_metrics: HashMap<(String, String), IntimacyPairMetrics> = HashMap::new();
let inheritance_pairs: HashSet<(String, String)> = inheritance_report
.edges
.iter()
.map(|e| normalize_class_pair(&e.child, &e.parent))
.collect();
for file_ir in call_graph.files.values() {
for class in &file_ir.classes {
let methods = get_class_methods_robust(file_ir, &class.name);
for method in &methods {
let qualified = format!("{}.{}", class.name, method.name);
let calls = file_ir
.calls
.get(&qualified)
.or_else(|| file_ir.calls.get(&method.name));
if let Some(calls) = calls {
for call in calls {
if !matches!(call.call_type, CallType::Method | CallType::Attr) {
continue;
}
if let Some(ref rt) = call.receiver_type {
if rt == &class.name {
continue;
}
let pair = normalize_class_pair(&class.name, rt);
if inheritance_pairs.contains(&pair) {
continue;
}
let metrics = pair_metrics
.entry(pair.clone())
.or_insert_with(IntimacyPairMetrics::new);
if class.name <= *rt {
metrics.a_to_b += 1;
if call.target.starts_with('_') {
metrics.a_to_b_private += 1;
}
} else {
metrics.b_to_a += 1;
if call.target.starts_with('_') {
metrics.b_to_a_private += 1;
}
}
}
}
}
}
}
}
for ((class_a, class_b), metrics) in &pair_metrics {
if metrics.total() < thresholds.intimacy_min_total {
continue;
}
if !metrics.is_bidirectional_enough(thresholds.intimacy_min_per_direction) {
continue;
}
findings.push(SmellFinding {
smell_type: SmellType::InappropriateIntimacy,
file: PathBuf::from("(cross-class)"),
name: format!("{} <-> {}", class_a, class_b),
line: 0,
reason: format!(
"Bidirectional coupling: {} -> {} ({} calls, {} private), {} -> {} ({} calls, {} private)",
class_a, class_b, metrics.a_to_b, metrics.a_to_b_private,
class_b, class_a, metrics.b_to_a, metrics.b_to_a_private
),
severity: intimacy_severity(metrics.total(), metrics.min_direction()),
suggestion: if suggest {
Some(format!(
"Consider merging {} and {} or extracting shared behavior into a third class",
class_a, class_b
))
} else {
None
},
});
}
findings
}
pub fn analyze_smells_aggregated(
path: &Path,
threshold: ThresholdPreset,
smell_type: Option<SmellType>,
suggest: bool,
) -> TldrResult<SmellsReport> {
let mut all_smells: Vec<SmellFinding> = Vec::new();
let mut files_scanned: usize = 0;
if should_run_original_detectors(smell_type) {
if let Ok(base_report) = detect_smells(path, threshold, smell_type, suggest) {
files_scanned = base_report.files_scanned;
all_smells.extend(base_report.smells);
}
}
if should_analyze_smell(smell_type, SmellType::LowCohesion) {
collect_low_cohesion_smells(path, suggest, &mut all_smells);
}
let needs_coupling = should_analyze_smell(smell_type, SmellType::TightCoupling);
let needs_tier2 = needs_tier2_analysis(smell_type);
let needs_call_graph = needs_coupling || needs_tier2;
let (root_dir, cg_language) = call_graph_context(path, needs_call_graph);
let shared_call_graph_ir = build_shared_call_graph_ir(root_dir, &cg_language, needs_call_graph);
let project_call_graph = if needs_coupling {
shared_call_graph_ir
.as_ref()
.map(crate::callgraph::builder::project_graph_from_ir_ref)
} else {
None
};
if needs_coupling {
collect_tight_coupling_smells(
path,
&cg_language,
project_call_graph.as_ref(),
suggest,
&mut all_smells,
);
}
if should_analyze_smell(smell_type, SmellType::DeadCode) {
collect_dead_code_smells(path, suggest, &mut all_smells);
}
if should_analyze_smell(smell_type, SmellType::CodeClone) {
collect_code_clone_smells(path, suggest, &mut all_smells);
}
if should_analyze_smell(smell_type, SmellType::HighCognitiveComplexity) {
collect_high_cognitive_smells(path, suggest, &mut all_smells);
}
let inheritance_report = build_inheritance_report(path, needs_tier2);
let thresholds = Thresholds::from_preset(threshold);
if should_analyze_smell(smell_type, SmellType::MiddleMan) {
collect_middle_man_smells(
shared_call_graph_ir.as_ref(),
&thresholds,
suggest,
&mut all_smells,
);
}
if should_analyze_smell(smell_type, SmellType::RefusedBequest) {
collect_refused_bequest_smells(
shared_call_graph_ir.as_ref(),
inheritance_report.as_ref(),
&thresholds,
suggest,
&mut all_smells,
);
}
if should_analyze_smell(smell_type, SmellType::FeatureEnvy) {
collect_feature_envy_smells(
shared_call_graph_ir.as_ref(),
&thresholds,
suggest,
&mut all_smells,
);
}
if should_analyze_smell(smell_type, SmellType::InappropriateIntimacy) {
collect_inappropriate_intimacy_smells(
shared_call_graph_ir.as_ref(),
inheritance_report.as_ref(),
&thresholds,
suggest,
&mut all_smells,
);
}
sort_smells(&mut all_smells);
let by_file = build_smells_by_file(&all_smells);
if files_scanned == 0 && !by_file.is_empty() {
files_scanned = by_file.len();
}
let summary = build_smells_summary(&all_smells, files_scanned);
Ok(SmellsReport {
smells: all_smells,
files_scanned,
by_file,
summary,
})
}
fn should_run_original_detectors(smell_type: Option<SmellType>) -> bool {
matches!(
smell_type,
None
| Some(SmellType::GodClass)
| Some(SmellType::LongMethod)
| Some(SmellType::LongParameterList)
| Some(SmellType::FeatureEnvy)
| Some(SmellType::DataClumps)
| Some(SmellType::DeepNesting)
| Some(SmellType::DataClass)
| Some(SmellType::LazyElement)
| Some(SmellType::MessageChain)
| Some(SmellType::PrimitiveObsession)
)
}
fn should_analyze_smell(smell_type: Option<SmellType>, target: SmellType) -> bool {
smell_type.is_none() || smell_type == Some(target)
}
fn needs_tier2_analysis(smell_type: Option<SmellType>) -> bool {
smell_type.is_none()
|| matches!(
smell_type,
Some(SmellType::MiddleMan)
| Some(SmellType::RefusedBequest)
| Some(SmellType::FeatureEnvy)
| Some(SmellType::InappropriateIntimacy)
)
}
fn call_graph_context(path: &Path, needs_call_graph: bool) -> (&Path, String) {
if !needs_call_graph {
return (path, String::new());
}
if path.is_file() {
let lang = Language::from_path(path)
.map(|l| l.to_string().to_lowercase())
.unwrap_or_else(|| "python".to_string());
return (path.parent().unwrap_or(path), lang);
}
let lang = WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.find_map(|e| Language::from_path(e.path()))
.map(|l| l.to_string().to_lowercase())
.unwrap_or_else(|| "python".to_string());
(path, lang)
}
fn build_shared_call_graph_ir(
root_dir: &Path,
cg_language: &str,
needs_call_graph: bool,
) -> Option<CallGraphIR> {
if !needs_call_graph {
return None;
}
use crate::callgraph::builder_v2::{build_project_call_graph_v2, BuildConfig};
let config = BuildConfig {
language: cg_language.to_string(),
..Default::default()
};
build_project_call_graph_v2(root_dir, config).ok()
}
fn collect_low_cohesion_smells(path: &Path, suggest: bool, all_smells: &mut Vec<SmellFinding>) {
if let Ok(cohesion_report) = crate::quality::cohesion::analyze_cohesion(path, None, 2) {
for class in &cohesion_report.classes {
if class.lcom4 < 2 {
continue;
}
all_smells.push(SmellFinding {
smell_type: SmellType::LowCohesion,
file: class.file.clone(),
name: class.name.clone(),
line: class.line as u32,
reason: format!(
"Class has LCOM4={} (>1 indicates multiple responsibilities)",
class.lcom4
),
severity: cohesion_severity(class.lcom4),
suggestion: if suggest {
class
.split_suggestion
.clone()
.or_else(|| Some("Consider splitting this class by responsibility".to_string()))
} else {
None
},
});
}
}
}
fn collect_tight_coupling_smells(
path: &Path,
cg_language: &str,
project_call_graph: Option<&crate::types::ProjectCallGraph>,
suggest: bool,
all_smells: &mut Vec<SmellFinding>,
) {
let Some(project_call_graph) = project_call_graph else {
return;
};
let lang = cg_language.parse::<Language>().unwrap_or(Language::Python);
let options = crate::quality::coupling::CouplingOptions {
max_pairs: 50,
..Default::default()
};
if let Ok(coupling_report) =
crate::quality::coupling::analyze_coupling_with_graph(path, lang, project_call_graph, &options)
{
for pair in &coupling_report.top_pairs {
if pair.score < 0.6 {
continue;
}
let source_name = pair
.source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| pair.source.display().to_string());
let target_name = pair
.target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| pair.target.display().to_string());
all_smells.push(SmellFinding {
smell_type: SmellType::TightCoupling,
file: pair.source.clone(),
name: format!("{} <-> {}", source_name, target_name),
line: 0,
reason: format!(
"Coupling score {:.2} ({} calls, {} shared imports)",
pair.score,
pair.call_count,
pair.shared_imports.len()
),
severity: coupling_severity(pair.score),
suggestion: if suggest {
Some(
"Consider introducing an interface or mediator to reduce direct coupling"
.to_string(),
)
} else {
None
},
});
}
}
}
fn collect_dead_code_smells(path: &Path, suggest: bool, all_smells: &mut Vec<SmellFinding>) {
if let Ok(dead_report) = crate::quality::dead_code::analyze_dead_code(path, None, &[]) {
for func in &dead_report.dead_functions {
all_smells.push(SmellFinding {
smell_type: SmellType::DeadCode,
file: func.file.clone(),
name: func.name.clone(),
line: func.line as u32,
reason: format!("Unreachable function ({:?})", func.reason),
severity: 1,
suggestion: if suggest {
Some("Remove this function or add a call path to it".to_string())
} else {
None
},
});
}
}
}
fn collect_code_clone_smells(path: &Path, suggest: bool, all_smells: &mut Vec<SmellFinding>) {
let sim_options = crate::quality::similarity::SimilarityOptions {
threshold: 0.6,
max_functions: 500,
max_pairs: 50,
};
if let Ok(sim_report) =
crate::quality::similarity::find_similar_with_options(path, None, &sim_options)
{
for pair in &sim_report.similar_pairs {
all_smells.push(SmellFinding {
smell_type: SmellType::CodeClone,
file: pair.func_a.file.clone(),
name: format!("{} ~ {}", pair.func_a.name, pair.func_b.name),
line: pair.func_a.line as u32,
reason: format!(
"Similarity score {:.0}% with {}:{}",
pair.score * 100.0,
pair.func_b.file.display(),
pair.func_b.line
),
severity: clone_severity(pair.score),
suggestion: if suggest {
Some("Consider extracting shared logic into a common function".to_string())
} else {
None
},
});
}
}
}
fn collect_high_cognitive_smells(path: &Path, suggest: bool, all_smells: &mut Vec<SmellFinding>) {
let complexity_options = crate::quality::complexity::ComplexityOptions {
hotspot_threshold: 10,
max_hotspots: 100,
include_cognitive: true,
};
if let Ok(complexity_report) =
crate::quality::complexity::analyze_complexity(path, None, Some(complexity_options))
{
for func in &complexity_report.functions {
if func.cognitive < 15 {
continue;
}
all_smells.push(SmellFinding {
smell_type: SmellType::HighCognitiveComplexity,
file: func.file.clone(),
name: func.name.clone(),
line: func.line as u32,
reason: format!("Cognitive complexity {} (threshold: 15)", func.cognitive),
severity: cognitive_severity(func.cognitive),
suggestion: if suggest {
Some(
"Simplify control flow, reduce nesting, or extract helper functions"
.to_string(),
)
} else {
None
},
});
}
}
}
fn build_inheritance_report(path: &Path, needs_tier2: bool) -> Option<InheritanceReport> {
if !needs_tier2 {
return None;
}
use crate::inheritance::{extract_inheritance, InheritanceOptions};
let options = InheritanceOptions::default();
extract_inheritance(path, None, &options).ok()
}
fn collect_middle_man_smells(
shared_call_graph_ir: Option<&CallGraphIR>,
thresholds: &Thresholds,
suggest: bool,
all_smells: &mut Vec<SmellFinding>,
) {
let Some(shared_call_graph_ir) = shared_call_graph_ir else {
return;
};
for file_ir in shared_call_graph_ir.files.values() {
let lang = inferred_language_name(&file_ir.path);
let findings = detect_middle_man_from_callgraph(file_ir, thresholds, &lang, suggest);
all_smells.extend(findings);
}
}
fn collect_refused_bequest_smells(
shared_call_graph_ir: Option<&CallGraphIR>,
inheritance_report: Option<&InheritanceReport>,
thresholds: &Thresholds,
suggest: bool,
all_smells: &mut Vec<SmellFinding>,
) {
let (Some(shared_call_graph_ir), Some(inheritance_report)) =
(shared_call_graph_ir, inheritance_report)
else {
return;
};
let findings =
detect_refused_bequest_from_callgraph(shared_call_graph_ir, inheritance_report, thresholds, suggest);
all_smells.extend(findings);
}
fn collect_feature_envy_smells(
shared_call_graph_ir: Option<&CallGraphIR>,
thresholds: &Thresholds,
suggest: bool,
all_smells: &mut Vec<SmellFinding>,
) {
let Some(shared_call_graph_ir) = shared_call_graph_ir else {
return;
};
for file_ir in shared_call_graph_ir.files.values() {
let lang = inferred_language_name(&file_ir.path);
let findings = detect_feature_envy_from_callgraph(file_ir, thresholds, &lang, suggest);
all_smells.extend(findings);
}
}
fn collect_inappropriate_intimacy_smells(
shared_call_graph_ir: Option<&CallGraphIR>,
inheritance_report: Option<&InheritanceReport>,
thresholds: &Thresholds,
suggest: bool,
all_smells: &mut Vec<SmellFinding>,
) {
let (Some(shared_call_graph_ir), Some(inheritance_report)) =
(shared_call_graph_ir, inheritance_report)
else {
return;
};
let findings = detect_inappropriate_intimacy_from_callgraph(
shared_call_graph_ir,
inheritance_report,
thresholds,
suggest,
);
all_smells.extend(findings);
}
fn inferred_language_name(path: &Path) -> String {
Language::from_path(path)
.map(|l| l.to_string().to_lowercase())
.unwrap_or_else(|| "python".to_string())
}
fn sort_smells(all_smells: &mut [SmellFinding]) {
all_smells.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
}
fn build_smells_by_file(all_smells: &[SmellFinding]) -> HashMap<PathBuf, Vec<SmellFinding>> {
let mut by_file: HashMap<PathBuf, Vec<SmellFinding>> = HashMap::new();
for smell in all_smells {
by_file
.entry(smell.file.clone())
.or_default()
.push(smell.clone());
}
by_file
}
fn build_smells_summary(all_smells: &[SmellFinding], files_scanned: usize) -> SmellsSummary {
let mut by_type: HashMap<String, usize> = HashMap::new();
for smell in all_smells {
*by_type.entry(smell.smell_type.to_string()).or_insert(0) += 1;
}
SmellsSummary {
total_smells: all_smells.len(),
by_type,
avg_smells_per_file: if files_scanned > 0 {
all_smells.len() as f64 / files_scanned as f64
} else {
0.0
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thresholds_default() {
let t = Thresholds::from_preset(ThresholdPreset::Default);
assert_eq!(t.god_class_methods, 20);
assert_eq!(t.god_class_loc, 500);
assert_eq!(t.long_method_loc, 50);
assert_eq!(t.long_method_complexity, 10);
assert_eq!(t.long_param_count, 5);
}
#[test]
fn test_thresholds_strict() {
let t = Thresholds::from_preset(ThresholdPreset::Strict);
assert!(t.god_class_methods < 20);
assert!(t.long_method_loc < 50);
}
#[test]
fn test_thresholds_relaxed() {
let t = Thresholds::from_preset(ThresholdPreset::Relaxed);
assert!(t.god_class_methods > 20);
assert!(t.long_method_loc > 50);
}
#[test]
fn test_severity_calculation() {
assert_eq!(calculate_severity(6, 5), 1); assert_eq!(calculate_severity(8, 5), 2); assert_eq!(calculate_severity(11, 5), 3); }
#[test]
fn test_smell_type_display() {
assert_eq!(SmellType::GodClass.to_string(), "God Class");
assert_eq!(SmellType::LongMethod.to_string(), "Long Method");
assert_eq!(SmellType::LongParameterList.to_string(), "Long Parameter List");
}
#[test]
fn test_new_smell_type_variants_exist() {
let _ = SmellType::LowCohesion;
let _ = SmellType::TightCoupling;
let _ = SmellType::DeadCode;
let _ = SmellType::CodeClone;
let _ = SmellType::HighCognitiveComplexity;
}
#[test]
fn test_new_smell_type_display() {
assert_eq!(SmellType::LowCohesion.to_string(), "Low Cohesion");
assert_eq!(SmellType::TightCoupling.to_string(), "Tight Coupling");
assert_eq!(SmellType::DeadCode.to_string(), "Dead Code");
assert_eq!(SmellType::CodeClone.to_string(), "Code Clone");
assert_eq!(SmellType::HighCognitiveComplexity.to_string(), "High Cognitive Complexity");
}
#[test]
fn test_new_smell_types_serialize() {
let json = serde_json::to_string(&SmellType::LowCohesion).unwrap();
assert_eq!(json, "\"low_cohesion\"");
let json = serde_json::to_string(&SmellType::TightCoupling).unwrap();
assert_eq!(json, "\"tight_coupling\"");
let json = serde_json::to_string(&SmellType::DeadCode).unwrap();
assert_eq!(json, "\"dead_code\"");
let json = serde_json::to_string(&SmellType::CodeClone).unwrap();
assert_eq!(json, "\"code_clone\"");
let json = serde_json::to_string(&SmellType::HighCognitiveComplexity).unwrap();
assert_eq!(json, "\"high_cognitive_complexity\"");
}
#[test]
fn test_cohesion_severity_mapping() {
assert_eq!(cohesion_severity(6), 3);
assert_eq!(cohesion_severity(7), 3);
assert_eq!(cohesion_severity(4), 2);
assert_eq!(cohesion_severity(5), 2);
assert_eq!(cohesion_severity(2), 1);
assert_eq!(cohesion_severity(3), 1);
}
#[test]
fn test_coupling_severity_mapping() {
assert_eq!(coupling_severity(0.9), 2);
assert_eq!(coupling_severity(0.8), 2);
assert_eq!(coupling_severity(0.7), 1);
assert_eq!(coupling_severity(0.6), 1);
}
#[test]
fn test_cognitive_severity_mapping() {
assert_eq!(cognitive_severity(30), 3);
assert_eq!(cognitive_severity(35), 3);
assert_eq!(cognitive_severity(20), 2);
assert_eq!(cognitive_severity(25), 2);
assert_eq!(cognitive_severity(15), 1);
assert_eq!(cognitive_severity(18), 1);
}
#[test]
fn test_clone_severity_mapping() {
assert_eq!(clone_severity(0.85), 2);
assert_eq!(clone_severity(0.81), 2);
assert_eq!(clone_severity(0.7), 1);
assert_eq!(clone_severity(0.61), 1);
}
#[test]
fn test_analyze_smells_aggregated_exists() {
let dir = std::env::temp_dir().join("tldr_smells_test_empty");
let _ = std::fs::create_dir_all(&dir);
let result = analyze_smells_aggregated(
&dir,
ThresholdPreset::Default,
None,
false,
);
assert!(result.is_ok());
let report = result.unwrap();
assert_eq!(report.smells.len(), 0);
assert_eq!(report.files_scanned, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_analyze_smells_aggregated_filter_new_types() {
let dir = std::env::temp_dir().join("tldr_smells_test_filter");
let _ = std::fs::create_dir_all(&dir);
let result = analyze_smells_aggregated(
&dir,
ThresholdPreset::Default,
Some(SmellType::DeadCode),
false,
);
assert!(result.is_ok());
let report = result.unwrap();
for smell in &report.smells {
assert_eq!(smell.smell_type, SmellType::DeadCode);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_backward_compatibility_detect_smells_unchanged() {
let dir = std::env::temp_dir().join("tldr_smells_test_compat");
let _ = std::fs::create_dir_all(&dir);
let result = detect_smells(
&dir,
ThresholdPreset::Default,
None,
false,
);
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_deep_nesting_variant_exists() {
let _ = SmellType::DeepNesting;
assert_eq!(SmellType::DeepNesting.to_string(), "Deep Nesting");
}
#[test]
fn test_deep_nesting_serialize() {
let json = serde_json::to_string(&SmellType::DeepNesting).unwrap();
assert_eq!(json, "\"deep_nesting\"");
}
#[test]
fn test_detect_deep_nesting_python() {
let source = r#"
def deeply_nested():
if True:
for i in range(10):
while True:
try:
if x > 0:
print("deep")
except:
pass
"#;
let findings = detect_deep_nesting(source, "python");
assert!(!findings.is_empty(), "Should detect deep nesting (depth >= 5)");
assert_eq!(findings[0].smell_type, SmellType::DeepNesting);
assert!(findings[0].severity >= 1);
}
#[test]
fn test_detect_deep_nesting_rust() {
let source = r#"
fn deeply_nested() {
if true {
for i in 0..10 {
while true {
if let Some(x) = foo() {
if x > 0 {
println!("deep");
}
}
}
}
}
}
"#;
let findings = detect_deep_nesting(source, "rust");
assert!(!findings.is_empty(), "Should detect deep nesting in Rust");
}
#[test]
fn test_no_deep_nesting_shallow() {
let source = r#"
def shallow():
if True:
for i in range(10):
print(i)
"#;
let findings = detect_deep_nesting(source, "python");
assert!(findings.is_empty(), "Shallow code should not trigger deep nesting smell");
}
#[test]
fn test_deep_nesting_severity_levels() {
assert_eq!(nesting_severity(8), 3);
assert_eq!(nesting_severity(10), 3);
assert_eq!(nesting_severity(6), 2);
assert_eq!(nesting_severity(7), 2);
assert_eq!(nesting_severity(5), 1);
}
#[test]
fn test_data_class_variant_exists() {
let _ = SmellType::DataClass;
assert_eq!(SmellType::DataClass.to_string(), "Data Class");
}
#[test]
fn test_data_class_serialize() {
let json = serde_json::to_string(&SmellType::DataClass).unwrap();
assert_eq!(json, "\"data_class\"");
}
#[test]
fn test_detect_data_class_python() {
let source = r#"
class UserData:
def __init__(self):
self.name = ""
self.email = ""
self.age = 0
self.address = ""
self.phone = ""
"#;
let findings = detect_data_classes(source, "python");
assert!(!findings.is_empty(), "Class with 5 fields and 1 method should be a data class");
assert_eq!(findings[0].smell_type, SmellType::DataClass);
}
#[test]
fn test_no_data_class_with_methods() {
let source = r#"
class UserService:
def __init__(self):
self.name = ""
self.email = ""
def validate(self):
pass
def save(self):
pass
def send_email(self):
pass
"#;
let findings = detect_data_classes(source, "python");
assert!(findings.is_empty(), "Class with many methods should not be a data class");
}
#[test]
fn test_data_class_severity_levels() {
assert_eq!(data_class_severity(8, 0), 2);
assert_eq!(data_class_severity(10, 0), 2);
assert_eq!(data_class_severity(4, 1), 1);
assert_eq!(data_class_severity(5, 2), 1);
}
#[test]
fn test_lazy_element_variant_exists() {
let _ = SmellType::LazyElement;
assert_eq!(SmellType::LazyElement.to_string(), "Lazy Element");
}
#[test]
fn test_lazy_element_serialize() {
let json = serde_json::to_string(&SmellType::LazyElement).unwrap();
assert_eq!(json, "\"lazy_element\"");
}
#[test]
fn test_detect_lazy_element_python() {
let source = r#"
class Wrapper:
def do_thing(self):
pass
"#;
let findings = detect_lazy_elements(source, "python");
assert!(!findings.is_empty(), "Class with 1 method and 0 fields should be a lazy element");
assert_eq!(findings[0].smell_type, SmellType::LazyElement);
assert_eq!(findings[0].severity, 1);
}
#[test]
fn test_no_lazy_element_with_enough_content() {
let source = r#"
class Service:
def __init__(self):
self.name = ""
self.id = 0
def run(self):
pass
def stop(self):
pass
"#;
let findings = detect_lazy_elements(source, "python");
assert!(findings.is_empty(), "Class with 2+ methods and 2+ fields should not be lazy");
}
#[test]
fn test_message_chain_variant_exists() {
let _ = SmellType::MessageChain;
assert_eq!(SmellType::MessageChain.to_string(), "Message Chain");
}
#[test]
fn test_message_chain_serialize() {
let json = serde_json::to_string(&SmellType::MessageChain).unwrap();
assert_eq!(json, "\"message_chain\"");
}
#[test]
fn test_detect_message_chains_python() {
let source = r#"
def process():
result = obj.get_manager().get_department().get_employees().get_first().name
"#;
let findings = detect_message_chains(source, "python");
assert!(!findings.is_empty(), "Should detect long method chain (> 3 calls)");
assert_eq!(findings[0].smell_type, SmellType::MessageChain);
}
#[test]
fn test_no_message_chain_short() {
let source = r#"
def simple():
result = obj.get_name().strip()
"#;
let findings = detect_message_chains(source, "python");
assert!(findings.is_empty(), "Short chains (<=3) should not trigger");
}
#[test]
fn test_message_chain_severity_levels() {
assert_eq!(chain_severity(6), 2);
assert_eq!(chain_severity(8), 2);
assert_eq!(chain_severity(4), 1);
assert_eq!(chain_severity(5), 1);
}
#[test]
fn test_primitive_obsession_variant_exists() {
let _ = SmellType::PrimitiveObsession;
assert_eq!(SmellType::PrimitiveObsession.to_string(), "Primitive Obsession");
}
#[test]
fn test_primitive_obsession_serialize() {
let json = serde_json::to_string(&SmellType::PrimitiveObsession).unwrap();
assert_eq!(json, "\"primitive_obsession\"");
}
#[test]
fn test_detect_primitive_obsession_python() {
let source = r#"
def create_user(name: str, email: str, age: int, phone: str, active: bool):
pass
"#;
let findings = detect_primitive_obsession(source, "python");
assert!(!findings.is_empty(), "Function with 5 primitive params should trigger");
assert_eq!(findings[0].smell_type, SmellType::PrimitiveObsession);
}
#[test]
fn test_detect_primitive_obsession_rust() {
let source = r#"
fn create_user(name: &str, email: String, age: i32, phone: String, active: bool, score: f64) {
}
"#;
let findings = detect_primitive_obsession(source, "rust");
assert!(!findings.is_empty(), "Function with 6 primitive params should trigger in Rust");
assert!(findings[0].severity >= 2, "6 primitives should have severity >= 2");
}
#[test]
fn test_no_primitive_obsession_with_domain_types() {
let source = r#"
def create_user(config: UserConfig, permissions: PermissionSet):
pass
"#;
let findings = detect_primitive_obsession(source, "python");
assert!(findings.is_empty(), "Non-primitive params should not trigger");
}
#[test]
fn test_primitive_obsession_severity_levels() {
assert_eq!(primitive_obsession_severity(6), 2);
assert_eq!(primitive_obsession_severity(8), 2);
assert_eq!(primitive_obsession_severity(4), 1);
assert_eq!(primitive_obsession_severity(5), 1);
}
#[test]
fn test_detect_smells_includes_new_types_on_files() {
let dir = std::env::temp_dir().join("tldr_smells_new_types_test");
let _ = std::fs::create_dir_all(&dir);
let py_file = dir.join("nested.py");
std::fs::write(&py_file, r#"
def deeply_nested():
if True:
for i in range(10):
while True:
try:
if x > 0:
print("deep")
except:
pass
"#).unwrap();
let result = detect_smells(
&dir,
ThresholdPreset::Default,
Some(SmellType::DeepNesting),
false,
);
assert!(result.is_ok());
let report = result.unwrap();
for smell in &report.smells {
assert_eq!(smell.smell_type, SmellType::DeepNesting);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_aggregated_includes_new_types() {
let dir = std::env::temp_dir().join("tldr_smells_new_agg_test");
let _ = std::fs::create_dir_all(&dir);
let py_file = dir.join("data.py");
std::fs::write(&py_file, r#"
class BigDataBag:
def __init__(self):
self.a = 1
self.b = 2
self.c = 3
self.d = 4
self.e = 5
self.f = 6
self.g = 7
self.h = 8
"#).unwrap();
let result = analyze_smells_aggregated(
&dir,
ThresholdPreset::Default,
Some(SmellType::DataClass),
false,
);
assert!(result.is_ok());
let report = result.unwrap();
for smell in &report.smells {
assert_eq!(smell.smell_type, SmellType::DataClass);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_detectors_handle_invalid_source() {
let bad_source = "{{{{not valid code at all}}}}";
assert!(detect_deep_nesting(bad_source, "python").is_empty());
assert!(detect_data_classes(bad_source, "python").is_empty());
assert!(detect_lazy_elements(bad_source, "python").is_empty());
assert!(detect_message_chains(bad_source, "python").is_empty());
assert!(detect_primitive_obsession(bad_source, "python").is_empty());
}
#[test]
fn test_detectors_handle_unknown_language() {
let source = "print('hello')";
assert!(detect_deep_nesting(source, "brainfuck").is_empty());
assert!(detect_data_classes(source, "brainfuck").is_empty());
assert!(detect_lazy_elements(source, "brainfuck").is_empty());
assert!(detect_message_chains(source, "brainfuck").is_empty());
assert!(detect_primitive_obsession(source, "brainfuck").is_empty());
}
#[test]
fn test_middle_man_variant_exists() {
let _ = SmellType::MiddleMan;
assert_eq!(SmellType::MiddleMan.to_string(), "Middle Man");
}
#[test]
fn test_middle_man_serialize() {
let json = serde_json::to_string(&SmellType::MiddleMan).unwrap();
assert_eq!(json, "\"middle_man\"");
}
type MethodCallTriple<'a> = (&'a str, &'a str, &'a str);
type MiddleManMethod<'a> = (&'a str, Vec<MethodCallTriple<'a>>);
fn build_middle_man_file_ir(
class_name: &str,
constructor_name: Option<&str>,
methods: Vec<MiddleManMethod<'_>>,
) -> FileIR {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
let mut method_names: Vec<String> = Vec::new();
let mut line = 1u32;
if let Some(ctor) = constructor_name {
method_names.push(ctor.to_string());
file_ir.funcs.push(FuncDef::method(ctor, class_name, line, line + 2));
line += 3;
}
for (method_name, calls) in &methods {
method_names.push(method_name.to_string());
file_ir.funcs.push(FuncDef::method(*method_name, class_name, line, line + 2));
let qualified = format!("{}.{}", class_name, method_name);
let call_sites: Vec<CallSite> = calls.iter().map(|(target, receiver, receiver_type)| {
CallSite::method(
qualified.clone(),
*target,
*receiver,
if receiver_type.is_empty() { None } else { Some(receiver_type.to_string()) },
Some(line + 1),
)
}).collect();
if !call_sites.is_empty() {
file_ir.calls.insert(qualified, call_sites);
}
line += 3;
}
file_ir.classes.push(ClassDef::new(
class_name.to_string(),
1,
line,
method_names,
vec![],
));
file_ir
}
#[test]
fn test_detect_middle_man_pure_delegator() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("__init__"),
vec![
("get_total", vec![("get_total", "order", "Order")]),
("get_items", vec![("get_items", "order", "Order")]),
("get_customer", vec![("get_customer", "order", "Order")]),
("get_status", vec![("get_status", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "Class with 4/4 delegating methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
assert!(findings[0].severity >= 2, "100% delegation with 4 methods should have severity >= 2");
}
#[test]
fn test_no_middle_man_mixed_logic() {
let file_ir = {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut fir = FileIR::new(PathBuf::from("test.py"));
fir.funcs.push(FuncDef::method("__init__", "OrderService", 1, 3));
fir.funcs.push(FuncDef::method("get_total", "OrderService", 4, 6));
fir.calls.insert("OrderService.get_total".to_string(), vec![
CallSite::method("OrderService.get_total", "get_total", "order", Some("Order".to_string()), Some(5)),
]);
fir.funcs.push(FuncDef::method("validate", "OrderService", 7, 10));
fir.calls.insert("OrderService.validate".to_string(), vec![
CallSite::method("OrderService.validate", "get_total", "self", None, Some(8)),
CallSite::method("OrderService.validate", "get_total", "order", Some("Order".to_string()), Some(9)),
]);
fir.funcs.push(FuncDef::method("process_payment", "OrderService", 11, 14));
fir.calls.insert("OrderService.process_payment".to_string(), vec![
CallSite::method("OrderService.process_payment", "get_total", "self", None, Some(12)),
]);
fir.classes.push(ClassDef::new(
"OrderService".to_string(), 1, 14,
vec!["__init__".to_string(), "get_total".to_string(), "validate".to_string(), "process_payment".to_string()],
vec![],
));
fir
};
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Class with 1/3 delegation (33%) should not be middle man");
}
#[test]
fn test_middle_man_threshold_boundary() {
let file_ir = {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut fir = FileIR::new(PathBuf::from("test.py"));
fir.funcs.push(FuncDef::method("__init__", "Delegator", 1, 3));
for (i, name) in ["method1", "method2", "method3"].iter().enumerate() {
let line = (4 + i * 3) as u32;
fir.funcs.push(FuncDef::method(*name, "Delegator", line, line + 2));
fir.calls.insert(format!("Delegator.{}", name), vec![
CallSite::method(
format!("Delegator.{}", name), *name, "backend",
Some("Backend".to_string()), Some(line + 1),
),
]);
}
fir.funcs.push(FuncDef::method("method4", "Delegator", 13, 16));
fir.funcs.push(FuncDef::method("method5", "Delegator", 17, 19));
fir.classes.push(ClassDef::new(
"Delegator".to_string(), 1, 19,
vec!["__init__".to_string(), "method1".to_string(), "method2".to_string(),
"method3".to_string(), "method4".to_string(), "method5".to_string()],
vec![],
));
fir
};
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "60% delegation at default threshold (60%) should trigger middle man");
}
#[test]
fn test_middle_man_too_small_class() {
let file_ir = build_middle_man_file_ir(
"TinyHelper",
Some("__init__"),
vec![
("do_thing", vec![("do_thing", "obj", "Target")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Single non-constructor method class should not trigger middle man (below min_methods)");
}
#[test]
fn test_middle_man_constructor_excluded() {
let file_ir = build_middle_man_file_ir(
"Forwarder",
Some("__init__"),
vec![
("forward1", vec![("method1", "target", "Target")]),
("forward2", vec![("method2", "target", "Target")]),
("forward3", vec![("method3", "target", "Target")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "Constructor should be excluded from method count; 3/3 delegation = 100%");
}
#[test]
fn test_middle_man_facade_exclusion() {
let file_ir = build_middle_man_file_ir(
"UserFacade",
Some("__init__"),
vec![
("get_user", vec![("get_user", "repo", "UserRepo")]),
("save_user", vec![("save_user", "repo", "UserRepo")]),
("delete_user", vec![("delete_user", "repo", "UserRepo")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Class named 'UserFacade' should be excluded by facade heuristic");
}
#[test]
fn test_middle_man_at_threshold() {
let file_ir = build_middle_man_file_ir(
"ExactBoundary",
None, vec![
("delegate1", vec![("op1", "svc", "Service")]),
("delegate2", vec![("op2", "svc", "Service")]),
("delegate3", vec![("op3", "svc", "Service")]),
("real_logic1", vec![]), ("real_logic2", vec![]), ],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "3/5 = 60% should trigger at default 60% threshold");
let file_ir_below = build_middle_man_file_ir(
"BelowBoundary",
None,
vec![
("delegate1", vec![("op1", "svc", "Service")]),
("delegate2", vec![("op2", "svc", "Service")]),
("real_logic1", vec![]),
("real_logic2", vec![]),
("real_logic3", vec![]),
],
);
let findings_below = detect_middle_man_from_callgraph(&file_ir_below, &thresholds, "python", false);
assert!(findings_below.is_empty(), "2/5 = 40% should NOT trigger at default 60% threshold");
}
#[test]
fn test_middle_man_severity_levels_integration() {
let file_ir_sev1 = build_middle_man_file_ir(
"MildDelegator",
None,
vec![
("d1", vec![("op1", "svc", "Service")]),
("d2", vec![("op2", "svc", "Service")]),
("d3", vec![("op3", "svc", "Service")]),
("real1", vec![]),
("real2", vec![]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings1 = detect_middle_man_from_callgraph(&file_ir_sev1, &thresholds, "python", false);
assert!(!findings1.is_empty());
assert_eq!(findings1[0].severity, 1, "60% with 3 delegators = severity 1");
let file_ir_sev2 = build_middle_man_file_ir(
"HeavyDelegator",
None,
vec![
("d1", vec![("op1", "svc", "Service")]),
("d2", vec![("op2", "svc", "Service")]),
("d3", vec![("op3", "svc", "Service")]),
("d4", vec![("op4", "svc", "Service")]),
("real1", vec![]),
],
);
let findings2 = detect_middle_man_from_callgraph(&file_ir_sev2, &thresholds, "python", false);
assert!(!findings2.is_empty());
assert_eq!(findings2[0].severity, 2, "80% with 4 delegators = severity 2");
let file_ir_sev3 = build_middle_man_file_ir(
"TotalDelegator",
None,
vec![
("d1", vec![("op1", "svc", "Service")]),
("d2", vec![("op2", "svc", "Service")]),
("d3", vec![("op3", "svc", "Service")]),
("d4", vec![("op4", "svc", "Service")]),
("d5", vec![("op5", "svc", "Service")]),
("d6", vec![("op6", "svc", "Service")]),
],
);
let findings3 = detect_middle_man_from_callgraph(&file_ir_sev3, &thresholds, "python", false);
assert!(!findings3.is_empty());
assert_eq!(findings3[0].severity, 3, "100% with 6 delegators = severity 3");
}
#[test]
fn test_refused_bequest_variant_exists() {
let _ = SmellType::RefusedBequest;
assert_eq!(SmellType::RefusedBequest.to_string(), "Refused Bequest");
}
#[test]
fn test_refused_bequest_serialize() {
let json = serde_json::to_string(&SmellType::RefusedBequest).unwrap();
assert_eq!(json, "\"refused_bequest\"");
}
fn build_refused_bequest_test_data(
parent_name: &str,
parent_methods: &[&str],
child_name: &str,
child_methods: &[&str],
edge_builder: impl FnOnce() -> crate::types::inheritance::InheritanceEdge,
parent_node_builder: impl FnOnce() -> crate::types::inheritance::InheritanceNode,
child_calls: &[(/* child_method */ &str, /* call_target */ &str)],
) -> (CallGraphIR, InheritanceReport) {
use crate::callgraph::cross_file_types::{ClassDef, FuncDef, CallSite, CallType};
use crate::types::inheritance::{InheritanceReport, InheritanceNode};
let parent_file_path = PathBuf::from("parent.py");
let child_file_path = PathBuf::from("child.py");
let mut parent_file_ir = FileIR::new(parent_file_path.clone());
parent_file_ir.classes.push(ClassDef::new(
parent_name.to_string(),
1,
(parent_methods.len() as u32 * 3) + 1,
parent_methods.iter().map(|m| m.to_string()).collect(),
vec![],
));
for (i, method_name) in parent_methods.iter().enumerate() {
let line = (i as u32 * 3) + 2;
parent_file_ir.funcs.push(FuncDef::method(
*method_name,
parent_name,
line,
line + 2,
));
}
let mut child_file_ir = FileIR::new(child_file_path.clone());
child_file_ir.classes.push(ClassDef::new(
child_name.to_string(),
1,
(child_methods.len() as u32 * 3) + 1,
child_methods.iter().map(|m| m.to_string()).collect(),
vec![parent_name.to_string()],
));
for (i, method_name) in child_methods.iter().enumerate() {
let line = (i as u32 * 3) + 2;
child_file_ir.funcs.push(FuncDef::method(
*method_name,
child_name,
line,
line + 2,
));
}
for (child_method, call_target) in child_calls {
let qualified = format!("{}.{}", child_name, child_method);
child_file_ir.add_call(
&qualified,
CallSite::new(
qualified.clone(),
call_target.to_string(),
CallType::Method,
Some(2),
None,
Some("super".to_string()),
Some(parent_name.to_string()),
),
);
}
let mut cg = CallGraphIR::new(PathBuf::from("."), "python");
cg.files.insert(parent_file_path, parent_file_ir);
cg.files.insert(child_file_path, child_file_ir);
let edge = edge_builder();
let parent_node = parent_node_builder();
let child_node = InheritanceNode::new(
child_name,
PathBuf::from("child.py"),
1,
crate::types::Language::Python,
).with_base(parent_name.to_string());
let ir = InheritanceReport {
edges: vec![edge],
nodes: vec![parent_node, child_node],
roots: vec![parent_name.to_string()],
leaves: vec![child_name.to_string()],
count: 2,
languages: vec![crate::types::Language::Python],
diamonds: vec![],
project_path: PathBuf::from("."),
scan_time_ms: 0,
};
(cg, ir)
}
#[test]
fn test_detect_refused_bequest() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let parent_methods: Vec<&str> = (1..=10).map(|i| match i {
1 => "method1", 2 => "method2", 3 => "method3", 4 => "method4",
5 => "method5", 6 => "method6", 7 => "method7", 8 => "method8",
9 => "method9", _ => "method10",
}).collect();
let (cg, ir) = build_refused_bequest_test_data(
"BaseService",
&parent_methods,
"ChildService",
&["method1", "custom_method"],
|| InheritanceEdge::project(
"ChildService", "BaseService",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"BaseService", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[], );
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(!findings.is_empty(), "Subclass using 1/10 (10%) of inherited methods should trigger");
assert_eq!(findings[0].smell_type, SmellType::RefusedBequest);
}
#[test]
fn test_no_refused_bequest_good_inheritance() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"Animal",
&["eat", "sleep", "move_around"],
"Dog",
&["eat", "sleep", "move_around", "bark"],
|| InheritanceEdge::project(
"Dog", "Animal",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"Animal", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[], );
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Subclass using 100% of inherited methods should not trigger");
}
#[test]
fn test_refused_bequest_skip_external() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"SomeExternalBase",
&["ext_method1", "ext_method2", "ext_method3", "ext_method4"],
"MyClass",
&["my_method"],
|| InheritanceEdge::unresolved(
"MyClass", "SomeExternalBase",
PathBuf::from("child.py"), 1,
),
|| InheritanceNode::new(
"SomeExternalBase", PathBuf::from("external.py"), 1,
crate::types::Language::Python,
),
&[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Should skip when base class is external/unresolved");
}
#[test]
fn test_refused_bequest_multiple_bases() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode, InheritanceReport};
use crate::callgraph::cross_file_types::{ClassDef, FuncDef};
let parent1_path = PathBuf::from("base1.py");
let _parent2_path = PathBuf::from("base2.py");
let child_path = PathBuf::from("child.py");
let mut base1_ir = FileIR::new(parent1_path.clone());
base1_ir.classes.push(ClassDef::new(
"Base1".to_string(), 1, 15,
vec!["method1".into(), "method2".into(), "method3".into(), "method4".into()],
vec![],
));
for (i, name) in ["method1", "method2", "method3", "method4"].iter().enumerate() {
let line = (i as u32 * 3) + 2;
base1_ir.funcs.push(FuncDef::method(*name, "Base1", line, line + 2));
}
let mut child_ir = FileIR::new(child_path.clone());
child_ir.classes.push(ClassDef::new(
"Child".to_string(), 1, 10,
vec!["method1".into(), "custom".into()],
vec!["Base1".into()],
));
child_ir.funcs.push(FuncDef::method("method1", "Child", 2, 4));
child_ir.funcs.push(FuncDef::method("custom", "Child", 5, 7));
let mut cg = CallGraphIR::new(PathBuf::from("."), "python");
cg.files.insert(parent1_path.clone(), base1_ir);
cg.files.insert(child_path, child_ir);
let edge = InheritanceEdge::project(
"Child", "Base1",
PathBuf::from("child.py"), 1,
parent1_path, 1,
);
let base1_node = InheritanceNode::new(
"Base1", PathBuf::from("base1.py"), 1, crate::types::Language::Python,
);
let child_node = InheritanceNode::new(
"Child", PathBuf::from("child.py"), 1, crate::types::Language::Python,
).with_base("Base1".to_string());
let ir = InheritanceReport {
edges: vec![edge],
nodes: vec![base1_node, child_node],
roots: vec!["Base1".to_string()],
leaves: vec!["Child".to_string()],
count: 2,
languages: vec![crate::types::Language::Python],
diamonds: vec![],
project_path: PathBuf::from("."),
scan_time_ms: 0,
};
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(!findings.is_empty(), "1/4 methods used (25%) should trigger refused bequest");
}
#[test]
fn test_refused_bequest_override_is_use() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"Base",
&["method1", "method2", "method3", "method4", "method5"],
"Child",
&["method1", "method2"], || InheritanceEdge::project(
"Child", "Base",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"Base", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[], );
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Overriding counts as using - 2/5 = 40% should not trigger (threshold 33%)");
}
#[test]
fn test_refused_bequest_abstract_parent_excluded() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"AbstractBase",
&["method1", "method2", "method3", "method4", "method5"],
"ConcreteChild",
&["custom_only"], || InheritanceEdge::project(
"ConcreteChild", "AbstractBase",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"AbstractBase", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
).as_abstract(), &[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Abstract parent should be excluded from refused bequest check");
}
#[test]
fn test_refused_bequest_go_embedding_excluded() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode, InheritanceKind};
let (cg, ir) = build_refused_bequest_test_data(
"BaseStruct",
&["Method1", "Method2", "Method3", "Method4"],
"ChildStruct",
&["CustomMethod"], || InheritanceEdge::project(
"ChildStruct", "BaseStruct",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
).with_kind(InheritanceKind::Embeds), || InheritanceNode::new(
"BaseStruct", PathBuf::from("parent.py"), 1,
crate::types::Language::Go,
),
&[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Go embedding (Embeds kind) should be excluded from refused bequest check");
}
#[test]
fn test_refused_bequest_override_counts_as_usage() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"Parent",
&["render", "validate", "save", "delete", "archive"],
"SpecialChild",
&["render", "validate", "save"], || InheritanceEdge::project(
"SpecialChild", "Parent",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"Parent", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[], );
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "3/5 overrides (60%) should not trigger (threshold 33%)");
}
#[test]
fn test_refused_bequest_mixin_parent_excluded() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"LoggingMixin",
&["log_info", "log_debug", "log_error", "log_warning"],
"MyService",
&["process"], || InheritanceEdge::project(
"MyService", "LoggingMixin",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"LoggingMixin", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
).as_mixin(), &[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Mixin parent should be excluded from refused bequest check");
}
#[test]
fn test_refused_bequest_protocol_parent_excluded() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"Renderable",
&["render", "to_html", "to_json", "to_xml"],
"SimpleRenderer",
&["custom_render"], || InheritanceEdge::project(
"SimpleRenderer", "Renderable",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"Renderable", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
).as_protocol(), &[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Protocol parent should be excluded from refused bequest check");
}
#[test]
fn test_refused_bequest_implements_kind_excluded() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode, InheritanceKind};
let (cg, ir) = build_refused_bequest_test_data(
"Repository",
&["find", "save", "delete", "update"],
"UserRepo",
&["custom_find"], || InheritanceEdge::project(
"UserRepo", "Repository",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
).with_kind(InheritanceKind::Implements),
|| InheritanceNode::new(
"Repository", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
).as_interface(),
&[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Interface implementation (Implements kind) should be excluded");
}
#[test]
fn test_refused_bequest_with_suggestion() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let parent_methods: Vec<&str> = (1..=10).map(|i| match i {
1 => "method1", 2 => "method2", 3 => "method3", 4 => "method4",
5 => "method5", 6 => "method6", 7 => "method7", 8 => "method8",
9 => "method9", _ => "method10",
}).collect();
let (cg, ir) = build_refused_bequest_test_data(
"BaseService",
&parent_methods,
"ChildService",
&["method1", "custom_method"],
|| InheritanceEdge::project(
"ChildService", "BaseService",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"BaseService", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, true);
assert!(!findings.is_empty(), "Should trigger with suggest=true");
assert!(findings[0].suggestion.is_some(), "Should include a suggestion");
let suggestion = findings[0].suggestion.as_ref().unwrap();
assert!(suggestion.contains("composition"), "Suggestion should mention composition");
}
#[test]
fn test_refused_bequest_child_calling_parent_method_is_usage() {
use crate::types::inheritance::{InheritanceEdge, InheritanceNode};
let (cg, ir) = build_refused_bequest_test_data(
"Base",
&["method1", "method2", "method3", "method4", "method5"],
"Child",
&["do_work", "do_other"], || InheritanceEdge::project(
"Child", "Base",
PathBuf::from("child.py"), 1,
PathBuf::from("parent.py"), 1,
),
|| InheritanceNode::new(
"Base", PathBuf::from("parent.py"), 1,
crate::types::Language::Python,
),
&[
("do_work", "method1"), ("do_other", "method2"), ],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Calling 2/5 parent methods (40%) should not trigger");
}
#[test]
fn test_feature_envy_variant_exists() {
let _ = SmellType::FeatureEnvy;
assert_eq!(SmellType::FeatureEnvy.to_string(), "Feature Envy");
}
#[test]
fn test_feature_envy_serialize() {
let json = serde_json::to_string(&SmellType::FeatureEnvy).unwrap();
assert_eq!(json, "\"feature_envy\"");
}
type FeatureEnvyMethod<'a> = (&'a str, bool, Vec<MethodCallTriple<'a>>);
fn build_feature_envy_file_ir(
class_name: &str,
constructor_name: Option<&str>,
methods: Vec<FeatureEnvyMethod<'_>>,
) -> FileIR {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
let mut method_names: Vec<String> = Vec::new();
let mut line = 1u32;
if let Some(ctor) = constructor_name {
method_names.push(ctor.to_string());
file_ir.funcs.push(FuncDef::method(ctor, class_name, line, line + 2));
line += 3;
}
for (method_name, is_method, calls) in &methods {
method_names.push(method_name.to_string());
if *is_method {
file_ir.funcs.push(FuncDef::method(*method_name, class_name, line, line + 5));
} else {
file_ir.funcs.push(FuncDef::new(
method_name.to_string(),
line,
line + 5,
false,
None,
None,
None,
));
}
let qualified = format!("{}.{}", class_name, method_name);
let call_sites: Vec<CallSite> = calls.iter().map(|(target, receiver, receiver_type)| {
CallSite::method(
qualified.clone(),
*target,
*receiver,
if receiver_type.is_empty() { None } else { Some(receiver_type.to_string()) },
Some(line + 1),
)
}).collect();
if !call_sites.is_empty() {
file_ir.calls.insert(qualified, call_sites);
}
line += 6;
}
file_ir.classes.push(ClassDef::new(
class_name.to_string(),
1,
line,
method_names,
vec![],
));
file_ir
}
#[test]
fn test_detect_feature_envy_field_access() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("__init__"),
vec![
("calculate_discount", true, vec![
("loyalty_points", "customer", "Customer"),
("discount_rate", "customer", "Customer"),
("years_active", "customer", "Customer"),
("bonus_multiplier", "customer", "Customer"),
("amount", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "Method accessing other class 4 times vs own 1 time should trigger");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_no_feature_envy_own_fields() {
let file_ir = build_feature_envy_file_ir(
"Account",
Some("__init__"),
vec![
("calculate_interest", true, vec![
("balance", "self", ""),
("rate", "self", ""),
("fees", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Method using only own fields should not trigger");
}
#[test]
fn test_feature_envy_method_calls() {
let file_ir = build_feature_envy_file_ir(
"Report",
None,
vec![
("generate", true, vec![
("get_title", "data", "DataSource"),
("get_author", "data", "DataSource"),
("get_content", "data", "DataSource"),
("get_footer", "data", "DataSource"),
("format_output", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "4 external vs 1 own method call should trigger");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_mixed_ratio() {
let file_ir = build_feature_envy_file_ir(
"Processor",
Some("__init__"),
vec![
("process", true, vec![
("value", "item", "Item"),
("weight", "item", "Item"),
("get_config", "self", ""),
("transform", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "2 foreign vs 2 own (ratio 1:1) should not trigger at default threshold (2.0)");
}
#[test]
fn test_feature_envy_static_excluded() {
let file_ir = build_feature_envy_file_ir(
"Util",
None,
vec![
("helper", false, vec![
("x", "data", "Data"),
("y", "data", "Data"),
("z", "data", "Data"),
("w", "data", "Data"),
("v", "data", "Data"),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Static methods should be excluded from feature envy");
}
#[test]
fn test_feature_envy_formatter_excluded() {
let file_ir = build_feature_envy_file_ir(
"UserFormatter",
None,
vec![
("format_user", true, vec![
("get_name", "user", "User"),
("get_email", "user", "User"),
("get_age", "user", "User"),
("get_address", "user", "User"),
("get_phone", "user", "User"),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Classes with 'Formatter' in name should be excluded (role-based C4)");
}
#[test]
fn test_feature_envy_at_threshold() {
let file_ir = build_feature_envy_file_ir(
"Analyzer",
None,
vec![
("analyze", true, vec![
("metric1", "stats", "Stats"),
("metric2", "stats", "Stats"),
("metric3", "stats", "Stats"),
("metric4", "stats", "Stats"),
("get_base", "self", ""),
("get_factor", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "Exactly at threshold (4 foreign, ratio 2.0) should trigger");
}
#[test]
fn test_feature_envy_below_min_foreign() {
let file_ir = build_feature_envy_file_ir(
"SmallEnvy",
None,
vec![
("process", true, vec![
("get_x", "other", "OtherClass"),
("get_y", "other", "OtherClass"),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "2 foreign accesses (below min_foreign=4) should not trigger");
}
#[test]
fn test_feature_envy_constructor_excluded() {
let file_ir = build_feature_envy_file_ir(
"Service",
None,
vec![
("__init__", true, vec![
("get_config", "db", "Database"),
("get_pool", "db", "Database"),
("get_timeout", "db", "Database"),
("get_retries", "db", "Database"),
("get_host", "db", "Database"),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Constructor methods should be excluded from feature envy analysis");
}
#[test]
fn test_feature_envy_detection_severity_levels() {
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let file_ir_mild = build_feature_envy_file_ir(
"MildEnvy",
None,
vec![
("envious_method", true, vec![
("a", "other", "Other"),
("b", "other", "Other"),
("c", "other", "Other"),
("d", "other", "Other"),
("own_method", "self", ""),
]),
],
);
let findings = detect_feature_envy_from_callgraph(&file_ir_mild, &thresholds, "python", false);
assert!(!findings.is_empty(), "4 foreign, 1 own should trigger");
assert_eq!(findings[0].severity, 1, "4 foreign, 1 own should be severity 1");
let file_ir_strong = build_feature_envy_file_ir(
"StrongEnvy",
None,
vec![
("very_envious", true, vec![
("a", "other", "Other"),
("b", "other", "Other"),
("c", "other", "Other"),
("d", "other", "Other"),
("e", "other", "Other"),
("f", "other", "Other"),
("own_method", "self", ""),
]),
],
);
let findings = detect_feature_envy_from_callgraph(&file_ir_strong, &thresholds, "python", false);
assert!(!findings.is_empty(), "6 foreign, 1 own should trigger");
assert_eq!(findings[0].severity, 2, "6 foreign, 1 own should be severity 2");
let file_ir_extreme = build_feature_envy_file_ir(
"ExtremeEnvy",
None,
vec![
("extremely_envious", true, vec![
("a", "other", "Other"),
("b", "other", "Other"),
("c", "other", "Other"),
("d", "other", "Other"),
("e", "other", "Other"),
("f", "other", "Other"),
("g", "other", "Other"),
("h", "other", "Other"),
("i", "other", "Other"),
("j", "other", "Other"),
("own_method", "self", ""),
]),
],
);
let findings = detect_feature_envy_from_callgraph(&file_ir_extreme, &thresholds, "python", false);
assert!(!findings.is_empty(), "10 foreign, 1 own should trigger");
assert_eq!(findings[0].severity, 3, "10 foreign, 1 own should be severity 3");
}
#[test]
fn test_feature_envy_with_suggestion() {
let file_ir = build_feature_envy_file_ir(
"Reporter",
None,
vec![
("generate", true, vec![
("get_title", "data", "DataSource"),
("get_author", "data", "DataSource"),
("get_content", "data", "DataSource"),
("get_footer", "data", "DataSource"),
("format_output", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", true);
assert!(!findings.is_empty());
assert!(findings[0].suggestion.is_some(), "suggestion should be present when suggest=true");
let suggestion = findings[0].suggestion.as_ref().unwrap();
assert!(suggestion.contains("DataSource"), "Suggestion should mention the envied class");
}
#[test]
fn test_feature_envy_zero_own_access() {
let file_ir = build_feature_envy_file_ir(
"PureEnvy",
None,
vec![
("all_foreign", true, vec![
("a", "other", "Other"),
("b", "other", "Other"),
("c", "other", "Other"),
("d", "other", "Other"),
("e", "other", "Other"),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(!findings.is_empty(), "5 foreign, 0 own should trigger (division by zero handled)");
}
#[test]
fn test_feature_envy_no_calls() {
let file_ir = build_feature_envy_file_ir(
"Silent",
None,
vec![
("no_calls_method", true, vec![]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Method with no calls should not trigger");
}
#[test]
fn test_feature_envy_role_exclusions() {
let excluded_names = [
"DataSerializer", "JsonDeserializer", "RequestHandler",
"AstVisitor", "HtmlRenderer", "UserValidator",
"TypeConverter", "ObjectMapper", "FormBuilder",
];
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
for class_name in &excluded_names {
let file_ir = build_feature_envy_file_ir(
class_name,
None,
vec![
("process", true, vec![
("a", "other", "Other"),
("b", "other", "Other"),
("c", "other", "Other"),
("d", "other", "Other"),
("e", "other", "Other"),
]),
],
);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Class '{}' should be excluded by role-based filter", class_name);
}
}
#[test]
fn test_feature_envy_typescript_this() {
let file_ir = build_feature_envy_file_ir(
"TsComponent",
None,
vec![
("render", true, vec![
("getData", "service", "ApiService"),
("getConfig", "service", "ApiService"),
("getHeaders", "service", "ApiService"),
("getTimeout", "service", "ApiService"),
("setState", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "typescript", false);
assert!(!findings.is_empty(), "4 foreign vs 1 own (this) should trigger in TypeScript");
}
#[test]
fn test_inappropriate_intimacy_variant_exists() {
let _ = SmellType::InappropriateIntimacy;
assert_eq!(SmellType::InappropriateIntimacy.to_string(), "Inappropriate Intimacy");
}
#[test]
fn test_inappropriate_intimacy_serialize() {
let json = serde_json::to_string(&SmellType::InappropriateIntimacy).unwrap();
assert_eq!(json, "\"inappropriate_intimacy\"");
}
type IntimacyCall<'a> = (&'a str, &'a str);
struct IntimacyClassSpec<'a> {
name: &'a str,
methods: &'a [&'a str],
outbound_calls: &'a [IntimacyCall<'a>],
}
fn build_intimacy_test_data(
class_a: IntimacyClassSpec<'_>,
class_b: IntimacyClassSpec<'_>,
same_file: bool,
inheritance_edges: Vec<crate::types::inheritance::InheritanceEdge>,
) -> (CallGraphIR, InheritanceReport) {
use crate::callgraph::cross_file_types::{ClassDef, FuncDef, CallSite, CallType};
use crate::types::inheritance::InheritanceReport;
let file_a_path = PathBuf::from("file_a.py");
let file_b_path = if same_file {
PathBuf::from("file_a.py")
} else {
PathBuf::from("file_b.py")
};
let mut file_a_ir = FileIR::new(file_a_path.clone());
file_a_ir.classes.push(ClassDef::new(
class_a.name.to_string(),
1,
(class_a.methods.len() as u32 * 3) + 1,
class_a.methods.iter().map(|m| m.to_string()).collect(),
vec![],
));
for (i, method_name) in class_a.methods.iter().enumerate() {
let line = (i as u32 * 3) + 2;
file_a_ir.funcs.push(FuncDef::method(
*method_name,
class_a.name,
line,
line + 2,
));
}
for (from_method, target) in class_a.outbound_calls {
let qualified = format!("{}.{}", class_a.name, from_method);
file_a_ir.add_call(
&qualified,
CallSite::new(
qualified.clone(),
target.to_string(),
CallType::Method,
Some(2),
None,
Some("b".to_string()),
Some(class_b.name.to_string()),
),
);
}
if same_file {
let offset = (class_a.methods.len() as u32 * 3) + 2;
file_a_ir.classes.push(ClassDef::new(
class_b.name.to_string(),
offset,
offset + (class_b.methods.len() as u32 * 3),
class_b.methods.iter().map(|m| m.to_string()).collect(),
vec![],
));
for (i, method_name) in class_b.methods.iter().enumerate() {
let line = offset + (i as u32 * 3) + 1;
file_a_ir.funcs.push(FuncDef::method(
*method_name,
class_b.name,
line,
line + 2,
));
}
for (from_method, target) in class_b.outbound_calls {
let qualified = format!("{}.{}", class_b.name, from_method);
file_a_ir.add_call(
&qualified,
CallSite::new(
qualified.clone(),
target.to_string(),
CallType::Method,
Some(2),
None,
Some("a".to_string()),
Some(class_a.name.to_string()),
),
);
}
let mut cg = CallGraphIR::new(PathBuf::from("."), "python");
cg.files.insert(file_a_path, file_a_ir);
let ir = InheritanceReport {
edges: inheritance_edges,
nodes: vec![],
roots: vec![],
leaves: vec![],
count: 2,
languages: vec![crate::types::Language::Python],
diamonds: vec![],
project_path: PathBuf::from("."),
scan_time_ms: 0,
};
(cg, ir)
} else {
let mut file_b_ir = FileIR::new(file_b_path.clone());
file_b_ir.classes.push(ClassDef::new(
class_b.name.to_string(),
1,
(class_b.methods.len() as u32 * 3) + 1,
class_b.methods.iter().map(|m| m.to_string()).collect(),
vec![],
));
for (i, method_name) in class_b.methods.iter().enumerate() {
let line = (i as u32 * 3) + 2;
file_b_ir.funcs.push(FuncDef::method(
*method_name,
class_b.name,
line,
line + 2,
));
}
for (from_method, target) in class_b.outbound_calls {
let qualified = format!("{}.{}", class_b.name, from_method);
file_b_ir.add_call(
&qualified,
CallSite::new(
qualified.clone(),
target.to_string(),
CallType::Method,
Some(2),
None,
Some("a".to_string()),
Some(class_a.name.to_string()),
),
);
}
let mut cg = CallGraphIR::new(PathBuf::from("."), "python");
cg.files.insert(file_a_path, file_a_ir);
cg.files.insert(file_b_path, file_b_ir);
let ir = InheritanceReport {
edges: inheritance_edges,
nodes: vec![],
roots: vec![],
leaves: vec![],
count: 2,
languages: vec![crate::types::Language::Python],
diamonds: vec![],
project_path: PathBuf::from("."),
scan_time_ms: 0,
};
(cg, ir)
}
}
#[test]
fn test_detect_inappropriate_intimacy() {
let a_to_b: Vec<(&str, &str)> = vec![
("add_item", "_get_price"),
("add_item", "_set_ref"),
("get_total", "_price"),
("get_total", "_weight"),
("process", "_validate"),
("process", "_update"),
];
let b_to_a: Vec<(&str, &str)> = vec![
("update_order", "_items"),
("update_order", "_total"),
("collaborate", "_count"),
("collaborate", "_status"),
("sync", "_refresh"),
("sync", "_notify"),
];
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "Order",
methods: &["add_item", "get_total", "process"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "Item",
methods: &["update_order", "collaborate", "sync"],
outbound_calls: &b_to_a,
},
true,
vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(!findings.is_empty(), "Bidirectional coupling (6+6=12) should trigger at default threshold (10)");
assert_eq!(findings[0].smell_type, SmellType::InappropriateIntimacy);
}
#[test]
fn test_no_intimacy_one_direction() {
let a_to_b: Vec<(&str, &str)> = vec![
("get_total", "amount"),
("get_total", "tax"),
("get_total", "discount"),
("list_orders", "status"),
("list_orders", "date"),
];
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "Customer",
methods: &["get_total", "list_orders"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "Order",
methods: &["process"],
outbound_calls: &[], },
true,
vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "One-way access should not trigger (need bidirectional)");
}
#[test]
fn test_intimacy_below_threshold() {
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "A",
methods: &["method1"],
outbound_calls: &[("method1", "_internal")],
},
IntimacyClassSpec {
name: "B",
methods: &["method1"],
outbound_calls: &[("method1", "_state")],
},
true,
vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Low-count bidirectional access (1+1=2) should not trigger");
}
#[test]
fn test_intimacy_method_calls_count() {
let a_to_b: Vec<(&str, &str)> = vec![
("work", "_internal_method1"),
("work", "_internal_method2"),
("work", "_internal_method3"),
("process", "_internal_method4"),
("process", "_internal_method5"),
];
let b_to_a: Vec<(&str, &str)> = vec![
("collaborate", "_helper1"),
("collaborate", "_helper2"),
("collaborate", "_helper3"),
("assist", "_helper4"),
("assist", "_helper5"),
];
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "ClassA",
methods: &["work", "process"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "ClassB",
methods: &["collaborate", "assist"],
outbound_calls: &b_to_a,
},
true,
vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(!findings.is_empty(), "Multiple bidirectional method calls (5+5=10) should trigger");
}
#[test]
fn test_intimacy_parent_child_expected() {
use crate::types::inheritance::InheritanceEdge;
let a_to_b: Vec<(&str, &str)> = vec![
("method1", "_child_a"),
("method1", "_child_b"),
("method2", "_child_c"),
("method2", "_child_d"),
("method3", "_child_e"),
];
let b_to_a: Vec<(&str, &str)> = vec![
("use_parent1", "_protected_a"),
("use_parent1", "_protected_b"),
("use_parent2", "_protected_c"),
("use_parent2", "_protected_d"),
("use_parent3", "_protected_e"),
];
let edge = InheritanceEdge::project(
"Child", "Parent",
PathBuf::from("file_a.py"), 1,
PathBuf::from("file_a.py"), 1,
);
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "Child",
methods: &["use_parent1", "use_parent2", "use_parent3"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "Parent",
methods: &["method1", "method2", "method3"],
outbound_calls: &b_to_a,
},
true,
vec![edge],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Parent-child access should be excluded by inheritance edge");
}
#[test]
fn test_intimacy_high_total_low_per_dir() {
let a_to_b: Vec<(&str, &str)> = vec![
("m1", "t1"), ("m1", "t2"), ("m1", "t3"),
("m2", "t4"), ("m2", "t5"), ("m2", "t6"),
("m3", "t7"), ("m3", "t8"), ("m3", "t9"),
];
let b_to_a: Vec<(&str, &str)> = vec![
("m1", "s1"),
];
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "A",
methods: &["m1", "m2", "m3"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "B",
methods: &["m1"],
outbound_calls: &b_to_a,
},
true, vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "High total (10) but low per-direction (1) should not trigger");
}
#[test]
fn test_intimacy_severity_tiers() {
let a_to_b_1: Vec<(&str, &str)> = vec![
("m1", "t1"), ("m1", "t2"), ("m2", "t3"),
];
let b_to_a_1: Vec<(&str, &str)> = vec![
("m1", "s1"), ("m1", "s2"), ("m2", "s3"),
];
let (cg1, ir1) = build_intimacy_test_data(
IntimacyClassSpec {
name: "A1",
methods: &["m1", "m2"],
outbound_calls: &a_to_b_1,
},
IntimacyClassSpec {
name: "B1",
methods: &["m1", "m2"],
outbound_calls: &b_to_a_1,
},
true, vec![],
);
let thresholds_strict = Thresholds::from_preset(ThresholdPreset::Strict);
let findings1 = detect_inappropriate_intimacy_from_callgraph(&cg1, &ir1, &thresholds_strict, false);
assert!(!findings1.is_empty(), "3+3=6 should trigger at strict threshold (6)");
assert_eq!(findings1[0].severity, 1, "6 total, 3 min_dir -> severity 1");
let a_to_b_2: Vec<(&str, &str)> = vec![
("m1", "t1"), ("m1", "t2"), ("m1", "t3"), ("m1", "t4"),
("m2", "t5"), ("m2", "t6"), ("m2", "t7"),
];
let b_to_a_2: Vec<(&str, &str)> = vec![
("m1", "s1"), ("m1", "s2"), ("m1", "s3"), ("m1", "s4"),
("m2", "s5"), ("m2", "s6"), ("m2", "s7"),
];
let (cg2, ir2) = build_intimacy_test_data(
IntimacyClassSpec {
name: "A2",
methods: &["m1", "m2"],
outbound_calls: &a_to_b_2,
},
IntimacyClassSpec {
name: "B2",
methods: &["m1", "m2"],
outbound_calls: &b_to_a_2,
},
true, vec![],
);
let findings2 = detect_inappropriate_intimacy_from_callgraph(&cg2, &ir2, &thresholds_strict, false);
assert!(!findings2.is_empty(), "7+7=14 should trigger");
assert_eq!(findings2[0].severity, 2, "14 total, 7 min_dir -> severity 2");
let mut a_to_b_3: Vec<(&str, &str)> = Vec::new();
let mut b_to_a_3: Vec<(&str, &str)> = Vec::new();
for _ in 0..12 {
a_to_b_3.push(("m1", "t1"));
b_to_a_3.push(("m1", "s1"));
}
let (cg3, ir3) = build_intimacy_test_data(
IntimacyClassSpec {
name: "A3",
methods: &["m1"],
outbound_calls: &a_to_b_3,
},
IntimacyClassSpec {
name: "B3",
methods: &["m1"],
outbound_calls: &b_to_a_3,
},
true, vec![],
);
let findings3 = detect_inappropriate_intimacy_from_callgraph(&cg3, &ir3, &thresholds_strict, false);
assert!(!findings3.is_empty(), "12+12=24 should trigger");
assert_eq!(findings3[0].severity, 3, "24 total, 12 min_dir -> severity 3");
}
#[test]
fn test_intimacy_cross_file() {
let a_to_b: Vec<(&str, &str)> = vec![
("m1", "t1"), ("m1", "t2"), ("m1", "t3"),
("m2", "t4"), ("m2", "t5"),
];
let b_to_a: Vec<(&str, &str)> = vec![
("m1", "s1"), ("m1", "s2"), ("m1", "s3"),
("m2", "s4"), ("m2", "s5"),
];
let (cg, ir) = build_intimacy_test_data(
IntimacyClassSpec {
name: "ServiceA",
methods: &["m1", "m2"],
outbound_calls: &a_to_b,
},
IntimacyClassSpec {
name: "ServiceB",
methods: &["m1", "m2"],
outbound_calls: &b_to_a,
},
false, vec![],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(!findings.is_empty(), "Cross-file bidirectional coupling (5+5=10) should trigger");
assert_eq!(findings[0].smell_type, SmellType::InappropriateIntimacy);
}
#[test]
fn test_thresholds_tier2_strict() {
let t = Thresholds::from_preset(ThresholdPreset::Strict);
assert!((t.middle_man_delegation_ratio - 0.50).abs() < f64::EPSILON);
assert_eq!(t.middle_man_min_methods, 3);
assert!((t.refused_bequest_usage_ratio - 0.33).abs() < f64::EPSILON);
assert_eq!(t.refused_bequest_min_inherited, 3);
assert_eq!(t.feature_envy_min_foreign, 3);
assert!((t.feature_envy_ratio - 1.5).abs() < f64::EPSILON);
assert_eq!(t.intimacy_min_total, 6);
assert_eq!(t.intimacy_min_per_direction, 2);
}
#[test]
fn test_thresholds_tier2_default() {
let t = Thresholds::from_preset(ThresholdPreset::Default);
assert!((t.middle_man_delegation_ratio - 0.60).abs() < f64::EPSILON);
assert_eq!(t.middle_man_min_methods, 3);
assert!((t.refused_bequest_usage_ratio - 0.33).abs() < f64::EPSILON);
assert_eq!(t.refused_bequest_min_inherited, 3);
assert_eq!(t.feature_envy_min_foreign, 4);
assert!((t.feature_envy_ratio - 2.0).abs() < f64::EPSILON);
assert_eq!(t.intimacy_min_total, 10);
assert_eq!(t.intimacy_min_per_direction, 3);
}
#[test]
fn test_thresholds_tier2_relaxed() {
let t = Thresholds::from_preset(ThresholdPreset::Relaxed);
assert!((t.middle_man_delegation_ratio - 0.75).abs() < f64::EPSILON);
assert_eq!(t.middle_man_min_methods, 3);
assert!((t.refused_bequest_usage_ratio - 0.15).abs() < f64::EPSILON);
assert_eq!(t.refused_bequest_min_inherited, 5);
assert_eq!(t.feature_envy_min_foreign, 5);
assert!((t.feature_envy_ratio - 3.0).abs() < f64::EPSILON);
assert_eq!(t.intimacy_min_total, 15);
assert_eq!(t.intimacy_min_per_direction, 4);
}
#[test]
fn test_get_class_methods_robust_python() {
use crate::callgraph::cross_file_types::{ClassDef, FuncDef, FileIR};
let mut file_ir = FileIR::new(PathBuf::from("test.py"));
file_ir.classes.push(ClassDef::new(
"MyClass".into(),
1,
20,
vec!["method_a".into(), "method_b".into()],
vec![],
));
file_ir.funcs.push(FuncDef::method("method_a", "MyClass", 2, 5));
file_ir.funcs.push(FuncDef::method("method_b", "MyClass", 6, 10));
let methods = get_class_methods_robust(&file_ir, "MyClass");
assert_eq!(methods.len(), 2);
let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"method_a"));
assert!(names.contains(&"method_b"));
}
#[test]
fn test_get_class_methods_robust_go_fallback() {
use crate::callgraph::cross_file_types::{ClassDef, FuncDef, FileIR};
let mut file_ir = FileIR::new(PathBuf::from("server.go"));
file_ir.classes.push(ClassDef::simple("Server", 1, 30));
file_ir.funcs.push(FuncDef::method("Start", "Server", 5, 10));
file_ir.funcs.push(FuncDef::method("Stop", "Server", 12, 18));
file_ir.funcs.push(FuncDef::function("main", 20, 30));
let methods = get_class_methods_robust(&file_ir, "Server");
assert_eq!(methods.len(), 2, "Should find 2 methods via FuncDef.class_name fallback");
let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"Start"));
assert!(names.contains(&"Stop"));
}
#[test]
fn test_is_self_reference_python() {
assert!(is_self_reference("self", "python"));
assert!(!is_self_reference("this", "python"));
assert!(!is_self_reference("me", "python"));
}
#[test]
fn test_is_self_reference_typescript() {
assert!(is_self_reference("this", "typescript"));
assert!(!is_self_reference("self", "typescript"));
}
#[test]
fn test_is_self_reference_rust() {
assert!(is_self_reference("self", "rust"));
assert!(!is_self_reference("this", "rust"));
}
#[test]
fn test_is_self_reference_java() {
assert!(is_self_reference("this", "java"));
assert!(!is_self_reference("self", "java"));
}
#[test]
fn test_is_self_reference_unknown_language() {
assert!(is_self_reference("self", "unknown_lang"));
assert!(is_self_reference("this", "unknown_lang"));
assert!(!is_self_reference("me", "unknown_lang"));
}
#[test]
fn test_is_constructor_python() {
assert!(is_constructor("__init__", "python"));
assert!(!is_constructor("process", "python"));
assert!(!is_constructor("constructor", "python"));
}
#[test]
fn test_is_constructor_javascript() {
assert!(is_constructor("constructor", "javascript"));
assert!(!is_constructor("__init__", "javascript"));
}
#[test]
fn test_is_constructor_typescript() {
assert!(is_constructor("constructor", "typescript"));
assert!(!is_constructor("new", "typescript"));
}
#[test]
fn test_is_constructor_rust() {
assert!(is_constructor("new", "rust"));
assert!(!is_constructor("__init__", "rust"));
}
#[test]
fn test_is_constructor_go() {
assert!(is_constructor("NewServer", "go"));
assert!(is_constructor("NewClient", "go"));
assert!(!is_constructor("processRequest", "go"));
}
#[test]
fn test_is_constructor_excludes_regular() {
for lang in &["python", "javascript", "typescript", "rust", "go", "java", "csharp"] {
assert!(!is_constructor("process", lang), "process should not be constructor for {}", lang);
assert!(!is_constructor("get_data", lang), "get_data should not be constructor for {}", lang);
}
}
#[test]
fn test_middle_man_severity_levels() {
assert_eq!(middle_man_severity(0.60, 3), 1);
assert_eq!(middle_man_severity(0.80, 3), 2);
assert_eq!(middle_man_severity(0.65, 4), 2);
assert_eq!(middle_man_severity(0.95, 5), 3);
assert_eq!(middle_man_severity(0.90, 6), 3);
}
#[test]
fn test_refused_bequest_severity_levels() {
assert_eq!(refused_bequest_severity(0.15, 4), 1);
assert_eq!(refused_bequest_severity(0.05, 4), 2);
assert_eq!(refused_bequest_severity(0.0, 3), 2);
assert_eq!(refused_bequest_severity(0.0, 5), 3);
assert_eq!(refused_bequest_severity(0.0, 10), 3);
}
#[test]
fn test_feature_envy_severity_levels() {
assert_eq!(feature_envy_severity(4, 2), 1);
assert_eq!(feature_envy_severity(6, 1), 2);
assert_eq!(feature_envy_severity(5, 1), 2);
assert_eq!(feature_envy_severity(10, 1), 3);
assert_eq!(feature_envy_severity(9, 2), 3); assert_eq!(feature_envy_severity(8, 3), 2); }
#[test]
fn test_intimacy_severity_levels() {
assert_eq!(intimacy_severity(8, 2), 1);
assert_eq!(intimacy_severity(14, 4), 2);
assert_eq!(intimacy_severity(12, 3), 2);
assert_eq!(intimacy_severity(20, 5), 3);
assert_eq!(intimacy_severity(24, 8), 3);
}
#[test]
fn test_stub_detect_middle_man_from_callgraph_compiles() {
use crate::callgraph::cross_file_types::FileIR;
let file_ir = FileIR::new(PathBuf::from("test.py"));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Stub should return empty Vec");
}
#[test]
fn test_stub_detect_refused_bequest_from_callgraph_compiles() {
use crate::callgraph::cross_file_types::CallGraphIR;
use crate::types::inheritance::InheritanceReport;
let cg = CallGraphIR::new(PathBuf::from("."), "python");
let ir = InheritanceReport::new(PathBuf::from("."));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_refused_bequest_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Stub should return empty Vec");
}
#[test]
fn test_stub_detect_feature_envy_from_callgraph_compiles() {
use crate::callgraph::cross_file_types::FileIR;
let file_ir = FileIR::new(PathBuf::from("test.py"));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "python", false);
assert!(findings.is_empty(), "Stub should return empty Vec");
}
#[test]
fn test_stub_detect_inappropriate_intimacy_from_callgraph_compiles() {
use crate::callgraph::cross_file_types::CallGraphIR;
use crate::types::inheritance::InheritanceReport;
let cg = CallGraphIR::new(PathBuf::from("."), "python");
let ir = InheritanceReport::new(PathBuf::from("."));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_inappropriate_intimacy_from_callgraph(&cg, &ir, &thresholds, false);
assert!(findings.is_empty(), "Stub should return empty Vec");
}
#[test]
fn test_tier2_threshold_ordering() {
let strict = Thresholds::from_preset(ThresholdPreset::Strict);
let default = Thresholds::from_preset(ThresholdPreset::Default);
let relaxed = Thresholds::from_preset(ThresholdPreset::Relaxed);
assert!(strict.middle_man_delegation_ratio <= default.middle_man_delegation_ratio,
"Strict MM ratio ({}) should be <= Default ({})",
strict.middle_man_delegation_ratio, default.middle_man_delegation_ratio);
assert!(default.middle_man_delegation_ratio <= relaxed.middle_man_delegation_ratio,
"Default MM ratio ({}) should be <= Relaxed ({})",
default.middle_man_delegation_ratio, relaxed.middle_man_delegation_ratio);
assert!(strict.refused_bequest_usage_ratio >= relaxed.refused_bequest_usage_ratio,
"Strict RB usage_ratio ({}) should be >= Relaxed ({})",
strict.refused_bequest_usage_ratio, relaxed.refused_bequest_usage_ratio);
assert!(strict.refused_bequest_min_inherited <= default.refused_bequest_min_inherited,
"Strict RB min_inherited ({}) should be <= Default ({})",
strict.refused_bequest_min_inherited, default.refused_bequest_min_inherited);
assert!(default.refused_bequest_min_inherited <= relaxed.refused_bequest_min_inherited,
"Default RB min_inherited ({}) should be <= Relaxed ({})",
default.refused_bequest_min_inherited, relaxed.refused_bequest_min_inherited);
assert!(strict.feature_envy_min_foreign <= default.feature_envy_min_foreign,
"Strict FE min_foreign ({}) should be <= Default ({})",
strict.feature_envy_min_foreign, default.feature_envy_min_foreign);
assert!(default.feature_envy_min_foreign <= relaxed.feature_envy_min_foreign,
"Default FE min_foreign ({}) should be <= Relaxed ({})",
default.feature_envy_min_foreign, relaxed.feature_envy_min_foreign);
assert!(strict.feature_envy_ratio <= default.feature_envy_ratio,
"Strict FE ratio ({}) should be <= Default ({})",
strict.feature_envy_ratio, default.feature_envy_ratio);
assert!(default.feature_envy_ratio <= relaxed.feature_envy_ratio,
"Default FE ratio ({}) should be <= Relaxed ({})",
default.feature_envy_ratio, relaxed.feature_envy_ratio);
assert!(strict.intimacy_min_total <= default.intimacy_min_total,
"Strict II min_total ({}) should be <= Default ({})",
strict.intimacy_min_total, default.intimacy_min_total);
assert!(default.intimacy_min_total <= relaxed.intimacy_min_total,
"Default II min_total ({}) should be <= Relaxed ({})",
default.intimacy_min_total, relaxed.intimacy_min_total);
assert!(strict.intimacy_min_per_direction <= default.intimacy_min_per_direction,
"Strict II min_per_direction ({}) should be <= Default ({})",
strict.intimacy_min_per_direction, default.intimacy_min_per_direction);
assert!(default.intimacy_min_per_direction <= relaxed.intimacy_min_per_direction,
"Default II min_per_direction ({}) should be <= Relaxed ({})",
default.intimacy_min_per_direction, relaxed.intimacy_min_per_direction);
}
#[test]
fn test_is_constructor_all_languages() {
assert!(is_constructor("__init__", "python"));
assert!(is_constructor("__init__", "py"));
assert!(!is_constructor("process", "python"));
assert!(is_constructor("constructor", "typescript"));
assert!(is_constructor("constructor", "javascript"));
assert!(is_constructor("constructor", "tsx"));
assert!(is_constructor("constructor", "jsx"));
assert!(is_constructor("constructor", "ts"));
assert!(is_constructor("constructor", "js"));
assert!(!is_constructor("process", "typescript"));
assert!(!is_constructor("process", "javascript"));
assert!(is_constructor("new", "rust"));
assert!(is_constructor("new", "rs"));
assert!(!is_constructor("process", "rust"));
assert!(is_constructor("NewService", "go"));
assert!(is_constructor("NewOrderForwarder", "go"));
assert!(is_constructor("New", "go"));
assert!(!is_constructor("process", "go"));
assert!(!is_constructor("newThing", "go"));
assert!(is_constructor("initialize", "ruby"));
assert!(is_constructor("initialize", "rb"));
assert!(!is_constructor("process", "ruby"));
assert!(is_constructor("__construct", "php"));
assert!(!is_constructor("process", "php"));
assert!(is_constructor("init", "swift"));
assert!(!is_constructor("process", "swift"));
assert!(is_constructor("<init>", "scala"));
assert!(is_constructor("this", "scala"));
assert!(!is_constructor("process", "scala"));
assert!(!is_constructor("MyClass", "java"));
assert!(!is_constructor("MyClass", "csharp"));
assert!(!is_constructor("MyClass", "cs"));
assert!(!is_constructor("MyClass", "kotlin"));
assert!(!is_constructor("MyClass", "kt"));
assert!(!is_constructor("MyClass", "c"));
assert!(!is_constructor("MyClass", "cpp"));
assert!(!is_constructor("MyClass", "c++"));
assert!(!is_constructor("new", "elixir"));
assert!(!is_constructor("new", "ex"));
assert!(!is_constructor("new", "lua"));
}
#[test]
fn test_is_self_reference_all_languages() {
assert!(is_self_reference("self", "python"));
assert!(is_self_reference("self", "py"));
assert!(is_self_reference("self", "rust"));
assert!(is_self_reference("self", "rs"));
assert!(is_self_reference("self", "ruby"));
assert!(is_self_reference("self", "rb"));
assert!(is_self_reference("self", "swift"));
assert!(!is_self_reference("this", "python"));
assert!(!is_self_reference("this", "rust"));
assert!(!is_self_reference("this", "ruby"));
assert!(!is_self_reference("this", "swift"));
assert!(is_self_reference("this", "typescript"));
assert!(is_self_reference("this", "ts"));
assert!(is_self_reference("this", "javascript"));
assert!(is_self_reference("this", "js"));
assert!(is_self_reference("this", "tsx"));
assert!(is_self_reference("this", "jsx"));
assert!(is_self_reference("this", "java"));
assert!(is_self_reference("this", "csharp"));
assert!(is_self_reference("this", "cs"));
assert!(is_self_reference("this", "kotlin"));
assert!(is_self_reference("this", "kt"));
assert!(is_self_reference("this", "scala"));
assert!(is_self_reference("this", "cpp"));
assert!(is_self_reference("this", "c++"));
assert!(is_self_reference("this", "php"));
assert!(!is_self_reference("self", "typescript"));
assert!(!is_self_reference("self", "java"));
assert!(!is_self_reference("self", "cpp"));
assert!(!is_self_reference("self", "php"));
assert!(!is_self_reference("self", "go"));
assert!(!is_self_reference("this", "go"));
assert!(!is_self_reference("s", "go"));
assert!(!is_self_reference("self", "c"));
assert!(!is_self_reference("this", "c"));
assert!(!is_self_reference("self", "elixir"));
assert!(!is_self_reference("this", "elixir"));
assert!(!is_self_reference("self", "ex"));
assert!(!is_self_reference("self", "lua"));
assert!(!is_self_reference("this", "lua"));
}
#[test]
fn test_middle_man_constructor_excluded_typescript() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("constructor"),
vec![
("getTotal", vec![("getTotal", "order", "Order")]),
("getItems", vec![("getItems", "order", "Order")]),
("getCustomer", vec![("getCustomer", "order", "Order")]),
("getStatus", vec![("getStatus", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "typescript", false);
assert!(!findings.is_empty(), "TypeScript class with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_rust() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("new"),
vec![
("get_total", vec![("get_total", "order", "Order")]),
("get_items", vec![("get_items", "order", "Order")]),
("get_customer", vec![("get_customer", "order", "Order")]),
("get_status", vec![("get_status", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "rust", false);
assert!(!findings.is_empty(), "Rust struct with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_go() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("NewOrderForwarder"),
vec![
("GetTotal", vec![("GetTotal", "order", "Order")]),
("GetItems", vec![("GetItems", "order", "Order")]),
("GetCustomer", vec![("GetCustomer", "order", "Order")]),
("GetStatus", vec![("GetStatus", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "go", false);
assert!(!findings.is_empty(), "Go struct with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_ruby() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("initialize"),
vec![
("get_total", vec![("get_total", "order", "Order")]),
("get_items", vec![("get_items", "order", "Order")]),
("get_customer", vec![("get_customer", "order", "Order")]),
("get_status", vec![("get_status", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "ruby", false);
assert!(!findings.is_empty(), "Ruby class with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_php() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("__construct"),
vec![
("getTotal", vec![("getTotal", "order", "Order")]),
("getItems", vec![("getItems", "order", "Order")]),
("getCustomer", vec![("getCustomer", "order", "Order")]),
("getStatus", vec![("getStatus", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "php", false);
assert!(!findings.is_empty(), "PHP class with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_swift() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("init"),
vec![
("getTotal", vec![("getTotal", "order", "Order")]),
("getItems", vec![("getItems", "order", "Order")]),
("getCustomer", vec![("getCustomer", "order", "Order")]),
("getStatus", vec![("getStatus", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "swift", false);
assert!(!findings.is_empty(), "Swift class with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_constructor_excluded_scala() {
let file_ir = build_middle_man_file_ir(
"OrderForwarder",
Some("<init>"),
vec![
("getTotal", vec![("getTotal", "order", "Order")]),
("getItems", vec![("getItems", "order", "Order")]),
("getCustomer", vec![("getCustomer", "order", "Order")]),
("getStatus", vec![("getStatus", "order", "Order")]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "scala", false);
assert!(!findings.is_empty(), "Scala class with 4/4 delegating non-constructor methods should be middle man");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_feature_envy_self_reference_typescript() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("constructor"),
vec![
("calculateDiscount", true, vec![
("loyaltyPoints", "customer", "Customer"),
("discountRate", "customer", "Customer"),
("yearsActive", "customer", "Customer"),
("bonusMultiplier", "customer", "Customer"),
("amount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "typescript", false);
assert!(!findings.is_empty(), "TypeScript method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_java() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
None, vec![
("calculateDiscount", true, vec![
("getLoyaltyPoints", "customer", "Customer"),
("getDiscountRate", "customer", "Customer"),
("getYearsActive", "customer", "Customer"),
("getBonusMultiplier", "customer", "Customer"),
("getAmount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "java", false);
assert!(!findings.is_empty(), "Java method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_ruby() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("initialize"),
vec![
("calculate_discount", true, vec![
("loyalty_points", "customer", "Customer"),
("discount_rate", "customer", "Customer"),
("years_active", "customer", "Customer"),
("bonus_multiplier", "customer", "Customer"),
("amount", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "ruby", false);
assert!(!findings.is_empty(), "Ruby method using 'self' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_swift() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("init"),
vec![
("calculateDiscount", true, vec![
("loyaltyPoints", "customer", "Customer"),
("discountRate", "customer", "Customer"),
("yearsActive", "customer", "Customer"),
("bonusMultiplier", "customer", "Customer"),
("amount", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "swift", false);
assert!(!findings.is_empty(), "Swift method using 'self' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_middle_man_go_struct_methods() {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut file_ir = FileIR::new(PathBuf::from("order_forwarder.go"));
file_ir.funcs.push(FuncDef::method("GetTotal", "OrderForwarder", 1, 3));
file_ir.funcs.push(FuncDef::method("GetItems", "OrderForwarder", 4, 6));
file_ir.funcs.push(FuncDef::method("GetCustomer", "OrderForwarder", 7, 9));
file_ir.funcs.push(FuncDef::method("GetStatus", "OrderForwarder", 10, 12));
for (method, target) in [("GetTotal", "GetTotal"), ("GetItems", "GetItems"),
("GetCustomer", "GetCustomer"), ("GetStatus", "GetStatus")] {
let qualified = format!("OrderForwarder.{}", method);
file_ir.calls.insert(qualified.clone(), vec![
CallSite::method(qualified, target, "order", Some("Order".to_string()), Some(2)),
]);
}
file_ir.classes.push(ClassDef::new(
"OrderForwarder".to_string(), 1, 12,
vec![], vec![],
));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "go", false);
assert!(!findings.is_empty(),
"Go struct with empty methods vec but FuncDefs with class_name should be detected via fallback");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_middle_man_rust_impl_methods() {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut file_ir = FileIR::new(PathBuf::from("order_forwarder.rs"));
file_ir.funcs.push(FuncDef::method("new", "OrderForwarder", 1, 3));
file_ir.funcs.push(FuncDef::method("get_total", "OrderForwarder", 4, 6));
file_ir.funcs.push(FuncDef::method("get_items", "OrderForwarder", 7, 9));
file_ir.funcs.push(FuncDef::method("get_customer", "OrderForwarder", 10, 12));
file_ir.funcs.push(FuncDef::method("get_status", "OrderForwarder", 13, 15));
for (method, target) in [("get_total", "get_total"), ("get_items", "get_items"),
("get_customer", "get_customer"), ("get_status", "get_status")] {
let qualified = format!("OrderForwarder.{}", method);
file_ir.calls.insert(qualified.clone(), vec![
CallSite::method(qualified, target, "order", Some("Order".to_string()), Some(5)),
]);
}
file_ir.classes.push(ClassDef::new(
"OrderForwarder".to_string(), 1, 15,
vec![], vec![],
));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "rust", false);
assert!(!findings.is_empty(),
"Rust struct with empty methods vec but FuncDefs with class_name should be detected via fallback");
assert_eq!(findings[0].smell_type, SmellType::MiddleMan);
}
#[test]
fn test_feature_envy_go_receiver() {
use crate::callgraph::cross_file_types::{CallSite, ClassDef};
let mut file_ir = FileIR::new(PathBuf::from("invoice.go"));
file_ir.funcs.push(FuncDef::method("CalculateDiscount", "Invoice", 1, 8));
let qualified = "Invoice.CalculateDiscount".to_string();
file_ir.calls.insert(qualified.clone(), vec![
CallSite::method(qualified.clone(), "GetLoyaltyPoints", "c", Some("Customer".to_string()), Some(2)),
CallSite::method(qualified.clone(), "GetDiscountRate", "c", Some("Customer".to_string()), Some(3)),
CallSite::method(qualified.clone(), "GetYearsActive", "c", Some("Customer".to_string()), Some(4)),
CallSite::method(qualified.clone(), "GetBonusMultiplier", "c", Some("Customer".to_string()), Some(5)),
CallSite::method(qualified.clone(), "GetAmount", "i", Some("Invoice".to_string()), Some(6)),
]);
file_ir.classes.push(ClassDef::new(
"Invoice".to_string(), 1, 8,
vec![], vec![],
));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "go", false);
assert!(!findings.is_empty(),
"Go method with 4 foreign calls (Customer) and 1 own (Invoice via type match) should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_middle_man_c_no_classes() {
let mut file_ir = FileIR::new(PathBuf::from("utils.c"));
file_ir.funcs.push(FuncDef::function("process_data", 1, 10));
file_ir.funcs.push(FuncDef::function("validate_input", 11, 20));
file_ir.funcs.push(FuncDef::function("format_output", 21, 30));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "c", false);
assert!(findings.is_empty(), "C file with no classes should produce no middle man findings");
}
#[test]
fn test_feature_envy_lua_no_classes() {
let mut file_ir = FileIR::new(PathBuf::from("utils.lua"));
file_ir.funcs.push(FuncDef::function("process", 1, 10));
file_ir.funcs.push(FuncDef::function("validate", 11, 20));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "lua", false);
assert!(findings.is_empty(), "Lua file with no classes should produce no feature envy findings");
}
#[test]
fn test_middle_man_elixir_no_classes() {
let mut file_ir = FileIR::new(PathBuf::from("order.ex"));
file_ir.funcs.push(FuncDef::function("process_order", 1, 15));
file_ir.funcs.push(FuncDef::function("validate_order", 16, 30));
file_ir.funcs.push(FuncDef::function("format_receipt", 31, 45));
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_middle_man_from_callgraph(&file_ir, &thresholds, "elixir", false);
assert!(findings.is_empty(), "Elixir file with no classes should produce no middle man findings");
}
#[test]
fn test_no_feature_envy_own_fields_typescript() {
let file_ir = build_feature_envy_file_ir(
"Account",
Some("constructor"),
vec![
("calculateInterest", true, vec![
("balance", "this", ""),
("rate", "this", ""),
("fees", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "typescript", false);
assert!(findings.is_empty(), "TypeScript method using only 'this' (own) should not trigger feature envy");
}
#[test]
fn test_no_feature_envy_own_fields_ruby() {
let file_ir = build_feature_envy_file_ir(
"Account",
Some("initialize"),
vec![
("calculate_interest", true, vec![
("balance", "self", ""),
("rate", "self", ""),
("fees", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "ruby", false);
assert!(findings.is_empty(), "Ruby method using only 'self' (own) should not trigger feature envy");
}
#[test]
fn test_no_feature_envy_own_fields_swift() {
let file_ir = build_feature_envy_file_ir(
"Account",
Some("init"),
vec![
("calculateInterest", true, vec![
("balance", "self", ""),
("rate", "self", ""),
("fees", "self", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "swift", false);
assert!(findings.is_empty(), "Swift method using only 'self' (own) should not trigger feature envy");
}
#[test]
fn test_no_feature_envy_own_fields_cpp() {
let file_ir = build_feature_envy_file_ir(
"Account",
None, vec![
("calculateInterest", true, vec![
("balance", "this", ""),
("rate", "this", ""),
("fees", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "cpp", false);
assert!(findings.is_empty(), "C++ method using only 'this' (own) should not trigger feature envy");
}
#[test]
fn test_feature_envy_self_reference_php() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("__construct"),
vec![
("calculateDiscount", true, vec![
("getLoyaltyPoints", "customer", "Customer"),
("getDiscountRate", "customer", "Customer"),
("getYearsActive", "customer", "Customer"),
("getBonusMultiplier", "customer", "Customer"),
("getAmount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "php", false);
assert!(!findings.is_empty(), "PHP method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_scala() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
Some("<init>"),
vec![
("calculateDiscount", true, vec![
("loyaltyPoints", "customer", "Customer"),
("discountRate", "customer", "Customer"),
("yearsActive", "customer", "Customer"),
("bonusMultiplier", "customer", "Customer"),
("amount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "scala", false);
assert!(!findings.is_empty(), "Scala method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_kotlin() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
None, vec![
("calculateDiscount", true, vec![
("getLoyaltyPoints", "customer", "Customer"),
("getDiscountRate", "customer", "Customer"),
("getYearsActive", "customer", "Customer"),
("getBonusMultiplier", "customer", "Customer"),
("getAmount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "kotlin", false);
assert!(!findings.is_empty(), "Kotlin method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
#[test]
fn test_feature_envy_self_reference_csharp() {
let file_ir = build_feature_envy_file_ir(
"Invoice",
None, vec![
("CalculateDiscount", true, vec![
("GetLoyaltyPoints", "customer", "Customer"),
("GetDiscountRate", "customer", "Customer"),
("GetYearsActive", "customer", "Customer"),
("GetBonusMultiplier", "customer", "Customer"),
("GetAmount", "this", ""),
]),
],
);
let thresholds = Thresholds::from_preset(ThresholdPreset::Default);
let findings = detect_feature_envy_from_callgraph(&file_ir, &thresholds, "csharp", false);
assert!(!findings.is_empty(), "C# method using 'this' for own + 4 foreign should trigger feature envy");
assert_eq!(findings[0].smell_type, SmellType::FeatureEnvy);
}
}