use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tldr_core::analysis::refcount::{count_identifiers_in_tree, is_rescued_by_refcount};
use tldr_core::ast::parser::parse_file;
use tldr_core::Language;
use super::types::BugbotFinding;
use crate::commands::dead::collect_module_infos_with_refcounts;
use crate::commands::remaining::types::ASTChange;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BornDeadEvidence {
pub is_public: bool,
pub ref_count: usize,
}
pub fn compose_born_dead(
inserted: &[&ASTChange],
project: &Path,
language: &Language,
) -> Result<Vec<BugbotFinding>> {
if inserted.is_empty() {
return Ok(Vec::new());
}
let (_module_infos, ref_counts) = collect_module_infos_with_refcounts(project, *language);
compose_born_dead_with_refcounts(inserted, &ref_counts)
}
pub fn compose_born_dead_scoped(
inserted: &[&ASTChange],
changed_files: &[PathBuf],
project: &Path,
language: &Language,
) -> Result<Vec<BugbotFinding>> {
if inserted.is_empty() {
return Ok(Vec::new());
}
let mut ref_counts: HashMap<String, usize> = HashMap::new();
for file in changed_files {
if !file.exists() {
continue;
}
if let Ok((tree, source, lang)) = parse_file(file) {
let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
for (name, count) in file_counts {
*ref_counts.entry(name).or_insert(0) += count;
}
}
}
let importer_files = find_importer_files(changed_files, project, language);
for file in &importer_files {
if !file.exists() {
continue;
}
if changed_files.iter().any(|cf| cf == file) {
continue;
}
if let Ok((tree, source, lang)) = parse_file(file) {
let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
for (name, count) in file_counts {
*ref_counts.entry(name).or_insert(0) += count;
}
}
}
compose_born_dead_with_refcounts(inserted, &ref_counts)
}
fn find_importer_files(
changed_files: &[PathBuf],
project: &Path,
language: &Language,
) -> Vec<PathBuf> {
let mut importer_paths = Vec::new();
let mut seen = std::collections::HashSet::new();
for file in changed_files {
let rel = file.strip_prefix(project).unwrap_or(file);
let module_candidates = derive_module_names(rel);
for module_name in &module_candidates {
if let Ok(report) =
tldr_core::analysis::importers::find_importers(project, module_name, *language)
{
for importer in &report.importers {
let importer_path = project.join(&importer.file);
if seen.insert(importer_path.clone()) {
importer_paths.push(importer_path);
}
}
}
}
}
importer_paths
}
fn derive_module_names(rel_path: &Path) -> Vec<String> {
let mut names = Vec::new();
let stem = rel_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if stem.is_empty() {
return names;
}
names.push(stem.to_string());
let components: Vec<&str> = rel_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
if components.len() > 1 {
let skip = if components[0] == "src" || components[0] == "lib" {
1
} else {
0
};
let module_parts: Vec<&str> = components[skip..].to_vec();
if let Some(last) = module_parts.last() {
let mut parts: Vec<String> = module_parts[..module_parts.len() - 1]
.iter()
.map(|s| s.to_string())
.collect();
let last_stem = Path::new(last)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(last);
if last_stem != "mod" && last_stem != "__init__" {
parts.push(last_stem.to_string());
}
if !parts.is_empty() {
let qualified = parts.join("::");
if qualified != stem {
names.push(qualified.clone());
}
names.push(format!("crate::{}", qualified));
}
}
}
names
}
pub fn compose_born_dead_with_refcounts(
inserted: &[&ASTChange],
ref_counts: &HashMap<String, usize>,
) -> Result<Vec<BugbotFinding>> {
let mut findings = Vec::new();
for change in inserted {
let name = match change.name.as_deref() {
Some(n) => n,
None => continue, };
if is_test_function(name) {
continue;
}
let file_path = change
.new_location
.as_ref()
.map(|loc| loc.file.as_str())
.unwrap_or("");
if is_test_file(file_path) {
continue;
}
if is_entry_point(name) {
continue;
}
let new_text = change.new_text.as_deref().unwrap_or("");
if is_trait_impl(name, new_text) {
continue;
}
if is_rescued_by_refcount(name, ref_counts) {
continue;
}
let is_public = new_text.contains("pub fn ") || new_text.contains("pub async fn ");
let ref_count = lookup_ref_count(name, ref_counts);
let line = change
.new_location
.as_ref()
.map(|loc| loc.line as usize)
.unwrap_or(0);
let file = change
.new_location
.as_ref()
.map(|loc| PathBuf::from(&loc.file))
.unwrap_or_default();
let severity = if is_public { "medium" } else { "low" };
findings.push(BugbotFinding {
finding_type: "born-dead".to_string(),
severity: severity.to_string(),
file,
function: name.to_string(),
line,
message: format!(
"New function '{}' has no callers (ref_count: {})",
name, ref_count
),
evidence: serde_json::to_value(&BornDeadEvidence {
is_public,
ref_count,
})
.unwrap_or_default(),
confidence: None,
finding_id: None,
});
}
Ok(findings)
}
fn lookup_ref_count(name: &str, ref_counts: &HashMap<String, usize>) -> usize {
let bare_name = if name.contains('.') {
name.rsplit('.').next().unwrap_or(name)
} else if name.contains(':') {
name.rsplit(':').next().unwrap_or(name)
} else {
name
};
if let Some(&count) = ref_counts.get(bare_name) {
return count;
}
if bare_name != name {
if let Some(&count) = ref_counts.get(name) {
return count;
}
}
0
}
pub fn is_test_function(name: &str) -> bool {
name.starts_with("test_")
|| name == "test"
|| name.starts_with("Test")
|| name.starts_with("Benchmark")
|| name.starts_with("Example")
}
fn is_test_file(path: &str) -> bool {
let path = path.replace('\\', "/");
if path.contains("/tests/")
|| path.contains("/test/")
|| path.contains("/__tests__/")
|| path.contains("/testing/")
{
return true;
}
let filename = path.rsplit('/').next().unwrap_or(&path);
filename.ends_with("_test.rs")
|| filename.ends_with("_tests.rs")
|| filename.ends_with("_test.go")
|| filename.ends_with("_test.py")
|| filename.starts_with("test_")
|| filename.ends_with(".test.ts")
|| filename.ends_with(".test.tsx")
|| filename.ends_with(".test.js")
|| filename.ends_with(".test.jsx")
|| filename.ends_with(".spec.ts")
|| filename.ends_with(".spec.tsx")
|| filename.ends_with(".spec.js")
|| filename.ends_with(".spec.jsx")
|| filename.ends_with("Test.java")
|| filename.ends_with("Tests.java")
}
fn is_entry_point(name: &str) -> bool {
matches!(
name,
"main" | "__main__" | "cli" | "app" | "run" | "start" | "create_app" | "make_app" | "lib"
)
}
fn is_trait_impl(name: &str, _text: &str) -> bool {
matches!(
name,
"fmt" | "from" | "into" | "try_from" | "try_into"
| "clone" | "clone_from" | "default" | "drop"
| "deref" | "deref_mut" | "as_ref" | "as_mut"
| "borrow" | "borrow_mut"
| "eq" | "ne" | "partial_cmp" | "cmp" | "hash"
| "next" | "size_hint"
| "index" | "index_mut"
| "from_str" | "to_string" | "write_str"
| "serialize" | "deserialize"
| "poll"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::remaining::types::{ChangeType, Location, NodeKind};
fn make_insert(name: &str, text: &str, file: &str, line: u32) -> ASTChange {
ASTChange {
change_type: ChangeType::Insert,
node_kind: NodeKind::Function,
name: Some(name.to_string()),
old_location: None,
new_location: Some(Location {
file: file.to_string(),
line,
column: 0,
end_line: None,
end_column: None,
}),
old_text: None,
new_text: Some(text.to_string()),
similarity: None,
children: None,
base_changes: None,
}
}
#[test]
fn test_born_dead_new_unused_function() {
let insert = make_insert(
"helper",
"fn helper() { println!(\"unused\"); }",
"src/lib.rs",
10,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("helper".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(findings.len(), 1, "Should detect one born-dead function");
assert_eq!(findings[0].finding_type, "born-dead");
assert_eq!(findings[0].function, "helper");
assert_eq!(findings[0].line, 10);
}
#[test]
fn test_born_dead_new_used_function() {
let insert = make_insert(
"helper",
"fn helper() { println!(\"used\"); }",
"src/lib.rs",
10,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("helper".to_string(), 3);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Function with callers should not be flagged, got: {:?}",
findings
);
}
#[test]
fn test_born_dead_public_medium_severity() {
let insert = make_insert(
"unused_pub",
"pub fn unused_pub() { }",
"src/lib.rs",
5,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("unused_pub".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].severity, "medium",
"Public unused function should have medium severity"
);
let evidence: BornDeadEvidence =
serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
assert!(evidence.is_public, "Evidence should mark as public");
assert_eq!(evidence.ref_count, 1);
}
#[test]
fn test_born_dead_private_low_severity() {
let insert = make_insert(
"unused_priv",
"fn unused_priv() { }",
"src/lib.rs",
5,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("unused_priv".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].severity, "low",
"Private unused function should have low severity"
);
let evidence: BornDeadEvidence =
serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
assert!(!evidence.is_public, "Evidence should mark as private");
}
#[test]
fn test_born_dead_test_function_suppressed() {
let insert = make_insert(
"test_something",
"fn test_something() { assert!(true); }",
"tests/my_test.rs",
1,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("test_something".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Test functions should be suppressed, got: {:?}",
findings
);
}
#[test]
fn test_born_dead_main_suppressed() {
let insert = make_insert("main", "fn main() { }", "src/main.rs", 1);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("main".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Entry point 'main' should be suppressed, got: {:?}",
findings
);
}
#[test]
fn test_born_dead_empty_inserted_list() {
let inserted: Vec<&ASTChange> = vec![];
let ref_counts = HashMap::new();
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(findings.is_empty(), "Empty input should produce no findings");
}
#[test]
fn test_born_dead_no_name_skipped() {
let change = ASTChange {
change_type: ChangeType::Insert,
node_kind: NodeKind::Function,
name: None,
old_location: None,
new_location: Some(Location {
file: "src/lib.rs".to_string(),
line: 1,
column: 0,
end_line: None,
end_column: None,
}),
old_text: None,
new_text: Some("fn () { }".to_string()),
similarity: None,
children: None,
base_changes: None,
};
let inserted: Vec<&ASTChange> = vec![&change];
let ref_counts = HashMap::new();
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Change with no name should be skipped"
);
}
#[test]
fn test_born_dead_zero_refcount_means_dead() {
let insert = make_insert(
"orphan_func",
"fn orphan_func() { }",
"src/lib.rs",
20,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let ref_counts = HashMap::new();
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(findings.len(), 1, "Function not in refcounts should be dead");
assert_eq!(findings[0].function, "orphan_func");
let evidence: BornDeadEvidence =
serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
assert_eq!(evidence.ref_count, 0);
}
#[test]
fn test_born_dead_multiple_findings() {
let insert1 = make_insert("dead_one", "fn dead_one() { }", "src/a.rs", 1);
let insert2 = make_insert("alive_one", "fn alive_one() { }", "src/a.rs", 10);
let insert3 = make_insert("dead_two", "pub fn dead_two() { }", "src/b.rs", 5);
let inserted: Vec<&ASTChange> = vec![&insert1, &insert2, &insert3];
let mut ref_counts = HashMap::new();
ref_counts.insert("dead_one".to_string(), 1);
ref_counts.insert("alive_one".to_string(), 4);
ref_counts.insert("dead_two".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(
findings.len(),
2,
"Should find 2 dead functions, got: {:?}",
findings
.iter()
.map(|f| &f.function)
.collect::<Vec<_>>()
);
let names: Vec<&str> = findings.iter().map(|f| f.function.as_str()).collect();
assert!(names.contains(&"dead_one"));
assert!(names.contains(&"dead_two"));
assert!(!names.contains(&"alive_one"));
}
#[test]
fn test_born_dead_benchmark_function_suppressed() {
let insert = make_insert(
"BenchmarkSomething",
"fn BenchmarkSomething() { }",
"benches/bench.rs",
1,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("BenchmarkSomething".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Benchmark functions should be suppressed"
);
}
#[test]
fn test_born_dead_short_name_not_rescued() {
let insert = make_insert("ab", "fn ab() { }", "src/lib.rs", 1);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("ab".to_string(), 4);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert_eq!(
findings.len(),
1,
"Short-named function with count < 5 should not be rescued"
);
}
#[test]
fn test_lookup_ref_count_qualified_name() {
let mut ref_counts = HashMap::new();
ref_counts.insert("method".to_string(), 3);
assert_eq!(lookup_ref_count("Class.method", &ref_counts), 3);
assert_eq!(lookup_ref_count("module:method", &ref_counts), 3);
assert_eq!(lookup_ref_count("method", &ref_counts), 3);
assert_eq!(lookup_ref_count("unknown", &ref_counts), 0);
}
#[test]
fn test_is_test_function_patterns() {
assert!(is_test_function("test_something"));
assert!(is_test_function("test"));
assert!(is_test_function("TestFoo"));
assert!(is_test_function("BenchmarkBar"));
assert!(is_test_function("ExampleBaz"));
assert!(!is_test_function("helper"));
assert!(!is_test_function("testing_mode")); assert!(!is_test_function("contestant"));
}
#[test]
fn test_is_entry_point_patterns() {
assert!(is_entry_point("main"));
assert!(is_entry_point("lib"));
assert!(is_entry_point("__main__"));
assert!(is_entry_point("cli"));
assert!(!is_entry_point("helper"));
assert!(!is_entry_point("main_loop")); }
#[test]
fn test_is_trait_impl_std_trait_methods() {
let std_methods = [
"fmt", "from", "into", "try_from", "try_into",
"clone", "clone_from", "default", "drop",
"deref", "deref_mut", "as_ref", "as_mut",
"borrow", "borrow_mut",
"eq", "ne", "partial_cmp", "cmp", "hash",
"next", "size_hint",
"index", "index_mut",
"from_str", "to_string", "write_str",
"serialize", "deserialize",
"poll",
];
for method in &std_methods {
assert!(
is_trait_impl(method, ""),
"'{}' should be recognised as a trait impl method",
method
);
}
}
#[test]
fn test_is_trait_impl_non_trait_methods() {
assert!(!is_trait_impl("helper", ""));
assert!(!is_trait_impl("process_data", ""));
assert!(!is_trait_impl("run", ""));
assert!(!is_trait_impl("build", ""));
assert!(!is_trait_impl("new", ""));
assert!(!is_trait_impl("main", ""));
assert!(!is_trait_impl("calculate", ""));
}
#[test]
fn test_born_dead_trait_impl_method_suppressed() {
let insert = make_insert(
"fmt",
"fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) }",
"src/lib.rs",
10,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let mut ref_counts = HashMap::new();
ref_counts.insert("fmt".to_string(), 1);
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Trait impl method 'fmt' should be suppressed, got: {:?}",
findings
);
}
#[test]
fn test_born_dead_test_file_suppressed() {
let insert = make_insert(
"validate_output_format",
"fn validate_output_format() { assert!(true); }",
"crates/cli/tests/integration_test.rs",
50,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let ref_counts = HashMap::new();
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Function in test file should be suppressed, got: {:?}",
findings
);
}
#[test]
fn test_born_dead_test_directory_suppressed() {
let insert = make_insert(
"setup_mock_server",
"fn setup_mock_server() {}",
"src/tests/helpers.rs",
10,
);
let inserted: Vec<&ASTChange> = vec![&insert];
let ref_counts = HashMap::new();
let findings =
compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
assert!(
findings.is_empty(),
"Function in tests directory should be suppressed, got: {:?}",
findings
);
}
#[test]
fn test_is_test_file_patterns() {
assert!(is_test_file("crates/cli/tests/integration_test.rs"));
assert!(is_test_file("src/tests/helpers.rs"));
assert!(is_test_file("src/test/java/FooTest.java"));
assert!(is_test_file("src/__tests__/foo.test.ts"));
assert!(is_test_file("tests/test_foo.py"));
assert!(is_test_file("pkg/handler_test.go"));
assert!(is_test_file("src/utils.spec.ts"));
assert!(is_test_file("FooTest.java"));
assert!(is_test_file("FooTests.java"));
assert!(!is_test_file("src/lib.rs"));
assert!(!is_test_file("src/main.py"));
assert!(!is_test_file("src/testing_utils.rs")); assert!(!is_test_file("src/contest.rs"));
}
}