Skip to main content

codelens_engine/
dead_code.rs

1use crate::call_graph::extract_calls;
2use crate::project::ProjectRoot;
3use anyhow::Result;
4use serde::Serialize;
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use crate::import_graph::parsers::collect_top_level_funcs;
9use crate::import_graph::{DeadCodeEntry, GraphCache, collect_candidate_files};
10
11#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
12pub struct DeadCodeEntryV2 {
13    pub file: String,
14    pub symbol: Option<String>,
15    pub kind: Option<String>,
16    pub line: Option<usize>,
17    pub reason: String,
18    pub pass: u8,
19    /// #268: confidence tier for this dead-code finding. `"high"` for
20    /// clean cases the import graph can fully account for.
21    /// `"needs_structural_evidence"` for TypeScript request/schema/type
22    /// files whose exported interfaces are frequently consumed through
23    /// structural patterns (`z.infer<...>`, `as Request`, object-literal
24    /// flow) that the import graph cannot trace — reviewers should
25    /// cross-check before deleting.
26    pub confidence: String,
27}
28
29/// #268: file is a TypeScript module whose dead-code verdict warrants a
30/// confidence downgrade because exported types are commonly consumed via
31/// structural typing (Zod-inferred shapes, `as RequestType` casts,
32/// object-literal flow) that `import_graph` does not trace. The
33/// signal is intentionally name-shaped — file path contains one of the
34/// recognised request/schema/contract tokens. False positives here only
35/// soften the verdict; they do not flip a real orphan to "alive".
36pub(super) fn is_ts_structural_likely(file: &str) -> bool {
37    let path = Path::new(file);
38    let ext_ok = matches!(
39        path.extension().and_then(|e| e.to_str()),
40        Some("ts" | "tsx")
41    );
42    if !ext_ok {
43        return false;
44    }
45    const STRUCTURAL_NAME_TOKENS: &[&str] = &[
46        "request",
47        "schema",
48        "types",
49        "contract",
50        "interface",
51        "model",
52        "dto",
53    ];
54    let name_lc = path
55        .file_name()
56        .and_then(|n| n.to_str())
57        .unwrap_or("")
58        .to_ascii_lowercase();
59    let parent_lc = path
60        .parent()
61        .and_then(|p| p.file_name())
62        .and_then(|n| n.to_str())
63        .unwrap_or("")
64        .to_ascii_lowercase();
65    STRUCTURAL_NAME_TOKENS
66        .iter()
67        .any(|kw| name_lc.contains(kw) || parent_lc.contains(kw))
68}
69
70/// #268: tier label for entries the import graph can fully account for.
71pub(super) const CONFIDENCE_HIGH: &str = "high";
72
73/// #268: tier label for TS request/schema/type entries whose orphan
74/// verdict needs cross-check (Zod-inferred shapes, structural casts,
75/// route-handler body usage are invisible to `import_graph`).
76pub(super) const CONFIDENCE_STRUCTURAL: &str = "needs_structural_evidence";
77
78pub(super) fn confidence_tier_for_file(file: &str) -> &'static str {
79    if is_ts_structural_likely(file) {
80        CONFIDENCE_STRUCTURAL
81    } else {
82        CONFIDENCE_HIGH
83    }
84}
85
86/// Exception file names that should not be flagged as dead (entry points / init files).
87pub(super) fn is_entry_point_file(file: &str) -> bool {
88    let name = Path::new(file)
89        .file_name()
90        .and_then(|n| n.to_str())
91        .unwrap_or(file);
92    matches!(
93        name,
94        "__init__.py"
95            | "mod.rs"
96            | "lib.rs"
97            | "main.rs"
98            | "index.ts"
99            | "index.js"
100            | "index.tsx"
101            | "index.jsx"
102    )
103}
104
105/// Exception symbol names that should not be flagged as dead.
106pub(super) fn is_entry_point_symbol(name: &str) -> bool {
107    name == "main"
108        || name == "__init__"
109        || name == "setUp"
110        || name == "tearDown"
111        || name.starts_with("test_")
112        || name.starts_with("Test")
113}
114
115/// Check whether any line preceding a symbol definition starts with `@`
116/// (decorator pattern). Scans upward through stacked decorators.
117/// `lines` is the 0-indexed source lines; `symbol_line` is 1-indexed.
118pub(super) fn has_decorator(lines: &[&str], symbol_line: usize) -> bool {
119    if symbol_line < 2 {
120        return false;
121    }
122    // Scan upward from the line before the definition
123    let mut idx = symbol_line - 2; // convert to 0-indexed, then go one line back
124    loop {
125        match lines.get(idx) {
126            Some(line) if line.trim_start().starts_with('@') => return true,
127            Some(line) if line.trim().is_empty() => {} // skip blank lines between decorators
128            _ => return false,
129        }
130        if idx == 0 {
131            return false;
132        }
133        idx -= 1;
134    }
135}
136
137pub fn find_dead_code(
138    project: &ProjectRoot,
139    max_results: usize,
140    cache: &GraphCache,
141) -> Result<Vec<DeadCodeEntry>> {
142    let graph = cache.get_or_build(project)?;
143    let mut dead: Vec<_> = graph
144        .iter()
145        .filter(|(_, node)| node.imported_by.is_empty())
146        .map(|(file, _)| DeadCodeEntry {
147            file: file.clone(),
148            symbol: None,
149            reason: "no importers".to_owned(),
150        })
151        .collect();
152    dead.sort_by(|a, b| a.file.cmp(&b.file));
153    if max_results > 0 && dead.len() > max_results {
154        dead.truncate(max_results);
155    }
156    Ok(dead)
157}
158
159pub fn find_dead_code_v2(
160    project: &ProjectRoot,
161    max_results: usize,
162    cache: &GraphCache,
163) -> Result<Vec<DeadCodeEntryV2>> {
164    let mut results: Vec<DeadCodeEntryV2> = Vec::new();
165
166    // ── Pass 1: unreferenced files ────────────────────────────────────────────
167    let graph = cache.get_or_build(project)?;
168    for (file, node) in graph.iter() {
169        if node.imported_by.is_empty() && !is_entry_point_file(file) {
170            results.push(DeadCodeEntryV2 {
171                file: file.clone(),
172                symbol: None,
173                kind: None,
174                line: None,
175                reason: "no importers".to_owned(),
176                pass: 1,
177                confidence: confidence_tier_for_file(file).to_owned(),
178            });
179        }
180    }
181
182    // ── Pass 2: unreferenced symbols ─────────────────────────────────────────
183    let candidate_files = collect_candidate_files(project.as_path())?;
184    let mut all_callees: HashSet<String> = HashSet::new();
185    for path in &candidate_files {
186        for edge in extract_calls(path) {
187            all_callees.insert(edge.callee_name);
188        }
189    }
190
191    for path in &candidate_files {
192        let relative = project.to_relative(path);
193
194        if results.iter().any(|e| e.file == relative && e.pass == 1) {
195            continue;
196        }
197        if is_entry_point_file(&relative) {
198            continue;
199        }
200
201        let source = std::fs::read_to_string(path).unwrap_or_default();
202        let lines: Vec<&str> = source.lines().collect();
203
204        let edges = extract_calls(path);
205        let mut defined_funcs: HashMap<String, usize> = HashMap::new();
206        for edge in &edges {
207            defined_funcs.entry(edge.caller_name.clone()).or_insert(0);
208        }
209        collect_top_level_funcs(path, &source, &mut defined_funcs);
210
211        for (func_name, func_line) in defined_funcs {
212            if func_name == "<module>" {
213                continue;
214            }
215            if is_entry_point_symbol(&func_name) {
216                continue;
217            }
218            if func_line > 0 && has_decorator(&lines, func_line) {
219                continue;
220            }
221            if !all_callees.contains(&func_name) {
222                results.push(DeadCodeEntryV2 {
223                    file: relative.clone(),
224                    symbol: Some(func_name),
225                    kind: Some("function".to_owned()),
226                    line: if func_line > 0 { Some(func_line) } else { None },
227                    reason: "unreferenced symbol".to_owned(),
228                    pass: 2,
229                    confidence: confidence_tier_for_file(&relative).to_owned(),
230                });
231            }
232        }
233    }
234
235    results.sort_by(|a, b| {
236        a.pass
237            .cmp(&b.pass)
238            .then(a.file.cmp(&b.file))
239            .then(a.symbol.cmp(&b.symbol))
240    });
241    if max_results > 0 && results.len() > max_results {
242        results.truncate(max_results);
243    }
244    Ok(results)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn ts_request_files_downgrade_confidence() {
253        // #268: request/schema/types/contract files commonly export
254        // interfaces consumed via structural typing — must downgrade.
255        for path in [
256            "src/api/request.ts",
257            "src/api/RequestTypes.ts",
258            "src/server/schema.ts",
259            "src/contracts/UserContract.ts",
260            "src/types/index.ts",
261            "src/models/User.ts",
262            "src/dtos/CreateUser.ts",
263            "components/MyForm/types.tsx",
264            "lib/Interface.ts",
265        ] {
266            assert!(
267                is_ts_structural_likely(path),
268                "{path:?} should be downgraded"
269            );
270            assert_eq!(confidence_tier_for_file(path), CONFIDENCE_STRUCTURAL);
271        }
272    }
273
274    #[test]
275    fn non_ts_or_unrelated_files_keep_high_confidence() {
276        for path in [
277            "src/main.rs",
278            "src/utils.py",
279            "scripts/build.sh",
280            "src/app/page.tsx", // not a request/schema/type-shaped name
281            "src/hooks/useGifStudio.ts",
282            "Cargo.toml",
283            "package.json",
284        ] {
285            assert!(
286                !is_ts_structural_likely(path),
287                "{path:?} should keep high confidence"
288            );
289            assert_eq!(confidence_tier_for_file(path), CONFIDENCE_HIGH);
290        }
291    }
292
293    #[test]
294    fn javascript_request_file_is_not_downgraded() {
295        // The downgrade is TypeScript-specific: TS structural typing +
296        // Zod schemas are the false-positive class. Plain JS does not
297        // get the same benefit.
298        assert!(!is_ts_structural_likely("src/api/request.js"));
299        assert_eq!(
300            confidence_tier_for_file("src/api/request.js"),
301            CONFIDENCE_HIGH
302        );
303    }
304}