Skip to main content

tldr_cli/commands/bugbot/
dead.rs

1//! Born-dead detection for bugbot
2//!
3//! Detects new functions (added in the current changeset) that have zero
4//! references anywhere in the codebase.  Zero false positives by construction:
5//! if you just wrote a function and nothing calls it, it is dead.
6//!
7//! Uses the reference-counting approach from the `dead` command (single-pass
8//! identifier scan via tree-sitter) rather than the full call graph.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::Result;
14use serde::{Deserialize, Serialize};
15
16use tldr_core::analysis::refcount::{count_identifiers_in_tree, is_rescued_by_refcount};
17use tldr_core::ast::parser::parse_file;
18use tldr_core::Language;
19
20use super::types::BugbotFinding;
21use crate::commands::dead::collect_module_infos_with_refcounts;
22use crate::commands::remaining::types::ASTChange;
23
24/// Evidence payload for a born-dead finding.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BornDeadEvidence {
27    /// Whether the function has `pub` visibility.
28    pub is_public: bool,
29    /// Number of identifier occurrences across the project (1 = definition only).
30    pub ref_count: usize,
31}
32
33/// Check inserted functions for zero references (born dead).
34///
35/// # Arguments
36/// * `inserted` - AST changes of type Insert + Function/Method (from `diff::inserted_functions`)
37/// * `project`  - Project root directory (used to scan for refcounts)
38/// * `language` - Language of the project files
39///
40/// Only call this when there are Insert changes -- the refcount scan is the
41/// expensive part (~seconds for large projects).
42pub fn compose_born_dead(
43    inserted: &[&ASTChange],
44    project: &Path,
45    language: &Language,
46) -> Result<Vec<BugbotFinding>> {
47    if inserted.is_empty() {
48        return Ok(Vec::new());
49    }
50
51    // Scan the entire project for identifier refcounts (single-pass tree-sitter).
52    let (_module_infos, ref_counts) =
53        collect_module_infos_with_refcounts(project, *language, false);
54
55    compose_born_dead_with_refcounts(inserted, &ref_counts)
56}
57
58/// Scoped born-dead detection: only scan changed files + their importers.
59///
60/// Instead of scanning the entire project (which takes minutes on large repos),
61/// this scans only the files that could possibly reference the new functions:
62/// - The changed files themselves (a new call must be in a changed file)
63/// - Files that import from changed modules (covers pre-existing call sites)
64///
65/// This reduces the scan from 847 files / 3+ minutes to ~30 files / <100ms
66/// on the TLDR codebase.
67pub fn compose_born_dead_scoped(
68    inserted: &[&ASTChange],
69    changed_files: &[PathBuf],
70    project: &Path,
71    language: &Language,
72) -> Result<Vec<BugbotFinding>> {
73    if inserted.is_empty() {
74        return Ok(Vec::new());
75    }
76
77    // Tier 1: Count identifiers in changed files only.
78    let mut ref_counts: HashMap<String, usize> = HashMap::new();
79    for file in changed_files {
80        if !file.exists() {
81            continue;
82        }
83        if let Ok((tree, source, lang)) = parse_file(file) {
84            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
85            for (name, count) in file_counts {
86                *ref_counts.entry(name).or_insert(0) += count;
87            }
88        }
89    }
90
91    // Tier 2: Find files that import changed modules, scan those too.
92    // This catches the case where a new function satisfies a pre-existing
93    // call site in an unchanged file that imports the changed module.
94    let importer_files = find_importer_files(changed_files, project, language);
95    for file in &importer_files {
96        if !file.exists() {
97            continue;
98        }
99        // Skip files already scanned in tier 1
100        if changed_files.iter().any(|cf| cf == file) {
101            continue;
102        }
103        if let Ok((tree, source, lang)) = parse_file(file) {
104            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
105            for (name, count) in file_counts {
106                *ref_counts.entry(name).or_insert(0) += count;
107            }
108        }
109    }
110
111    compose_born_dead_with_refcounts(inserted, &ref_counts)
112}
113
114/// Find files that import any of the changed modules.
115///
116/// For each changed file, derives its module name and calls `find_importers`
117/// to locate files that import it. Returns a deduplicated list of file paths.
118fn find_importer_files(
119    changed_files: &[PathBuf],
120    project: &Path,
121    language: &Language,
122) -> Vec<PathBuf> {
123    let mut importer_paths = Vec::new();
124    let mut seen = std::collections::HashSet::new();
125
126    for file in changed_files {
127        // Derive module name from file path relative to project root.
128        // e.g., "src/foo.rs" -> try "foo", "crate::foo", "src/foo"
129        let rel = file.strip_prefix(project).unwrap_or(file);
130        let module_candidates = derive_module_names(rel);
131
132        for module_name in &module_candidates {
133            if let Ok(report) =
134                tldr_core::analysis::importers::find_importers(project, module_name, *language)
135            {
136                for importer in &report.importers {
137                    let importer_path = project.join(&importer.file);
138                    if seen.insert(importer_path.clone()) {
139                        importer_paths.push(importer_path);
140                    }
141                }
142            }
143        }
144    }
145
146    importer_paths
147}
148
149/// Derive possible module names from a relative file path.
150///
151/// e.g., `src/utils/helpers.rs` -> `["helpers", "utils::helpers", "crate::utils::helpers"]`
152/// e.g., `lib.rs` -> `["lib"]`
153fn derive_module_names(rel_path: &Path) -> Vec<String> {
154    let mut names = Vec::new();
155    let stem = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
156
157    if stem.is_empty() {
158        return names;
159    }
160
161    // Bare module name (e.g., "helpers")
162    names.push(stem.to_string());
163
164    // Build path-based module name, stripping src/ prefix and extension
165    let components: Vec<&str> = rel_path
166        .components()
167        .filter_map(|c| c.as_os_str().to_str())
168        .collect();
169
170    if components.len() > 1 {
171        // Skip common source directory prefixes
172        let skip = if components[0] == "src" || components[0] == "lib" {
173            1
174        } else {
175            0
176        };
177        let module_parts: Vec<&str> = components[skip..].to_vec();
178
179        // Replace file extension in last component with nothing
180        if let Some(last) = module_parts.last() {
181            let mut parts: Vec<String> = module_parts[..module_parts.len() - 1]
182                .iter()
183                .map(|s| s.to_string())
184                .collect();
185            let last_stem = Path::new(last)
186                .file_stem()
187                .and_then(|s| s.to_str())
188                .unwrap_or(last);
189            // Skip mod.rs / __init__.py — use parent as module name
190            if last_stem != "mod" && last_stem != "__init__" {
191                parts.push(last_stem.to_string());
192            }
193
194            if !parts.is_empty() {
195                // Qualified path (e.g., "utils::helpers")
196                let qualified = parts.join("::");
197                if qualified != stem {
198                    names.push(qualified.clone());
199                }
200                // Crate-prefixed (e.g., "crate::utils::helpers")
201                names.push(format!("crate::{}", qualified));
202            }
203        }
204    }
205
206    names
207}
208
209/// Inner implementation that accepts pre-computed refcounts.
210///
211/// Separated so tests can provide synthetic refcount maps without scanning a
212/// real project directory.
213pub fn compose_born_dead_with_refcounts(
214    inserted: &[&ASTChange],
215    ref_counts: &HashMap<String, usize>,
216) -> Result<Vec<BugbotFinding>> {
217    let mut findings = Vec::new();
218
219    for change in inserted {
220        let name = match change.name.as_deref() {
221            Some(n) => n,
222            None => continue, // no name => skip
223        };
224
225        // Skip test functions (they are entry points invoked by test runners)
226        if is_test_function(name) {
227            continue;
228        }
229
230        // Skip functions in test files — they are test infrastructure
231        // invoked by test harnesses, not dead code.
232        let file_path = change
233            .new_location
234            .as_ref()
235            .map(|loc| loc.file.as_str())
236            .unwrap_or("");
237        if is_test_file(file_path) {
238            continue;
239        }
240
241        // Skip standard entry points (main, etc.)
242        if is_entry_point(name) {
243            continue;
244        }
245
246        // Skip trait impl methods -- heuristic: check `new_text` for `impl ... for`
247        let new_text = change.new_text.as_deref().unwrap_or("");
248        if is_trait_impl(name, new_text) {
249            continue;
250        }
251
252        // Check refcount: if rescued (ref_count > 1, name >= 3 chars) => alive
253        if is_rescued_by_refcount(name, ref_counts) {
254            continue;
255        }
256
257        // Not rescued => born dead
258        let is_public = new_text.contains("pub fn ") || new_text.contains("pub async fn ");
259        let ref_count = lookup_ref_count(name, ref_counts);
260
261        let line = change
262            .new_location
263            .as_ref()
264            .map(|loc| loc.line as usize)
265            .unwrap_or(0);
266
267        let file = change
268            .new_location
269            .as_ref()
270            .map(|loc| PathBuf::from(&loc.file))
271            .unwrap_or_default();
272
273        let severity = if is_public { "medium" } else { "low" };
274
275        findings.push(BugbotFinding {
276            finding_type: "born-dead".to_string(),
277            severity: severity.to_string(),
278            file,
279            function: name.to_string(),
280            line,
281            message: format!(
282                "New function '{}' has no callers (ref_count: {})",
283                name, ref_count
284            ),
285            evidence: serde_json::to_value(&BornDeadEvidence {
286                is_public,
287                ref_count,
288            })
289            .unwrap_or_default(),
290            confidence: None,
291            finding_id: None,
292        });
293    }
294
295    Ok(findings)
296}
297
298/// Look up the refcount for a function name, handling qualified names.
299fn lookup_ref_count(name: &str, ref_counts: &HashMap<String, usize>) -> usize {
300    // Try bare name first (e.g. "method" from "Class.method")
301    let bare_name = if name.contains('.') {
302        name.rsplit('.').next().unwrap_or(name)
303    } else if name.contains(':') {
304        name.rsplit(':').next().unwrap_or(name)
305    } else {
306        name
307    };
308
309    if let Some(&count) = ref_counts.get(bare_name) {
310        return count;
311    }
312    if bare_name != name {
313        if let Some(&count) = ref_counts.get(name) {
314            return count;
315        }
316    }
317    0
318}
319
320/// Check if a function name looks like a test function.
321pub fn is_test_function(name: &str) -> bool {
322    name.starts_with("test_")
323        || name == "test"
324        || name.starts_with("Test")
325        || name.starts_with("Benchmark")
326        || name.starts_with("Example")
327}
328
329/// Check if a file path indicates a test file.
330///
331/// Covers common conventions across languages:
332/// - Rust: `tests/` directory, `_test.rs`, `_tests.rs`
333/// - Python: `test_*.py`, `*_test.py`, `tests/` directory
334/// - JS/TS: `*.test.ts`, `*.spec.ts`, `__tests__/`
335/// - Go: `*_test.go`
336/// - Java: `*Test.java`, `src/test/`
337fn is_test_file(path: &str) -> bool {
338    // Normalize separators for cross-platform matching
339    let path = path.replace('\\', "/");
340
341    // Directory-based patterns
342    if path.contains("/tests/")
343        || path.contains("/test/")
344        || path.contains("/__tests__/")
345        || path.contains("/testing/")
346    {
347        return true;
348    }
349
350    // File suffix patterns (extract the filename)
351    let filename = path.rsplit('/').next().unwrap_or(&path);
352    filename.ends_with("_test.rs")
353        || filename.ends_with("_tests.rs")
354        || filename.ends_with("_test.go")
355        || filename.ends_with("_test.py")
356        || filename.starts_with("test_")
357        || filename.ends_with(".test.ts")
358        || filename.ends_with(".test.tsx")
359        || filename.ends_with(".test.js")
360        || filename.ends_with(".test.jsx")
361        || filename.ends_with(".spec.ts")
362        || filename.ends_with(".spec.tsx")
363        || filename.ends_with(".spec.js")
364        || filename.ends_with(".spec.jsx")
365        || filename.ends_with("Test.java")
366        || filename.ends_with("Tests.java")
367}
368
369/// Check if a function name is a standard entry point.
370fn is_entry_point(name: &str) -> bool {
371    matches!(
372        name,
373        "main" | "__main__" | "cli" | "app" | "run" | "start" | "create_app" | "make_app" | "lib"
374    )
375}
376
377/// Name-based heuristic: suppress born-dead findings for common standard-library
378/// trait method names.
379///
380/// The `new_text` field in an `ASTChange` contains the function body, not the
381/// surrounding `impl` block, so we cannot inspect the impl context.  Instead we
382/// check the function name against known trait methods from `std` and popular
383/// crates (serde).  These methods are invoked polymorphically and almost never
384/// truly dead.
385///
386/// False negatives (a trait impl method we don't recognise) produce an extra
387/// finding the user can ignore.  False positives (silencing real dead code) are
388/// worse, so the list is kept conservative -- only names that are exclusively
389/// or overwhelmingly used as trait implementations.
390fn is_trait_impl(name: &str, _text: &str) -> bool {
391    matches!(
392        name,
393        "fmt"
394            | "from"
395            | "into"
396            | "try_from"
397            | "try_into"
398            | "clone"
399            | "clone_from"
400            | "default"
401            | "drop"
402            | "deref"
403            | "deref_mut"
404            | "as_ref"
405            | "as_mut"
406            | "borrow"
407            | "borrow_mut"
408            | "eq"
409            | "ne"
410            | "partial_cmp"
411            | "cmp"
412            | "hash"
413            | "next"
414            | "size_hint"
415            | "index"
416            | "index_mut"
417            | "from_str"
418            | "to_string"
419            | "write_str"
420            | "serialize"
421            | "deserialize"
422            | "poll"
423    )
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::commands::remaining::types::{ChangeType, Location, NodeKind};
430
431    /// Helper to build an ASTChange representing an inserted function.
432    fn make_insert(name: &str, text: &str, file: &str, line: u32) -> ASTChange {
433        ASTChange {
434            change_type: ChangeType::Insert,
435            node_kind: NodeKind::Function,
436            name: Some(name.to_string()),
437            old_location: None,
438            new_location: Some(Location {
439                file: file.to_string(),
440                line,
441                column: 0,
442                end_line: None,
443                end_column: None,
444            }),
445            old_text: None,
446            new_text: Some(text.to_string()),
447            similarity: None,
448            children: None,
449            base_changes: None,
450        }
451    }
452
453    #[test]
454    fn test_born_dead_new_unused_function() {
455        let insert = make_insert(
456            "helper",
457            "fn helper() { println!(\"unused\"); }",
458            "src/lib.rs",
459            10,
460        );
461        let inserted: Vec<&ASTChange> = vec![&insert];
462
463        // refcounts: "helper" appears once (its definition)
464        let mut ref_counts = HashMap::new();
465        ref_counts.insert("helper".to_string(), 1);
466
467        let findings =
468            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
469
470        assert_eq!(findings.len(), 1, "Should detect one born-dead function");
471        assert_eq!(findings[0].finding_type, "born-dead");
472        assert_eq!(findings[0].function, "helper");
473        assert_eq!(findings[0].line, 10);
474    }
475
476    #[test]
477    fn test_born_dead_new_used_function() {
478        let insert = make_insert(
479            "helper",
480            "fn helper() { println!(\"used\"); }",
481            "src/lib.rs",
482            10,
483        );
484        let inserted: Vec<&ASTChange> = vec![&insert];
485
486        // refcounts: "helper" appears 3 times (definition + 2 call sites)
487        let mut ref_counts = HashMap::new();
488        ref_counts.insert("helper".to_string(), 3);
489
490        let findings =
491            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
492
493        assert!(
494            findings.is_empty(),
495            "Function with callers should not be flagged, got: {:?}",
496            findings
497        );
498    }
499
500    #[test]
501    fn test_born_dead_public_medium_severity() {
502        let insert = make_insert("unused_pub", "pub fn unused_pub() { }", "src/lib.rs", 5);
503        let inserted: Vec<&ASTChange> = vec![&insert];
504
505        let mut ref_counts = HashMap::new();
506        ref_counts.insert("unused_pub".to_string(), 1);
507
508        let findings =
509            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
510
511        assert_eq!(findings.len(), 1);
512        assert_eq!(
513            findings[0].severity, "medium",
514            "Public unused function should have medium severity"
515        );
516
517        // Verify evidence
518        let evidence: BornDeadEvidence =
519            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
520        assert!(evidence.is_public, "Evidence should mark as public");
521        assert_eq!(evidence.ref_count, 1);
522    }
523
524    #[test]
525    fn test_born_dead_private_low_severity() {
526        let insert = make_insert("unused_priv", "fn unused_priv() { }", "src/lib.rs", 5);
527        let inserted: Vec<&ASTChange> = vec![&insert];
528
529        let mut ref_counts = HashMap::new();
530        ref_counts.insert("unused_priv".to_string(), 1);
531
532        let findings =
533            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
534
535        assert_eq!(findings.len(), 1);
536        assert_eq!(
537            findings[0].severity, "low",
538            "Private unused function should have low severity"
539        );
540
541        let evidence: BornDeadEvidence =
542            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
543        assert!(!evidence.is_public, "Evidence should mark as private");
544    }
545
546    #[test]
547    fn test_born_dead_test_function_suppressed() {
548        let insert = make_insert(
549            "test_something",
550            "fn test_something() { assert!(true); }",
551            "tests/my_test.rs",
552            1,
553        );
554        let inserted: Vec<&ASTChange> = vec![&insert];
555
556        // Even with ref_count=1, test functions should be suppressed
557        let mut ref_counts = HashMap::new();
558        ref_counts.insert("test_something".to_string(), 1);
559
560        let findings =
561            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
562
563        assert!(
564            findings.is_empty(),
565            "Test functions should be suppressed, got: {:?}",
566            findings
567        );
568    }
569
570    #[test]
571    fn test_born_dead_main_suppressed() {
572        let insert = make_insert("main", "fn main() { }", "src/main.rs", 1);
573        let inserted: Vec<&ASTChange> = vec![&insert];
574
575        let mut ref_counts = HashMap::new();
576        ref_counts.insert("main".to_string(), 1);
577
578        let findings =
579            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
580
581        assert!(
582            findings.is_empty(),
583            "Entry point 'main' should be suppressed, got: {:?}",
584            findings
585        );
586    }
587
588    #[test]
589    fn test_born_dead_empty_inserted_list() {
590        let inserted: Vec<&ASTChange> = vec![];
591        let ref_counts = HashMap::new();
592
593        let findings =
594            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
595
596        assert!(
597            findings.is_empty(),
598            "Empty input should produce no findings"
599        );
600    }
601
602    #[test]
603    fn test_born_dead_no_name_skipped() {
604        // An ASTChange with no name should be silently skipped
605        let change = ASTChange {
606            change_type: ChangeType::Insert,
607            node_kind: NodeKind::Function,
608            name: None,
609            old_location: None,
610            new_location: Some(Location {
611                file: "src/lib.rs".to_string(),
612                line: 1,
613                column: 0,
614                end_line: None,
615                end_column: None,
616            }),
617            old_text: None,
618            new_text: Some("fn () { }".to_string()),
619            similarity: None,
620            children: None,
621            base_changes: None,
622        };
623        let inserted: Vec<&ASTChange> = vec![&change];
624        let ref_counts = HashMap::new();
625
626        let findings =
627            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
628
629        assert!(findings.is_empty(), "Change with no name should be skipped");
630    }
631
632    #[test]
633    fn test_born_dead_zero_refcount_means_dead() {
634        // Function not found in refcounts at all => ref_count=0 => dead
635        let insert = make_insert("orphan_func", "fn orphan_func() { }", "src/lib.rs", 20);
636        let inserted: Vec<&ASTChange> = vec![&insert];
637
638        // Empty refcounts -- function name not even found
639        let ref_counts = HashMap::new();
640
641        let findings =
642            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
643
644        assert_eq!(
645            findings.len(),
646            1,
647            "Function not in refcounts should be dead"
648        );
649        assert_eq!(findings[0].function, "orphan_func");
650
651        let evidence: BornDeadEvidence =
652            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
653        assert_eq!(evidence.ref_count, 0);
654    }
655
656    #[test]
657    fn test_born_dead_multiple_findings() {
658        let insert1 = make_insert("dead_one", "fn dead_one() { }", "src/a.rs", 1);
659        let insert2 = make_insert("alive_one", "fn alive_one() { }", "src/a.rs", 10);
660        let insert3 = make_insert("dead_two", "pub fn dead_two() { }", "src/b.rs", 5);
661
662        let inserted: Vec<&ASTChange> = vec![&insert1, &insert2, &insert3];
663
664        let mut ref_counts = HashMap::new();
665        ref_counts.insert("dead_one".to_string(), 1);
666        ref_counts.insert("alive_one".to_string(), 4);
667        ref_counts.insert("dead_two".to_string(), 1);
668
669        let findings =
670            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
671
672        assert_eq!(
673            findings.len(),
674            2,
675            "Should find 2 dead functions, got: {:?}",
676            findings.iter().map(|f| &f.function).collect::<Vec<_>>()
677        );
678
679        let names: Vec<&str> = findings.iter().map(|f| f.function.as_str()).collect();
680        assert!(names.contains(&"dead_one"));
681        assert!(names.contains(&"dead_two"));
682        assert!(!names.contains(&"alive_one"));
683    }
684
685    #[test]
686    fn test_born_dead_benchmark_function_suppressed() {
687        let insert = make_insert(
688            "BenchmarkSomething",
689            "fn BenchmarkSomething() { }",
690            "benches/bench.rs",
691            1,
692        );
693        let inserted: Vec<&ASTChange> = vec![&insert];
694
695        let mut ref_counts = HashMap::new();
696        ref_counts.insert("BenchmarkSomething".to_string(), 1);
697
698        let findings =
699            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
700
701        assert!(
702            findings.is_empty(),
703            "Benchmark functions should be suppressed"
704        );
705    }
706
707    #[test]
708    fn test_born_dead_short_name_not_rescued() {
709        // Short names (< 3 chars) need >= 5 refs to be rescued.
710        // With count=4, still below threshold => dead.
711        let insert = make_insert("ab", "fn ab() { }", "src/lib.rs", 1);
712        let inserted: Vec<&ASTChange> = vec![&insert];
713
714        // ref_count=4 and name is too short (needs >= 5) => not rescued => dead
715        let mut ref_counts = HashMap::new();
716        ref_counts.insert("ab".to_string(), 4);
717
718        let findings =
719            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
720
721        assert_eq!(
722            findings.len(),
723            1,
724            "Short-named function with count < 5 should not be rescued"
725        );
726    }
727
728    #[test]
729    fn test_lookup_ref_count_qualified_name() {
730        let mut ref_counts = HashMap::new();
731        ref_counts.insert("method".to_string(), 3);
732
733        assert_eq!(lookup_ref_count("Class.method", &ref_counts), 3);
734        assert_eq!(lookup_ref_count("module:method", &ref_counts), 3);
735        assert_eq!(lookup_ref_count("method", &ref_counts), 3);
736        assert_eq!(lookup_ref_count("unknown", &ref_counts), 0);
737    }
738
739    #[test]
740    fn test_is_test_function_patterns() {
741        assert!(is_test_function("test_something"));
742        assert!(is_test_function("test"));
743        assert!(is_test_function("TestFoo"));
744        assert!(is_test_function("BenchmarkBar"));
745        assert!(is_test_function("ExampleBaz"));
746        assert!(!is_test_function("helper"));
747        assert!(!is_test_function("testing_mode")); // starts with "Test" check is case-sensitive
748        assert!(!is_test_function("contestant"));
749    }
750
751    #[test]
752    fn test_is_entry_point_patterns() {
753        assert!(is_entry_point("main"));
754        assert!(is_entry_point("lib"));
755        assert!(is_entry_point("__main__"));
756        assert!(is_entry_point("cli"));
757        assert!(!is_entry_point("helper"));
758        assert!(!is_entry_point("main_loop")); // exact match only
759    }
760
761    #[test]
762    fn test_is_trait_impl_std_trait_methods() {
763        // Standard library trait methods should be recognised
764        let std_methods = [
765            "fmt",
766            "from",
767            "into",
768            "try_from",
769            "try_into",
770            "clone",
771            "clone_from",
772            "default",
773            "drop",
774            "deref",
775            "deref_mut",
776            "as_ref",
777            "as_mut",
778            "borrow",
779            "borrow_mut",
780            "eq",
781            "ne",
782            "partial_cmp",
783            "cmp",
784            "hash",
785            "next",
786            "size_hint",
787            "index",
788            "index_mut",
789            "from_str",
790            "to_string",
791            "write_str",
792            "serialize",
793            "deserialize",
794            "poll",
795        ];
796        for method in &std_methods {
797            assert!(
798                is_trait_impl(method, ""),
799                "'{}' should be recognised as a trait impl method",
800                method
801            );
802        }
803    }
804
805    #[test]
806    fn test_is_trait_impl_non_trait_methods() {
807        // Regular function names should NOT be flagged as trait methods
808        assert!(!is_trait_impl("helper", ""));
809        assert!(!is_trait_impl("process_data", ""));
810        assert!(!is_trait_impl("run", ""));
811        assert!(!is_trait_impl("build", ""));
812        assert!(!is_trait_impl("new", ""));
813        assert!(!is_trait_impl("main", ""));
814        assert!(!is_trait_impl("calculate", ""));
815    }
816
817    #[test]
818    fn test_born_dead_trait_impl_method_suppressed() {
819        // A new function named "fmt" with no callers should be suppressed
820        // because it is likely a Display/Debug trait impl.
821        let insert = make_insert(
822            "fmt",
823            "fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) }",
824            "src/lib.rs",
825            10,
826        );
827        let inserted: Vec<&ASTChange> = vec![&insert];
828
829        let mut ref_counts = HashMap::new();
830        ref_counts.insert("fmt".to_string(), 1);
831
832        let findings =
833            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
834
835        assert!(
836            findings.is_empty(),
837            "Trait impl method 'fmt' should be suppressed, got: {:?}",
838            findings
839        );
840    }
841
842    #[test]
843    fn test_born_dead_test_file_suppressed() {
844        // A function in a test file should be suppressed even without test_ prefix
845        let insert = make_insert(
846            "validate_output_format",
847            "fn validate_output_format() { assert!(true); }",
848            "crates/cli/tests/integration_test.rs",
849            50,
850        );
851        let inserted: Vec<&ASTChange> = vec![&insert];
852        let ref_counts = HashMap::new();
853
854        let findings =
855            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
856
857        assert!(
858            findings.is_empty(),
859            "Function in test file should be suppressed, got: {:?}",
860            findings
861        );
862    }
863
864    #[test]
865    fn test_born_dead_test_directory_suppressed() {
866        // Functions in /tests/ directory should be suppressed
867        let insert = make_insert(
868            "setup_mock_server",
869            "fn setup_mock_server() {}",
870            "src/tests/helpers.rs",
871            10,
872        );
873        let inserted: Vec<&ASTChange> = vec![&insert];
874        let ref_counts = HashMap::new();
875
876        let findings =
877            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
878
879        assert!(
880            findings.is_empty(),
881            "Function in tests directory should be suppressed, got: {:?}",
882            findings
883        );
884    }
885
886    #[test]
887    fn test_is_test_file_patterns() {
888        // Positive cases
889        assert!(is_test_file("crates/cli/tests/integration_test.rs"));
890        assert!(is_test_file("src/tests/helpers.rs"));
891        assert!(is_test_file("src/test/java/FooTest.java"));
892        assert!(is_test_file("src/__tests__/foo.test.ts"));
893        assert!(is_test_file("tests/test_foo.py"));
894        assert!(is_test_file("pkg/handler_test.go"));
895        assert!(is_test_file("src/utils.spec.ts"));
896        assert!(is_test_file("FooTest.java"));
897        assert!(is_test_file("FooTests.java"));
898
899        // Negative cases
900        assert!(!is_test_file("src/lib.rs"));
901        assert!(!is_test_file("src/main.py"));
902        assert!(!is_test_file("src/testing_utils.rs")); // /testing/ dir, not this
903        assert!(!is_test_file("src/contest.rs"));
904    }
905}