use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use std::collections::HashSet;
use std::path::PathBuf;
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct RefusedBequestThresholds {
pub min_overrides: usize,
pub max_parent_call_ratio: f64,
}
impl Default for RefusedBequestThresholds {
fn default() -> Self {
Self {
min_overrides: 2,
max_parent_call_ratio: 0.3,
}
}
}
static EXCLUDE_PARENT_PATTERNS: &[&str] =
&["ABC", "Abstract", "Interface", "Base", "Mixin", "Protocol"];
pub struct RefusedBequestDetector {
#[allow(dead_code)] config: DetectorConfig,
thresholds: RefusedBequestThresholds,
}
impl RefusedBequestDetector {
pub fn new() -> Self {
Self::with_thresholds(RefusedBequestThresholds::default())
}
pub fn with_thresholds(thresholds: RefusedBequestThresholds) -> Self {
Self {
config: DetectorConfig::new(),
thresholds,
}
}
#[allow(dead_code)] pub fn with_config(config: DetectorConfig) -> Self {
let thresholds = RefusedBequestThresholds {
min_overrides: config.get_option_or("min_overrides", 2),
max_parent_call_ratio: config.get_option_or("max_parent_call_ratio", 0.3),
};
Self { config, thresholds }
}
fn is_abstract_parent(&self, parent_name: &str) -> bool {
if parent_name.is_empty() {
return false;
}
let parent_lower = parent_name.to_lowercase();
EXCLUDE_PARENT_PATTERNS
.iter()
.any(|pattern| parent_lower.contains(&pattern.to_lowercase()))
}
fn calculate_severity(&self, ratio: f64) -> Severity {
if ratio == 0.0 {
Severity::High
} else if ratio < 0.2 {
Severity::Medium
} else {
Severity::Low
}
}
fn estimate_effort(&self, severity: Severity) -> String {
match severity {
Severity::Critical | Severity::High => "Medium (2-4 hours)".to_string(),
Severity::Medium => "Medium (1-2 hours)".to_string(),
Severity::Low | Severity::Info => "Small (30-60 minutes)".to_string(),
}
}
fn create_finding(
&self,
_child_name: String,
child_class: String,
_parent_name: String,
parent_class: String,
file_path: String,
line_start: Option<u32>,
line_end: Option<u32>,
total_overrides: usize,
overrides_calling_parent: usize,
) -> Finding {
let ratio = if total_overrides > 0 {
overrides_calling_parent as f64 / total_overrides as f64
} else {
0.0
};
let severity = self.calculate_severity(ratio);
let severity_reason = if ratio == 0.0 {
"No overrides call parent"
} else if ratio < 0.2 {
&format!("Only {:.0}% of overrides call parent", ratio * 100.0)
} else {
&format!("{:.0}% of overrides call parent", ratio * 100.0)
};
let parent_lower = parent_class.to_lowercase();
let recommendation = format!(
"Consider refactoring to use composition instead of inheritance:\n\
1. Replace `class {}({})` with `class {}`\n\
2. Add `{}` as a member: `self.{} = {}()`\n\
3. Delegate only the methods you actually need\n\n\
Benefits: Looser coupling, clearer intent, easier testing",
child_class, parent_class, child_class, parent_lower, parent_lower, parent_class
);
Finding {
id: String::new(),
detector: "RefusedBequestDetector".to_string(),
severity,
title: format!("Refused bequest: {} inherits {}", child_class, parent_class),
description: format!(
"Class '{}' inherits from '{}' but overrides {} method(s) with only {} \
calling the parent ({:.0}%). {}. This suggests inheritance may be misused.",
child_class,
parent_class,
total_overrides,
overrides_calling_parent,
ratio * 100.0,
severity_reason
),
affected_files: vec![PathBuf::from(&file_path)],
line_start,
line_end,
suggested_fix: Some(recommendation),
estimated_effort: Some(self.estimate_effort(severity)),
category: Some("design".to_string()),
cwe_id: None,
why_it_matters: Some(
"Refused bequest violates the Liskov Substitution Principle. \
When a subclass overrides parent methods without calling super(), \
it suggests the inheritance relationship is incorrect. Using composition \
instead of inheritance leads to more flexible and maintainable code."
.to_string(),
),
..Default::default()
}
}
fn check_polymorphic_usage(
&self,
graph: &dyn crate::graph::GraphQuery,
child_qn: &str,
parent_qn: &str,
) -> bool {
let child_methods: Vec<_> = graph
.get_functions()
.into_iter()
.filter(|f| f.qualified_name.starts_with(child_qn))
.collect();
for method in &child_methods {
let callers = graph.get_callers(&method.qualified_name);
for caller in &callers {
let caller_callees = graph.get_callees(&caller.qualified_name);
for callee in &caller_callees {
if callee.qualified_name.starts_with(parent_qn) {
debug!(
"Polymorphic usage: {} calls both {} and parent {}",
caller.name, method.name, parent_qn
);
return true;
}
}
}
}
false
}
fn get_inheritance_depth(&self, graph: &dyn crate::graph::GraphQuery, class_qn: &str) -> usize {
let mut depth = 0;
let mut current = class_qn.to_string();
let mut seen = HashSet::new();
while let Some((_, parent)) = graph
.get_inheritance()
.into_iter()
.find(|(child, _)| child == ¤t)
{
if seen.contains(&parent) {
break; }
seen.insert(parent.clone());
current = parent;
depth += 1;
if depth > 10 {
break; }
}
depth
}
fn check_divergent_callers(
&self,
graph: &dyn crate::graph::GraphQuery,
child_methods: &[crate::graph::CodeNode],
parent_qn: &str,
) -> bool {
let parent_methods: Vec<_> = graph
.get_functions()
.into_iter()
.filter(|f| f.qualified_name.starts_with(parent_qn))
.collect();
for child_method in child_methods {
let method_name = child_method.name.clone();
let child_callers: HashSet<_> = graph
.get_callers(&child_method.qualified_name)
.into_iter()
.map(|c| c.qualified_name)
.collect();
if let Some(parent_method) = parent_methods.iter().find(|m| m.name == method_name) {
let parent_callers: HashSet<_> = graph
.get_callers(&parent_method.qualified_name)
.into_iter()
.map(|c| c.qualified_name)
.collect();
let unique_child_callers: HashSet<_> =
child_callers.difference(&parent_callers).collect();
if unique_child_callers.len() >= 2 {
return true;
}
}
}
false
}
}
impl Default for RefusedBequestDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for RefusedBequestDetector {
fn name(&self) -> &'static str {
"RefusedBequestDetector"
}
fn description(&self) -> &'static str {
"Detects classes that inherit but don't use parent functionality"
}
fn category(&self) -> &'static str {
"design"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
fn detect(&self, graph: &dyn crate::graph::GraphQuery) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
let _all_classes: HashSet<String> = graph
.get_classes()
.into_iter()
.map(|c| c.qualified_name.clone())
.collect();
for (child_qn, parent_qn) in graph.get_inheritance() {
if parent_qn.contains("Base")
|| parent_qn.contains("Abstract")
|| parent_qn.contains("Mixin")
{
continue;
}
if let Some(child) = graph.get_node(&child_qn) {
let child_methods: Vec<_> = graph
.get_functions()
.into_iter()
.filter(|f| f.qualified_name.starts_with(&child_qn))
.collect();
if child_methods.len() >= 3 {
let potential_refusals: Vec<_> = child_methods
.iter()
.filter(|m| m.complexity().unwrap_or(1) <= 2 && m.loc() <= 5)
.collect();
if potential_refusals.len() >= 2 {
let is_polymorphic =
self.check_polymorphic_usage(graph, &child_qn, &parent_qn);
let inheritance_depth = self.get_inheritance_depth(graph, &parent_qn);
let has_divergent_callers =
self.check_divergent_callers(graph, &child_methods, &parent_qn);
let severity = if is_polymorphic && has_divergent_callers {
Severity::High
} else if is_polymorphic || inheritance_depth >= 3 {
Severity::Medium
} else {
Severity::Low
};
let mut notes = Vec::new();
if is_polymorphic {
notes.push("⚠️ Used polymorphically (through parent type)".to_string());
}
if has_divergent_callers {
notes.push("📞 Callers use it differently than parent".to_string());
}
if inheritance_depth >= 2 {
notes.push(format!("📊 Inheritance depth: {}", inheritance_depth));
}
let graph_notes = if notes.is_empty() {
String::new()
} else {
format!("\n\n**Graph analysis:**\n{}", notes.join("\n"))
};
findings.push(Finding {
id: String::new(),
detector: "RefusedBequestDetector".to_string(),
severity,
title: format!("Refused Bequest: {}", child.name),
description: format!(
"Class '{}' inherits from '{}' but may not use inherited behavior properly.\n\n\
{} of {} methods appear to override without using parent.{}",
child.name,
parent_qn.rsplit("::").next().unwrap_or(&parent_qn),
potential_refusals.len(),
child_methods.len(),
graph_notes
),
affected_files: vec![child.file_path.clone().into()],
line_start: Some(child.line_start),
line_end: Some(child.line_end),
suggested_fix: Some(
if is_polymorphic {
format!(
"Since '{}' is used polymorphically, consider:\n\
1. Fix the overrides to properly extend parent behavior\n\
2. Extract a new interface that both classes implement\n\
3. Use the Strategy pattern if behavior varies",
child.name
)
} else {
"Consider composition over inheritance if not using parent behavior".to_string()
}
),
estimated_effort: Some(if severity == Severity::High {
"Large (2-4 hours)".to_string()
} else {
"Medium (1-2 hours)".to_string()
}),
category: Some("structure".to_string()),
cwe_id: None,
why_it_matters: Some(
"Refused bequest indicates improper use of inheritance and may violate \
the Liskov Substitution Principle. This makes code harder to reason about \
and can lead to subtle bugs when the child is used in place of the parent."
.to_string()
),
..Default::default()
});
}
}
}
}
info!(
"RefusedBequestDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_thresholds() {
let detector = RefusedBequestDetector::new();
assert_eq!(detector.thresholds.min_overrides, 2);
assert!((detector.thresholds.max_parent_call_ratio - 0.3).abs() < f64::EPSILON);
}
#[test]
fn test_is_abstract_parent() {
let detector = RefusedBequestDetector::new();
assert!(detector.is_abstract_parent("ABC"));
assert!(detector.is_abstract_parent("AbstractBase"));
assert!(detector.is_abstract_parent("BaseClass"));
assert!(detector.is_abstract_parent("UserInterface"));
assert!(detector.is_abstract_parent("MyMixin"));
assert!(!detector.is_abstract_parent("User"));
assert!(!detector.is_abstract_parent("OrderService"));
}
#[test]
fn test_severity_calculation() {
let detector = RefusedBequestDetector::new();
assert_eq!(detector.calculate_severity(0.0), Severity::High);
assert_eq!(detector.calculate_severity(0.1), Severity::Medium);
assert_eq!(detector.calculate_severity(0.2), Severity::Low);
assert_eq!(detector.calculate_severity(0.5), Severity::Low);
}
}