Skip to main content

aft/inspect/scanners/
dead_code.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::time::{Instant, UNIX_EPOCH};
5
6use rayon::prelude::*;
7use serde::Deserialize;
8use serde_json::json;
9
10use crate::cache_freshness::{self, FileFreshness};
11use crate::callgraph::{resolve_module_path, resolve_reexported_symbol_target};
12use crate::calls::extract_type_references;
13use crate::imports::{parse_imports, specifier_imported_name, specifier_local_name};
14use crate::inspect::job::{is_test_support_file, DISPATCHED_CALLEE_SEPARATOR};
15use crate::inspect::{
16    CallgraphOutboundCall, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
17    InspectResult, InspectScanSuccess,
18};
19use crate::parser::{detect_language, grammar_for, LangId};
20
21use super::DEFAULT_EXPORT_MARKER_KIND;
22
23const MAX_DRILL_DOWN_ITEMS: usize = 100;
24
25type ExportNode = (String, String);
26
27#[derive(Debug, Default)]
28struct ImportedExportLiveness {
29    root_exports: Vec<ImportedExportContribution>,
30    namespace_exports: Vec<ImportedExportContribution>,
31}
32
33pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
34    let started = Instant::now();
35
36    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
37        let success = InspectScanSuccess {
38            scanned_files: job.scope_files.clone(),
39            contributions: Vec::new(),
40            aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
41        };
42        return InspectResult::success(job, success, started.elapsed());
43    };
44
45    let liveness_root_files = snapshot
46        .entry_points
47        .iter()
48        .map(|file| relative_path(&job.project_root, file))
49        .collect::<BTreeSet<_>>();
50    let public_api_files = collect_public_api_files(&job.project_root);
51    let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
52        exported_symbol_indexes(job, snapshot);
53
54    let contributions = job
55        .scope_files
56        .par_iter()
57        .map(|file| {
58            gather_file_contribution(
59                job,
60                snapshot,
61                file,
62                &exported_symbols_by_file,
63                &files_by_exported_symbol,
64                &default_export_symbols_by_file,
65                &liveness_root_files,
66                &public_api_files,
67            )
68        })
69        .collect::<Vec<_>>();
70
71    let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
72    let aggregate = aggregate_dead_code_contributions(&contributions, &public_api_files, &roles);
73    let success = InspectScanSuccess {
74        scanned_files: job.scope_files.clone(),
75        contributions,
76        aggregate,
77    };
78
79    InspectResult::success(job, success, started.elapsed())
80}
81
82fn exported_symbol_indexes(
83    job: &InspectJob,
84    snapshot: &CallgraphSnapshot,
85) -> (
86    BTreeMap<String, BTreeSet<String>>,
87    BTreeMap<String, BTreeSet<String>>,
88    BTreeMap<String, String>,
89) {
90    let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
91    let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
92    let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
93
94    for export in &snapshot.exported_symbols {
95        let file = relative_path(&job.project_root, &export.file);
96        if export.kind == DEFAULT_EXPORT_MARKER_KIND {
97            default_export_symbols_by_file.insert(file, export.symbol.clone());
98            continue;
99        }
100
101        exported_symbols_by_file
102            .entry(file.clone())
103            .or_default()
104            .insert(export.symbol.clone());
105        files_by_exported_symbol
106            .entry(export.symbol.clone())
107            .or_default()
108            .insert(file);
109    }
110
111    (
112        exported_symbols_by_file,
113        files_by_exported_symbol,
114        default_export_symbols_by_file,
115    )
116}
117
118fn gather_file_contribution(
119    job: &InspectJob,
120    snapshot: &CallgraphSnapshot,
121    file: &Path,
122    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
123    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
124    default_export_symbols_by_file: &BTreeMap<String, String>,
125    liveness_root_files: &BTreeSet<String>,
126    public_api_files: &BTreeSet<String>,
127) -> FileContribution {
128    let file_name = relative_path(&job.project_root, file);
129    let is_liveness_root_file = liveness_root_files.contains(&file_name);
130    let is_public_api_file = public_api_files.contains(&file_name);
131    let mut exports = snapshot
132        .exported_symbols
133        .iter()
134        .filter(|export| same_file(&job.project_root, &export.file, file))
135        .filter(|export| export.kind != DEFAULT_EXPORT_MARKER_KIND)
136        .map(|export| ExportContribution {
137            symbol: export.symbol.clone(),
138            kind: export.kind.clone(),
139            line: export.line,
140            is_type_like: is_type_like_kind(&export.kind),
141            is_entry_point: false,
142        })
143        .collect::<Vec<_>>();
144
145    let mut internal_calls = snapshot
146        .outbound_calls
147        .iter()
148        .filter(|call| same_file(&job.project_root, &call.caller_file, file))
149        .filter_map(|call| {
150            project_internal_call(
151                &job.project_root,
152                call,
153                &file_name,
154                exported_symbols_by_file,
155                files_by_exported_symbol,
156            )
157        })
158        .collect::<Vec<_>>();
159    internal_calls.extend(reexport_liveness_edges(
160        &job.project_root,
161        file,
162        &file_name,
163        exported_symbols_by_file,
164        default_export_symbols_by_file,
165    ));
166    internal_calls.sort_by(|left, right| {
167        left.caller_symbol
168            .cmp(&right.caller_symbol)
169            .then_with(|| left.file.cmp(&right.file))
170            .then_with(|| left.symbol.cmp(&right.symbol))
171            .then_with(|| left.line.cmp(&right.line))
172    });
173    internal_calls.dedup_by(|left, right| {
174        left.caller_symbol == right.caller_symbol
175            && left.file == right.file
176            && left.symbol == right.symbol
177            && left.line == right.line
178    });
179
180    let dispatched_method_names = snapshot
181        .outbound_calls
182        .iter()
183        .filter(|call| same_file(&job.project_root, &call.caller_file, file))
184        .filter_map(dispatched_method_name_from_call)
185        .collect::<BTreeSet<_>>()
186        .into_iter()
187        .collect::<Vec<_>>();
188    let imported_export_liveness = imported_export_liveness_roots(
189        &job.project_root,
190        file,
191        exported_symbols_by_file,
192        default_export_symbols_by_file,
193    );
194    let type_ref_names = collect_type_ref_names(file);
195
196    let liveness_roots = liveness_roots_for_file(
197        &file_name,
198        &exports,
199        &internal_calls,
200        is_liveness_root_file,
201        is_public_api_file,
202    );
203    for export in &mut exports {
204        export.is_entry_point = liveness_roots.contains(&export.symbol);
205    }
206
207    let mut payload = json!({
208        "file": file_name,
209        "exports": exports
210            .iter()
211            .map(|export| {
212                let mut value = json!({
213                    "symbol": export.symbol,
214                    "kind": export.kind,
215                    "line": export.line,
216                    "is_entry_point": export.is_entry_point,
217                });
218                if export.is_type_like {
219                    value["is_type_like"] = json!(true);
220                }
221                value
222            })
223            .collect::<Vec<_>>(),
224        "internal_calls": internal_calls
225            .into_iter()
226            .map(|call| json!({
227                "caller_symbol": call.caller_symbol,
228                "file": call.file,
229                "symbol": call.symbol,
230                "line": call.line,
231            }))
232            .collect::<Vec<_>>(),
233        "liveness_roots": liveness_roots,
234    });
235    if !dispatched_method_names.is_empty() {
236        payload["dispatched_method_names"] = json!(dispatched_method_names);
237    }
238    if !imported_export_liveness.root_exports.is_empty() {
239        payload["imported_exports"] = json!(imported_export_liveness
240            .root_exports
241            .iter()
242            .map(|root| json!({
243                "file": root.file,
244                "symbol": root.symbol,
245            }))
246            .collect::<Vec<_>>());
247    }
248    if !imported_export_liveness.namespace_exports.is_empty() {
249        payload["namespace_imported_exports"] = json!(imported_export_liveness
250            .namespace_exports
251            .iter()
252            .map(|root| json!({
253                "file": root.file,
254                "symbol": root.symbol,
255            }))
256            .collect::<Vec<_>>());
257    }
258
259    FileContribution::new(
260        InspectCategory::DeadCode,
261        file.to_path_buf(),
262        collect_freshness(file),
263        payload,
264    )
265    .with_type_ref_names(type_ref_names)
266}
267
268pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
269    json!({
270        "count": 0,
271        "items": [],
272        "by_language": {},
273        "drill_down_capped": false,
274        "uncertain_count": 0,
275        "uncertain_items": [],
276        "callgraph_available": false,
277        "scanned_files": scanned_files,
278        "notes": ["callgraph_unavailable"],
279    })
280}
281
282pub(crate) fn aggregate_dead_code_contributions(
283    contributions: &[FileContribution],
284    public_api_files: &BTreeSet<String>,
285    roles: &crate::inspect::entry_points::ProjectRoles,
286) -> serde_json::Value {
287    aggregate_dead_code_contributions_with_limit(
288        contributions,
289        public_api_files,
290        roles,
291        Some(MAX_DRILL_DOWN_ITEMS),
292    )
293}
294
295pub(crate) fn aggregate_dead_code_contributions_with_limit(
296    contributions: &[FileContribution],
297    public_api_files: &BTreeSet<String>,
298    roles: &crate::inspect::entry_points::ProjectRoles,
299    drill_down_limit: Option<usize>,
300) -> serde_json::Value {
301    let parsed = contributions
302        .iter()
303        .filter_map(|contribution| {
304            serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
305        })
306        .collect::<Vec<_>>();
307
308    let edges_by_source = edges_by_source(&parsed);
309    let dispatched_method_names = collect_dispatched_method_names(&parsed);
310    let reachable = reachable_exports(&parsed, &edges_by_source, &dispatched_method_names);
311    let referenced_type_names = collect_referenced_type_names(&parsed);
312
313    let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
314    let mut count = 0usize;
315    let mut dead_items = Vec::new();
316    let uncertain_count = 0usize;
317    let uncertain_items: Vec<serde_json::Value> = Vec::new();
318    for contribution in &parsed {
319        // Test-support files (fixtures, corpora, mock data) are consumed by
320        // path, never imported, so their exports always look dead. Skip
321        // REPORTING them — their edges already kept real code live above.
322        if is_test_support_file(&contribution.file) {
323            continue;
324        }
325        let is_public_api_file = public_api_files.contains(&contribution.file);
326        for export in &contribution.exports {
327            let node = (contribution.file.clone(), export.symbol.clone());
328            if reachable.contains(&node)
329                || is_public_api_file
330                || dispatched_method_names.contains(symbol_liveness_name(&export.symbol))
331            {
332                continue;
333            }
334
335            if (export.is_type_like || is_type_like_kind(&export.kind))
336                && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
337            {
338                continue;
339            }
340
341            count += 1;
342            *by_language
343                .entry(language_for_file(&contribution.file).to_string())
344                .or_default() += 1;
345            // Collect ALL items here; rank by signal tier and truncate below so
346            // product findings survive the cap instead of being eaten by
347            // alphabetically-first benchmark/tooling files.
348            dead_items.push(json!({
349                "file": contribution.file,
350                "symbol": export.symbol,
351                "kind": export.kind,
352                "line": export.line,
353            }));
354        }
355    }
356
357    let dead_items =
358        crate::inspect::entry_points::rank_and_truncate_items(dead_items, roles, drill_down_limit);
359    let top = crate::inspect::entry_points::top_preview_symbols(&dead_items);
360
361    json!({
362        "count": count,
363        "items": dead_items,
364        "top": top,
365        "by_language": by_language,
366        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
367        "uncertain_count": uncertain_count,
368        "uncertain_items": uncertain_items,
369        "callgraph_available": true,
370        "scanned_files": contributions.len(),
371    })
372}
373
374fn edges_by_source(
375    contributions: &[DeadCodeContribution],
376) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
377    let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
378
379    for contribution in contributions {
380        for call in &contribution.internal_calls {
381            // Keep EVERY resolved edge, regardless of whether the target is an
382            // exported symbol. Liveness must traverse through private
383            // intermediaries (a private router/helper that forwards a root to a
384            // public handler). Restricting targets to exports severed the chain
385            // at the first private hop and made every handler reachable only via
386            // a private function look dead. Node identity is (file, symbol);
387            // private and exported symbols share the same node space.
388            if call.caller_symbol.is_empty() {
389                continue;
390            }
391            let target = (call.file.clone(), call.symbol.clone());
392            let source = (contribution.file.clone(), call.caller_symbol.clone());
393            edges.entry(source).or_default().insert(target);
394        }
395    }
396
397    edges
398}
399
400fn collect_dispatched_method_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
401    contributions
402        .iter()
403        .flat_map(|contribution| contribution.dispatched_method_names.iter().cloned())
404        .collect()
405}
406
407fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
408    // A type-like export is live if it is referenced in type position ANYWHERE
409    // in the project — not only from call-reachable files. Filtering by
410    // call-reachability (the original Phase 2 design) under-approximates
411    // liveness: the cross-file call graph is incomplete (constructor/method
412    // edges, workspace-package boundaries), so genuinely-used types referenced
413    // from files the call graph fails to mark reachable were flagged dead.
414    // This mirrors `collect_dispatched_method_names`, which is also unfiltered,
415    // and keeps dead_code biased toward under-reporting (it is a hint, not
416    // authority): a type with zero type-references anywhere is still precise
417    // dead.
418    contributions
419        .iter()
420        .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
421        .collect()
422}
423
424fn reachable_exports(
425    contributions: &[DeadCodeContribution],
426    edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
427    dispatched_method_names: &BTreeSet<String>,
428) -> BTreeSet<ExportNode> {
429    let imported_exports_by_file = imported_exports_by_file(contributions);
430    let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
431    let mut expanded_file_imports = BTreeSet::new();
432    let mut reachable = BTreeSet::new();
433    let mut queue = VecDeque::new();
434
435    for contribution in contributions {
436        for root in &contribution.liveness_roots {
437            queue.push_back((contribution.file.clone(), root.clone()));
438        }
439        for export in &contribution.exports {
440            if export.is_entry_point {
441                queue.push_back((contribution.file.clone(), export.symbol.clone()));
442            }
443        }
444    }
445
446    // Methods reached only via receiver dispatch (`obj.method()`) carry no
447    // resolvable call edge — the receiver type is unknown — so they never
448    // become reachable BFS nodes. They ARE rescued from the dead list by name
449    // (`dispatched_method_names`), but that rescue keeps only the method itself
450    // alive; it does NOT propagate liveness THROUGH the method body. Every free
451    // function the method calls is then orphaned and reported dead despite
452    // having real callers (e.g. `BgTaskRegistry::spawn` -> `task_paths`, which
453    // has 33 callers yet was flagged dead). Seed each dispatch-live method body
454    // as a BFS root, keyed by its scoped caller identity (`Type::method`, the
455    // form `edges_by_source` uses for sources) so liveness flows through to the
456    // method's callees. This widens the existing dead_code under-reporting bias
457    // by exactly one hop and never severs a live chain.
458    for source in edges_by_source.keys() {
459        if dispatched_method_names.contains(symbol_liveness_name(&source.1)) {
460            queue.push_back(source.clone());
461        }
462    }
463
464    while let Some(node) = queue.pop_front() {
465        if !reachable.insert(node.clone()) {
466            continue;
467        }
468        if expanded_file_imports.insert(node.0.clone()) {
469            // Static imports are file-level liveness edges: an imported export
470            // should keep the target live only when the importer file itself is
471            // reachable. This prevents dead consumers from making their imports
472            // look live while still covering references the call graph cannot
473            // see (type-only imports, JSX/value usage, barrel consumers, etc.).
474            if let Some(targets) = imported_exports_by_file.get(&node.0) {
475                for target in targets {
476                    if !reachable.contains(target) {
477                        queue.push_back(target.clone());
478                    }
479                }
480            }
481
482            // Namespace imports remain conservative file-level edges: once the
483            // importer file is reached, every export of the imported module is
484            // considered live because member access is not tracked here.
485            if let Some(targets) = namespace_imports_by_file.get(&node.0) {
486                for target in targets {
487                    if !reachable.contains(target) {
488                        queue.push_back(target.clone());
489                    }
490                }
491            }
492        }
493        if let Some(targets) = edges_by_source.get(&node) {
494            for target in targets {
495                if !reachable.contains(target) {
496                    queue.push_back(target.clone());
497                }
498            }
499        }
500    }
501
502    reachable
503}
504
505fn imported_exports_by_file(
506    contributions: &[DeadCodeContribution],
507) -> BTreeMap<String, BTreeSet<ExportNode>> {
508    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
509
510    for contribution in contributions {
511        if contribution.imported_exports.is_empty() {
512            continue;
513        }
514        by_file
515            .entry(contribution.file.clone())
516            .or_default()
517            .extend(
518                contribution
519                    .imported_exports
520                    .iter()
521                    .map(|root| (root.file.clone(), root.symbol.clone())),
522            );
523    }
524
525    by_file
526}
527
528fn namespace_imported_exports_by_file(
529    contributions: &[DeadCodeContribution],
530) -> BTreeMap<String, BTreeSet<ExportNode>> {
531    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
532
533    for contribution in contributions {
534        if contribution.namespace_imported_exports.is_empty() {
535            continue;
536        }
537        by_file
538            .entry(contribution.file.clone())
539            .or_default()
540            .extend(
541                contribution
542                    .namespace_imported_exports
543                    .iter()
544                    .map(|root| (root.file.clone(), root.symbol.clone())),
545            );
546    }
547
548    by_file
549}
550
551fn project_internal_call(
552    project_root: &Path,
553    call: &CallgraphOutboundCall,
554    caller_file: &str,
555    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
556    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
557) -> Option<InternalCall> {
558    let target = parse_target(project_root, &call.target);
559    let symbol = target.symbol?;
560    let file = match target.file {
561        // Qualified target (file::symbol). The snapshot builder already
562        // resolved and validated this edge — cross-file targets are confirmed
563        // exports of the target file, and same-file targets are confirmed
564        // definitions (private functions included, e.g. `main.rs::dispatch`).
565        // Keep the edge regardless of the target's export visibility: liveness
566        // must flow THROUGH private intermediaries, otherwise a public handler
567        // reached only via a private router/helper looks unreachable.
568        Some(file) => file,
569        None => resolve_unqualified_target(
570            caller_file,
571            &symbol,
572            exported_symbols_by_file,
573            files_by_exported_symbol,
574        )?,
575    };
576
577    Some(InternalCall {
578        caller_symbol: call.caller_symbol.clone(),
579        file,
580        symbol,
581        line: call.line,
582    })
583}
584
585fn reexport_liveness_edges(
586    project_root: &Path,
587    file: &Path,
588    file_name: &str,
589    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
590    default_export_symbols_by_file: &BTreeMap<String, String>,
591) -> Vec<InternalCall> {
592    let Some(lang) = detect_language(file) else {
593        return Vec::new();
594    };
595    let Ok(source) = fs::read_to_string(file) else {
596        return Vec::new();
597    };
598
599    match lang {
600        LangId::TypeScript | LangId::Tsx | LangId::JavaScript => ts_reexport_liveness_edges(
601            project_root,
602            file,
603            file_name,
604            &source,
605            lang,
606            exported_symbols_by_file,
607            default_export_symbols_by_file,
608        ),
609        LangId::Rust => rust_reexport_liveness_edges(
610            project_root,
611            file,
612            file_name,
613            &source,
614            exported_symbols_by_file,
615            default_export_symbols_by_file,
616        ),
617        _ => Vec::new(),
618    }
619}
620
621fn ts_reexport_liveness_edges(
622    project_root: &Path,
623    file: &Path,
624    file_name: &str,
625    source: &str,
626    lang: LangId,
627    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
628    default_export_symbols_by_file: &BTreeMap<String, String>,
629) -> Vec<InternalCall> {
630    let grammar = grammar_for(lang);
631    let mut parser = tree_sitter::Parser::new();
632    if parser.set_language(&grammar).is_err() {
633        return Vec::new();
634    }
635    let Some(tree) = parser.parse(source, None) else {
636        return Vec::new();
637    };
638
639    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
640    let mut edges = Vec::new();
641    let mut cursor = tree.root_node().walk();
642    if !cursor.goto_first_child() {
643        return edges;
644    }
645
646    loop {
647        let node = cursor.node();
648        if node.kind() == "export_statement" {
649            if let Some(module_path) = export_source_module(source, node) {
650                if let Some(module_entry) = resolve_import_module_path(from_dir, &module_path) {
651                    edges.extend(ts_reexport_edges_for_statement(
652                        project_root,
653                        file_name,
654                        source,
655                        node,
656                        &module_entry,
657                        exported_symbols_by_file,
658                        default_export_symbols_by_file,
659                    ));
660                }
661            }
662        }
663
664        if !cursor.goto_next_sibling() {
665            break;
666        }
667    }
668
669    edges
670}
671
672fn ts_reexport_edges_for_statement(
673    project_root: &Path,
674    file_name: &str,
675    source: &str,
676    node: tree_sitter::Node,
677    module_entry: &Path,
678    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
679    default_export_symbols_by_file: &BTreeMap<String, String>,
680) -> Vec<InternalCall> {
681    let mut edges = Vec::new();
682    let line = (node.start_position().row + 1) as u32;
683    let raw_export = node_text(source, node).trim();
684
685    for specifier in ts_reexport_specifiers(raw_export) {
686        if !file_exports_symbol(file_name, &specifier.exported, exported_symbols_by_file) {
687            continue;
688        }
689        if let Some((target_file, target_symbol)) = resolve_imported_export_liveness_root(
690            project_root,
691            module_entry,
692            &specifier.imported,
693            exported_symbols_by_file,
694            default_export_symbols_by_file,
695        ) {
696            edges.push(InternalCall {
697                caller_symbol: specifier.exported,
698                file: target_file,
699                symbol: target_symbol,
700                line,
701            });
702        }
703    }
704
705    if raw_export.contains('*') {
706        if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
707            if file_exports_symbol(file_name, &namespace_export, exported_symbols_by_file) {
708                edges.extend(reexport_edges_for_all_target_symbols(
709                    project_root,
710                    file_name,
711                    &namespace_export,
712                    module_entry,
713                    line,
714                    exported_symbols_by_file,
715                    default_export_symbols_by_file,
716                    false,
717                ));
718            }
719        } else {
720            edges.extend(reexport_edges_for_all_target_symbols(
721                project_root,
722                file_name,
723                "",
724                module_entry,
725                line,
726                exported_symbols_by_file,
727                default_export_symbols_by_file,
728                true,
729            ));
730        }
731    }
732
733    edges
734}
735
736fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
737    let Some(start) = raw_export.find('{').map(|index| index + 1) else {
738        return Vec::new();
739    };
740    let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
741        return Vec::new();
742    };
743
744    raw_export[start..end]
745        .split(',')
746        .filter_map(|specifier| {
747            let specifier = specifier.trim();
748            if specifier.is_empty() {
749                return None;
750            }
751            let imported = specifier_imported_name(specifier).trim();
752            let exported = specifier_local_name(specifier).trim();
753            if imported.is_empty() || exported.is_empty() {
754                return None;
755            }
756            Some(ReexportSpecifier {
757                imported: imported.to_string(),
758                exported: exported.to_string(),
759            })
760        })
761        .collect()
762}
763
764fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
765    let after_star = raw_export.split_once('*')?.1.trim_start();
766    let after_as = after_star.strip_prefix("as")?.trim_start();
767    let name = after_as
768        .split_whitespace()
769        .next()?
770        .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
771    (!name.is_empty()).then(|| name.to_string())
772}
773
774fn rust_reexport_liveness_edges(
775    project_root: &Path,
776    file: &Path,
777    file_name: &str,
778    source: &str,
779    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
780    default_export_symbols_by_file: &BTreeMap<String, String>,
781) -> Vec<InternalCall> {
782    let module_files = rust_module_files(file, source);
783    let mut edges = Vec::new();
784
785    for (statement, line) in rust_pub_use_statements(source) {
786        for specifier in rust_reexport_specifiers(&statement) {
787            let Some(module_entry) = rust_module_entry(&module_files, &specifier.module_path)
788            else {
789                continue;
790            };
791
792            if specifier.imported == "*" {
793                edges.extend(reexport_edges_for_all_target_symbols(
794                    project_root,
795                    file_name,
796                    "",
797                    &module_entry,
798                    line,
799                    exported_symbols_by_file,
800                    default_export_symbols_by_file,
801                    true,
802                ));
803                continue;
804            }
805
806            if !file_exports_symbol(file_name, &specifier.exported, exported_symbols_by_file) {
807                continue;
808            }
809            if let Some((target_file, target_symbol)) = resolve_imported_export_liveness_root(
810                project_root,
811                &module_entry,
812                &specifier.imported,
813                exported_symbols_by_file,
814                default_export_symbols_by_file,
815            ) {
816                edges.push(InternalCall {
817                    caller_symbol: specifier.exported,
818                    file: target_file,
819                    symbol: target_symbol,
820                    line,
821                });
822            }
823        }
824    }
825
826    edges
827}
828
829fn reexport_edges_for_all_target_symbols(
830    project_root: &Path,
831    file_name: &str,
832    namespace_export: &str,
833    module_entry: &Path,
834    line: u32,
835    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
836    default_export_symbols_by_file: &BTreeMap<String, String>,
837    match_current_export_names: bool,
838) -> Vec<InternalCall> {
839    let Some((_, target_symbols)) =
840        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
841    else {
842        return Vec::new();
843    };
844
845    let mut edges = Vec::new();
846    for target_symbol in target_symbols {
847        let caller_symbol = if match_current_export_names {
848            if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
849                continue;
850            }
851            target_symbol.clone()
852        } else {
853            namespace_export.to_string()
854        };
855
856        if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
857            project_root,
858            module_entry,
859            target_symbol,
860            exported_symbols_by_file,
861            default_export_symbols_by_file,
862        ) {
863            edges.push(InternalCall {
864                caller_symbol,
865                file: target_file,
866                symbol: resolved_symbol,
867                line,
868            });
869        }
870    }
871
872    edges
873}
874
875fn rust_module_files(file: &Path, source: &str) -> BTreeMap<String, PathBuf> {
876    let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
877    let mut modules = BTreeMap::new();
878    for line in source.lines() {
879        let trimmed = line.trim();
880        let after_visibility = trimmed
881            .strip_prefix("pub ")
882            .or_else(|| trimmed.strip_prefix("pub(crate) "))
883            .or_else(|| trimmed.strip_prefix("pub(super) "))
884            .unwrap_or(trimmed)
885            .trim_start();
886        let Some(after_mod) = after_visibility.strip_prefix("mod ") else {
887            continue;
888        };
889        let module = after_mod
890            .trim_end_matches(';')
891            .split_whitespace()
892            .next()
893            .unwrap_or_default()
894            .trim();
895        if module.is_empty() || module.contains('{') {
896            continue;
897        }
898        if let Some(path) = resolve_rust_module_file(base_dir, module) {
899            modules.insert(module.to_string(), path);
900        }
901    }
902    modules
903}
904
905fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
906    let flat = base_dir.join(format!("{module}.rs"));
907    if flat.is_file() {
908        return Some(flat);
909    }
910    let nested = base_dir.join(module).join("mod.rs");
911    nested.is_file().then_some(nested)
912}
913
914fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
915    let mut statements = Vec::new();
916    let mut current = String::new();
917    let mut start_line = 0u32;
918
919    for (index, line) in source.lines().enumerate() {
920        let trimmed = line.trim();
921        if current.is_empty() {
922            if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
923                continue;
924            }
925            start_line = (index + 1) as u32;
926        }
927
928        current.push(' ');
929        current.push_str(trimmed);
930        if trimmed.ends_with(';') {
931            statements.push((current.trim().to_string(), start_line));
932            current.clear();
933        }
934    }
935
936    statements
937}
938
939fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
940    let statement = statement
941        .trim()
942        .trim_end_matches(';')
943        .strip_prefix("pub(crate) use ")
944        .or_else(|| {
945            statement
946                .trim()
947                .trim_end_matches(';')
948                .strip_prefix("pub use ")
949        })
950        .unwrap_or("")
951        .trim();
952    if statement.is_empty() {
953        return Vec::new();
954    }
955
956    if let Some((module_path, grouped)) = statement.split_once("::{") {
957        let grouped = grouped.trim_end_matches('}');
958        return grouped
959            .split(',')
960            .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
961            .collect();
962    }
963
964    let Some((module_path, imported)) = statement.rsplit_once("::") else {
965        return Vec::new();
966    };
967    rust_reexport_specifier(module_path.trim(), imported.trim())
968        .into_iter()
969        .collect()
970}
971
972fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
973    if specifier.is_empty() {
974        return None;
975    }
976    let (imported, exported) = specifier
977        .split_once(" as ")
978        .map(|(imported, exported)| (imported.trim(), exported.trim()))
979        .unwrap_or((specifier.trim(), specifier.trim()));
980    if imported.is_empty() || exported.is_empty() {
981        return None;
982    }
983    Some(RustReexportSpecifier {
984        module_path: rust_normalize_module_path(module_path),
985        imported: imported.to_string(),
986        exported: exported.to_string(),
987    })
988}
989
990fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
991    module_path
992        .split("::")
993        .filter_map(|segment| {
994            let segment = segment.trim();
995            if segment.is_empty() || matches!(segment, "self" | "crate") {
996                None
997            } else {
998                Some(segment.to_string())
999            }
1000        })
1001        .collect()
1002}
1003
1004fn rust_module_entry(
1005    module_files: &BTreeMap<String, PathBuf>,
1006    module_path: &[String],
1007) -> Option<PathBuf> {
1008    let first = module_path.first()?;
1009    module_files.get(first).cloned()
1010}
1011
1012fn file_exports_symbol(
1013    file_name: &str,
1014    symbol: &str,
1015    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1016) -> bool {
1017    exported_symbols_by_file
1018        .get(file_name)
1019        .is_some_and(|symbols| symbols.contains(symbol))
1020}
1021
1022fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
1023    node.child_by_field_name("source")
1024        .or_else(|| find_child_by_kind(node, "string"))
1025        .and_then(|source_node| string_literal_content(source, source_node))
1026}
1027
1028fn find_child_by_kind<'tree>(
1029    node: tree_sitter::Node<'tree>,
1030    kind: &str,
1031) -> Option<tree_sitter::Node<'tree>> {
1032    let mut cursor = node.walk();
1033    if !cursor.goto_first_child() {
1034        return None;
1035    }
1036    loop {
1037        let child = cursor.node();
1038        if child.kind() == kind {
1039            return Some(child);
1040        }
1041        if let Some(descendant) = find_child_by_kind(child, kind) {
1042            return Some(descendant);
1043        }
1044        if !cursor.goto_next_sibling() {
1045            break;
1046        }
1047    }
1048    None
1049}
1050
1051fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
1052    let raw = node_text(source, node).trim();
1053    let quote = raw.chars().next()?;
1054    if quote != '\'' && quote != '"' {
1055        return None;
1056    }
1057    raw.strip_prefix(quote)
1058        .and_then(|value| value.strip_suffix(quote))
1059        .map(ToOwned::to_owned)
1060}
1061
1062fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
1063    &source[node.byte_range()]
1064}
1065
1066fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1067    if is_relative_module_path(module_path) {
1068        return resolve_js_ts_module_path(from_dir, module_path);
1069    }
1070    resolve_workspace_package_import(from_dir, module_path)
1071}
1072
1073fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1074    resolve_module_path(from_dir, module_path)
1075        .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
1076}
1077
1078fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1079    if !is_relative_module_path(module_path) {
1080        return None;
1081    }
1082    let base = from_dir.join(module_path);
1083    let ext = base.extension().and_then(|extension| extension.to_str())?;
1084    let candidates: &[&str] = match ext {
1085        "js" => &["ts", "tsx"],
1086        "jsx" => &["tsx", "ts"],
1087        "mjs" => &["mts", "ts"],
1088        "cjs" => &["cts", "ts"],
1089        _ => return None,
1090    };
1091
1092    candidates
1093        .iter()
1094        .map(|extension| base.with_extension(extension))
1095        .find(|candidate| candidate.is_file())
1096}
1097
1098fn is_relative_module_path(module_path: &str) -> bool {
1099    module_path.starts_with("./")
1100        || module_path.starts_with("../")
1101        || module_path == "."
1102        || module_path == ".."
1103}
1104
1105#[derive(Debug)]
1106struct ReexportSpecifier {
1107    imported: String,
1108    exported: String,
1109}
1110
1111#[derive(Debug)]
1112struct RustReexportSpecifier {
1113    module_path: Vec<String>,
1114    imported: String,
1115    exported: String,
1116}
1117
1118fn imported_export_liveness_roots(
1119    project_root: &Path,
1120    file: &Path,
1121    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1122    default_export_symbols_by_file: &BTreeMap<String, String>,
1123) -> ImportedExportLiveness {
1124    let Some(lang) = detect_language(file)
1125        .filter(|lang| matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript))
1126    else {
1127        return ImportedExportLiveness::default();
1128    };
1129    let Ok(source) = fs::read_to_string(file) else {
1130        return ImportedExportLiveness::default();
1131    };
1132    let grammar = grammar_for(lang);
1133    let mut parser = tree_sitter::Parser::new();
1134    if parser.set_language(&grammar).is_err() {
1135        return ImportedExportLiveness::default();
1136    }
1137    let Some(tree) = parser.parse(&source, None) else {
1138        return ImportedExportLiveness::default();
1139    };
1140
1141    let import_block = parse_imports(&source, &tree, lang);
1142    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1143    let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
1144    let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
1145
1146    for import in &import_block.imports {
1147        if import.namespace_import.is_some() {
1148            if let Some(module_entry) = resolve_import_module_path(from_dir, &import.module_path) {
1149                namespace_exports.extend(resolve_namespace_import_liveness_roots(
1150                    project_root,
1151                    &module_entry,
1152                    exported_symbols_by_file,
1153                    default_export_symbols_by_file,
1154                ));
1155            }
1156        }
1157
1158        let Some(module_entry) = resolve_import_module_path(from_dir, &import.module_path) else {
1159            continue;
1160        };
1161
1162        for imported_name in import
1163            .names
1164            .iter()
1165            .map(|name| specifier_imported_name(name))
1166        {
1167            if let Some(root) = resolve_imported_export_liveness_root(
1168                project_root,
1169                &module_entry,
1170                imported_name,
1171                exported_symbols_by_file,
1172                default_export_symbols_by_file,
1173            ) {
1174                root_exports.insert(root);
1175            }
1176        }
1177
1178        if import.default_import.is_some() {
1179            if let Some(root) = resolve_imported_export_liveness_root(
1180                project_root,
1181                &module_entry,
1182                "default",
1183                exported_symbols_by_file,
1184                default_export_symbols_by_file,
1185            ) {
1186                root_exports.insert(root);
1187            }
1188        }
1189    }
1190
1191    ImportedExportLiveness {
1192        root_exports: root_exports
1193            .into_iter()
1194            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1195            .collect(),
1196        namespace_exports: namespace_exports
1197            .into_iter()
1198            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1199            .collect(),
1200    }
1201}
1202
1203fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1204    let package_name = package_name_from_import(module_path)?;
1205    let module_entry = resolve_module_path(from_dir, module_path)?;
1206    let resolved_package_name = package_name_for_file(&module_entry)?;
1207    (resolved_package_name == package_name).then_some(module_entry)
1208}
1209
1210fn package_name_from_import(module_path: &str) -> Option<String> {
1211    if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
1212    {
1213        return None;
1214    }
1215
1216    let mut parts = module_path.split('/');
1217    let first = parts.next()?;
1218    if first.is_empty() {
1219        return None;
1220    }
1221
1222    if first.starts_with('@') {
1223        let second = parts.next()?;
1224        (!second.is_empty()).then(|| format!("{first}/{second}"))
1225    } else {
1226        Some(first.to_string())
1227    }
1228}
1229
1230fn package_name_for_file(file: &Path) -> Option<String> {
1231    let mut current = file.parent();
1232    while let Some(dir) = current {
1233        let manifest = dir.join("package.json");
1234        if manifest.is_file() {
1235            if let Ok(source) = fs::read_to_string(&manifest) {
1236                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
1237                    if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
1238                        return Some(name.to_string());
1239                    }
1240                }
1241            }
1242        }
1243        current = dir.parent();
1244    }
1245    None
1246}
1247
1248fn resolve_namespace_import_liveness_roots(
1249    project_root: &Path,
1250    module_entry: &Path,
1251    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1252    default_export_symbols_by_file: &BTreeMap<String, String>,
1253) -> Vec<ExportNode> {
1254    let Some((_, symbols)) =
1255        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1256    else {
1257        return Vec::new();
1258    };
1259    let mut roots = BTreeSet::new();
1260
1261    for symbol in symbols {
1262        if let Some(root) = resolve_imported_export_liveness_root(
1263            project_root,
1264            module_entry,
1265            symbol,
1266            exported_symbols_by_file,
1267            default_export_symbols_by_file,
1268        ) {
1269            roots.insert(root);
1270        }
1271    }
1272
1273    if default_export_symbol_for_resolved_file(
1274        project_root,
1275        module_entry,
1276        default_export_symbols_by_file,
1277    )
1278    .is_some()
1279    {
1280        if let Some(root) = resolve_imported_export_liveness_root(
1281            project_root,
1282            module_entry,
1283            "default",
1284            exported_symbols_by_file,
1285            default_export_symbols_by_file,
1286        ) {
1287            roots.insert(root);
1288        }
1289    }
1290
1291    roots.into_iter().collect()
1292}
1293
1294fn resolve_imported_export_liveness_root(
1295    project_root: &Path,
1296    module_entry: &Path,
1297    imported_symbol: &str,
1298    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1299    default_export_symbols_by_file: &BTreeMap<String, String>,
1300) -> Option<ExportNode> {
1301    let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
1302        exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1303            .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
1304    };
1305    let mut file_default_export_symbol = |path: &Path| {
1306        default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
1307            .or_else(|| {
1308                exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1309                    .and_then(|(_, symbols)| {
1310                        symbols.contains("default").then(|| "default".to_string())
1311                    })
1312            })
1313    };
1314
1315    let (target_file, symbol) = resolve_reexported_symbol_target(
1316        module_entry,
1317        imported_symbol,
1318        &mut file_exports_symbol,
1319        &mut file_default_export_symbol,
1320    )?;
1321
1322    let (file, symbols) =
1323        exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
1324    symbols.contains(&symbol).then_some((file, symbol))
1325}
1326
1327fn exported_symbols_for_resolved_file<'a>(
1328    project_root: &Path,
1329    file: &Path,
1330    exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
1331) -> Option<(String, &'a BTreeSet<String>)> {
1332    let relative = relative_path(project_root, file);
1333    if let Some(symbols) = exported_symbols_by_file.get(&relative) {
1334        return Some((relative, symbols));
1335    }
1336
1337    let canonical_root = fs::canonicalize(project_root).ok()?;
1338    let canonical_file = fs::canonicalize(file).ok()?;
1339    let relative = relative_path(&canonical_root, &canonical_file);
1340    exported_symbols_by_file
1341        .get(&relative)
1342        .map(|symbols| (relative, symbols))
1343}
1344
1345fn default_export_symbol_for_resolved_file(
1346    project_root: &Path,
1347    file: &Path,
1348    default_export_symbols_by_file: &BTreeMap<String, String>,
1349) -> Option<String> {
1350    let relative = relative_path(project_root, file);
1351    if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
1352        return Some(symbol.clone());
1353    }
1354
1355    let canonical_root = fs::canonicalize(project_root).ok()?;
1356    let canonical_file = fs::canonicalize(file).ok()?;
1357    let relative = relative_path(&canonical_root, &canonical_file);
1358    default_export_symbols_by_file.get(&relative).cloned()
1359}
1360
1361fn resolve_unqualified_target(
1362    caller_file: &str,
1363    symbol: &str,
1364    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1365    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1366) -> Option<String> {
1367    if exported_symbols_by_file
1368        .get(caller_file)
1369        .is_some_and(|symbols| symbols.contains(symbol))
1370    {
1371        return Some(caller_file.to_string());
1372    }
1373
1374    let files = files_by_exported_symbol.get(symbol)?;
1375    if files.len() == 1 {
1376        files.iter().next().cloned()
1377    } else {
1378        None
1379    }
1380}
1381
1382fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
1383    let (target, full_callee) = split_call_target_metadata(&call.target);
1384    if let Some(full_callee) = full_callee {
1385        return dispatched_method_name_from_callee(full_callee);
1386    }
1387    if target.contains("::") || target.contains('#') {
1388        return None;
1389    }
1390    dispatched_method_name_from_callee(target)
1391}
1392
1393fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
1394    let callee = callee.trim();
1395    if !callee.contains('.') {
1396        return None;
1397    }
1398
1399    clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
1400}
1401
1402fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
1403    target
1404        .split_once(DISPATCHED_CALLEE_SEPARATOR)
1405        .map_or((target, None), |(target, full_callee)| {
1406            (target, Some(full_callee))
1407        })
1408}
1409
1410fn symbol_liveness_name(symbol: &str) -> &str {
1411    symbol
1412        .rsplit(['.', ':', '#'])
1413        .find(|segment| !segment.is_empty())
1414        .unwrap_or(symbol)
1415}
1416
1417fn is_type_like_kind(kind: &str) -> bool {
1418    matches!(
1419        kind,
1420        "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
1421    )
1422}
1423
1424fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
1425    let (target, _) = split_call_target_metadata(target);
1426    let trimmed = target.trim();
1427    if trimmed.is_empty() {
1428        return ParsedTarget {
1429            file: None,
1430            symbol: None,
1431        };
1432    }
1433
1434    if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
1435        return ParsedTarget {
1436            file: Some(relative_path(project_root, Path::new(file))),
1437            symbol: clean_symbol(symbol),
1438        };
1439    }
1440
1441    if let Some((file, symbol)) = trimmed.rsplit_once('#') {
1442        return ParsedTarget {
1443            file: Some(relative_path(project_root, Path::new(file))),
1444            symbol: clean_symbol(symbol),
1445        };
1446    }
1447
1448    ParsedTarget {
1449        file: None,
1450        symbol: clean_symbol(trimmed),
1451    }
1452}
1453
1454fn split_file_symbol_target<'a>(
1455    project_root: &Path,
1456    target: &'a str,
1457    separator: &str,
1458) -> Option<(&'a str, &'a str)> {
1459    let mut search_start = 0;
1460    while let Some(offset) = target[search_start..].find(separator) {
1461        let split_at = search_start + offset;
1462        let file = &target[..split_at];
1463        let symbol = &target[split_at + separator.len()..];
1464        if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
1465            return Some((file, symbol));
1466        }
1467        search_start = split_at + separator.len();
1468    }
1469    None
1470}
1471
1472fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
1473    let path = Path::new(file);
1474    language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
1475}
1476
1477fn clean_symbol(symbol: &str) -> Option<String> {
1478    let trimmed = symbol.trim();
1479    if trimmed.is_empty() {
1480        None
1481    } else {
1482        Some(trimmed.to_string())
1483    }
1484}
1485
1486fn liveness_roots_for_file(
1487    file_name: &str,
1488    exports: &[ExportContribution],
1489    internal_calls: &[InternalCall],
1490    is_liveness_root_file: bool,
1491    is_public_api_file: bool,
1492) -> Vec<String> {
1493    if !is_liveness_root_file && !is_public_api_file {
1494        return Vec::new();
1495    }
1496
1497    let mut roots = BTreeSet::new();
1498    roots.insert("<top-level>".to_string());
1499    if is_public_api_file {
1500        roots.extend(exports.iter().map(|export| export.symbol.clone()));
1501    } else {
1502        roots.extend(
1503            exports
1504                .iter()
1505                .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
1506                .map(|export| export.symbol.clone()),
1507        );
1508        roots.extend(
1509            internal_calls
1510                .iter()
1511                .map(|call| call.caller_symbol.as_str())
1512                .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
1513                .map(str::to_string),
1514        );
1515    }
1516
1517    roots.into_iter().collect()
1518}
1519
1520fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
1521    let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
1522    if symbol == "<top-level>" {
1523        return true;
1524    }
1525
1526    let lower = symbol.to_ascii_lowercase();
1527    if matches!(
1528        lower.as_str(),
1529        "main" | "init" | "setup" | "bootstrap" | "run"
1530    ) {
1531        return true;
1532    }
1533
1534    Path::new(file_name)
1535        .file_stem()
1536        .and_then(|stem| stem.to_str())
1537        .is_some_and(|stem| stem == symbol)
1538}
1539
1540pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
1541    crate::inspect::entry_points::resolve_entry_points(project_root)
1542        .public_api_files_relative(project_root)
1543}
1544
1545fn language_for_file(file: &str) -> &'static str {
1546    let extension = Path::new(file)
1547        .extension()
1548        .and_then(|extension| extension.to_str())
1549        .map(|extension| extension.to_ascii_lowercase())
1550        .unwrap_or_default();
1551
1552    match extension.as_str() {
1553        "rs" => "rust",
1554        "ts" | "tsx" | "mts" | "cts" => "typescript",
1555        "js" | "jsx" | "mjs" | "cjs" => "javascript",
1556        "py" => "python",
1557        "go" => "go",
1558        "c" | "h" => "c",
1559        "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
1560        "zig" => "zig",
1561        "cs" => "csharp",
1562        "sh" | "bash" | "zsh" | "fish" => "bash",
1563        "html" | "htm" => "html",
1564        "md" | "markdown" => "markdown",
1565        "sol" => "solidity",
1566        "vue" => "vue",
1567        "json" => "json",
1568        "scala" => "scala",
1569        "java" => "java",
1570        "rb" => "ruby",
1571        "kt" | "kts" => "kotlin",
1572        "swift" => "swift",
1573        "php" => "php",
1574        "lua" => "lua",
1575        "pl" | "pm" => "perl",
1576        _ => "unknown",
1577    }
1578}
1579
1580fn collect_type_ref_names(file: &Path) -> BTreeSet<String> {
1581    let Some(lang) = detect_language(file).filter(|lang| supports_type_refs(*lang)) else {
1582        return BTreeSet::new();
1583    };
1584    let Ok(source) = fs::read_to_string(file) else {
1585        return BTreeSet::new();
1586    };
1587    let grammar = grammar_for(lang);
1588    let mut parser = tree_sitter::Parser::new();
1589    if parser.set_language(&grammar).is_err() {
1590        return BTreeSet::new();
1591    }
1592    let Some(tree) = parser.parse(&source, None) else {
1593        return BTreeSet::new();
1594    };
1595
1596    extract_type_references(&source, tree.root_node(), lang)
1597}
1598
1599fn supports_type_refs(lang: LangId) -> bool {
1600    matches!(
1601        lang,
1602        LangId::TypeScript
1603            | LangId::Tsx
1604            | LangId::JavaScript
1605            | LangId::Python
1606            | LangId::Rust
1607            | LangId::Go
1608    )
1609}
1610
1611fn collect_freshness(file: &Path) -> FileFreshness {
1612    cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
1613        mtime: UNIX_EPOCH,
1614        size: 0,
1615        content_hash: cache_freshness::zero_hash(),
1616    })
1617}
1618
1619fn same_file(project_root: &Path, left: &Path, right: &Path) -> bool {
1620    normalize_absolute(project_root, left) == normalize_absolute(project_root, right)
1621}
1622
1623fn relative_path(project_root: &Path, path: &Path) -> String {
1624    let absolute = if path.is_absolute() {
1625        path.to_path_buf()
1626    } else {
1627        project_root.join(path)
1628    };
1629    let normalized = normalize_path(&absolute);
1630    normalized
1631        .strip_prefix(&normalize_path(project_root))
1632        .unwrap_or(normalized.as_path())
1633        .to_string_lossy()
1634        .replace('\\', "/")
1635}
1636
1637fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
1638    let absolute = if path.is_absolute() {
1639        path.to_path_buf()
1640    } else {
1641        project_root.join(path)
1642    };
1643    normalize_path(&absolute)
1644}
1645
1646fn normalize_path(path: &Path) -> PathBuf {
1647    let mut normalized = PathBuf::new();
1648    for component in path.components() {
1649        match component {
1650            Component::CurDir => {}
1651            Component::ParentDir => {
1652                if !normalized.pop() {
1653                    normalized.push(component.as_os_str());
1654                }
1655            }
1656            _ => normalized.push(component.as_os_str()),
1657        }
1658    }
1659    normalized
1660}
1661
1662#[derive(Debug, Clone, Deserialize)]
1663struct DeadCodeContribution {
1664    file: String,
1665    exports: Vec<ExportContribution>,
1666    internal_calls: Vec<InternalCallContribution>,
1667    #[serde(default)]
1668    liveness_roots: Vec<String>,
1669    #[serde(default)]
1670    imported_exports: Vec<ImportedExportContribution>,
1671    #[serde(default)]
1672    namespace_imported_exports: Vec<ImportedExportContribution>,
1673    #[serde(default)]
1674    dispatched_method_names: Vec<String>,
1675    #[serde(default)]
1676    type_ref_names: Vec<String>,
1677}
1678
1679#[derive(Debug, Clone, Deserialize)]
1680struct ImportedExportContribution {
1681    file: String,
1682    symbol: String,
1683}
1684
1685#[derive(Debug, Clone, Deserialize)]
1686struct ExportContribution {
1687    symbol: String,
1688    kind: String,
1689    line: u32,
1690    #[serde(default)]
1691    is_type_like: bool,
1692    #[serde(default)]
1693    is_entry_point: bool,
1694}
1695
1696#[derive(Debug, Clone, Deserialize)]
1697struct InternalCallContribution {
1698    #[serde(default)]
1699    caller_symbol: String,
1700    file: String,
1701    symbol: String,
1702}
1703
1704#[derive(Debug, Clone)]
1705struct InternalCall {
1706    caller_symbol: String,
1707    file: String,
1708    symbol: String,
1709    line: u32,
1710}
1711
1712#[derive(Debug, Clone)]
1713struct ParsedTarget {
1714    file: Option<String>,
1715    symbol: Option<String>,
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use super::*;
1721    use std::fs;
1722    use std::path::{Path, PathBuf};
1723    use std::sync::{Arc, RwLock};
1724
1725    use crate::config::Config;
1726    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
1727    use crate::inspect::{CallgraphExport, JobKey};
1728    use crate::parser::SymbolCache;
1729
1730    fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
1731        let temp_dir = tempfile::tempdir().expect("tempdir");
1732        let root = temp_dir.path().join("project");
1733        fs::create_dir_all(&root).expect("create project root");
1734
1735        let paths = files
1736            .iter()
1737            .map(|(relative, contents)| {
1738                let path = root.join(relative);
1739                if let Some(parent) = path.parent() {
1740                    fs::create_dir_all(parent).expect("create parent");
1741                }
1742                fs::write(&path, contents).expect("write fixture file");
1743                path
1744            })
1745            .collect::<Vec<_>>();
1746
1747        (temp_dir, root, paths)
1748    }
1749
1750    fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
1751        InspectJob {
1752            job_id: 1,
1753            key: JobKey::for_project_category(InspectCategory::DeadCode),
1754            category: InspectCategory::DeadCode,
1755            scope_files,
1756            project_root: root.to_path_buf(),
1757            inspect_dir: root.join(".aft-cache").join("inspect"),
1758            config: Arc::new(Config {
1759                project_root: Some(root.to_path_buf()),
1760                ..Config::default()
1761            }),
1762            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
1763            callgraph_snapshot: Some(Arc::new(snapshot)),
1764        }
1765    }
1766
1767    fn snapshot(
1768        files: Vec<PathBuf>,
1769        exported_symbols: Vec<CallgraphExport>,
1770        outbound_calls: Vec<CallgraphOutboundCall>,
1771    ) -> CallgraphSnapshot {
1772        snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
1773    }
1774
1775    fn snapshot_with_entry_points(
1776        files: Vec<PathBuf>,
1777        exported_symbols: Vec<CallgraphExport>,
1778        outbound_calls: Vec<CallgraphOutboundCall>,
1779        entry_points: BTreeSet<PathBuf>,
1780    ) -> CallgraphSnapshot {
1781        CallgraphSnapshot {
1782            generated_at: None,
1783            files,
1784            exported_symbols,
1785            outbound_calls,
1786            entry_points,
1787        }
1788    }
1789
1790    fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
1791        CallgraphExport {
1792            file: root.join(file),
1793            symbol: symbol.to_string(),
1794            kind: kind.to_string(),
1795            line: 1,
1796        }
1797    }
1798
1799    fn outbound(
1800        root: &Path,
1801        caller_file: &str,
1802        caller_symbol: &str,
1803        target: &str,
1804    ) -> CallgraphOutboundCall {
1805        CallgraphOutboundCall {
1806            caller_file: root.join(caller_file),
1807            caller_symbol: caller_symbol.to_string(),
1808            target: target.to_string(),
1809            line: 1,
1810        }
1811    }
1812
1813    fn dispatched_target(target: &str, full_callee: &str) -> String {
1814        format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
1815    }
1816
1817    fn scan(job: InspectJob) -> serde_json::Value {
1818        run_dead_code_scan(&job)
1819            .outcome
1820            .expect("scan succeeds")
1821            .aggregate
1822    }
1823
1824    #[test]
1825    fn method_dispatched_by_receiver_call_is_live() {
1826        let (_temp_dir, root, paths) = fixture_project(&[
1827            ("src/service.ts", "export class Service { render() {} }\n"),
1828            (
1829                "src/consumer.ts",
1830                "function run(service: Service) { service.render(); }\n",
1831            ),
1832        ]);
1833        let aggregate = scan(job(
1834            &root,
1835            paths.clone(),
1836            snapshot(
1837                paths,
1838                vec![export(&root, "src/service.ts", "render", "method")],
1839                vec![outbound(
1840                    &root,
1841                    "src/consumer.ts",
1842                    "run",
1843                    &dispatched_target("render", "service.render"),
1844                )],
1845            ),
1846        ));
1847
1848        assert_eq!(aggregate["count"], 0);
1849        assert_eq!(aggregate["uncertain_count"], 0);
1850        assert!(aggregate["items"].as_array().unwrap().is_empty());
1851    }
1852
1853    #[test]
1854    fn method_without_any_dispatch_is_still_dead() {
1855        let (_temp_dir, root, paths) =
1856            fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
1857        let aggregate = scan(job(
1858            &root,
1859            paths.clone(),
1860            snapshot(
1861                paths,
1862                vec![export(&root, "src/service.ts", "render", "method")],
1863                Vec::new(),
1864            ),
1865        ));
1866
1867        assert_eq!(aggregate["count"], 1);
1868        assert_eq!(aggregate["items"][0]["symbol"], "render");
1869        assert_eq!(aggregate["uncertain_count"], 0);
1870    }
1871
1872    #[test]
1873    fn free_function_called_from_dispatch_live_method_body_is_live() {
1874        // Regression for the dead_code reachability bug: a free function reached
1875        // only through a method whose only caller is a receiver dispatch
1876        // (`obj.method()`) must NOT be reported dead. The method ("render") is
1877        // rescued from the dead list by dispatch-name, but liveness must also
1878        // flow THROUGH its body to the free function it calls ("helper").
1879        // Mirrors the real `BgTaskRegistry::spawn` -> `task_paths` case, where
1880        // `task_paths` had 33 callers yet was flagged dead because the BFS never
1881        // entered the dispatch-only method body. Method bodies are keyed by
1882        // scoped identity (`Service::render`) while exports are bare (`render`),
1883        // so the body edge is unreachable without seeding the scoped method node.
1884        let (_temp_dir, root, paths) = fixture_project(&[
1885            (
1886                "src/service.ts",
1887                "export class Service { render() { helper(); } }\n",
1888            ),
1889            ("src/helper.ts", "export function helper() {}\n"),
1890            (
1891                "src/consumer.ts",
1892                "function run(service: Service) { service.render(); }\n",
1893            ),
1894        ]);
1895        let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
1896        let aggregate = scan(job(
1897            &root,
1898            paths.clone(),
1899            snapshot(
1900                paths,
1901                vec![
1902                    export(&root, "src/service.ts", "render", "method"),
1903                    export(&root, "src/helper.ts", "helper", "function"),
1904                ],
1905                vec![
1906                    // The method's ONLY caller is a receiver dispatch — no
1907                    // resolvable edge into `Service::render`.
1908                    outbound(
1909                        &root,
1910                        "src/consumer.ts",
1911                        "run",
1912                        &dispatched_target("render", "service.render"),
1913                    ),
1914                    // The dispatch-only method body calls a free function. The
1915                    // caller identity is scoped (`Service::render`), the form the
1916                    // edge map uses for sources.
1917                    outbound(&root, "src/service.ts", "Service::render", &helper_target),
1918                ],
1919            ),
1920        ));
1921
1922        assert_eq!(
1923            aggregate["count"], 0,
1924            "free function reached via dispatch-live method body must be live: {aggregate:#}"
1925        );
1926        assert!(aggregate["items"].as_array().unwrap().is_empty());
1927    }
1928
1929    #[test]
1930    fn rust_struct_referenced_only_in_types_is_live() {
1931        let (_temp_dir, root, paths) = fixture_project(&[
1932            ("src/types.rs", "pub struct Widget { id: u64 }\n"),
1933            (
1934                "src/main.rs",
1935                "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
1936            ),
1937        ]);
1938        let aggregate = scan(job(
1939            &root,
1940            paths.clone(),
1941            snapshot_with_entry_points(
1942                paths,
1943                vec![
1944                    export(&root, "src/types.rs", "Widget", "struct"),
1945                    export(&root, "src/main.rs", "main", "function"),
1946                ],
1947                Vec::new(),
1948                BTreeSet::from([root.join("src/main.rs")]),
1949            ),
1950        ));
1951
1952        assert_eq!(aggregate["count"], 0);
1953        assert_eq!(aggregate["uncertain_count"], 0);
1954        assert!(aggregate["items"].as_array().unwrap().is_empty());
1955    }
1956
1957    #[test]
1958    fn ts_interface_referenced_only_in_type_annotation_is_live() {
1959        let (_temp_dir, root, paths) = fixture_project(&[
1960            ("src/types.ts", "export interface Widget { id: string }\n"),
1961            (
1962                "src/main.ts",
1963                "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
1964            ),
1965        ]);
1966        let aggregate = scan(job(
1967            &root,
1968            paths.clone(),
1969            snapshot_with_entry_points(
1970                paths,
1971                vec![
1972                    export(&root, "src/types.ts", "Widget", "interface"),
1973                    export(&root, "src/main.ts", "run", "function"),
1974                ],
1975                Vec::new(),
1976                BTreeSet::from([root.join("src/main.ts")]),
1977            ),
1978        ));
1979
1980        assert_eq!(aggregate["count"], 0);
1981        assert_eq!(aggregate["uncertain_count"], 0);
1982        assert!(aggregate["items"].as_array().unwrap().is_empty());
1983    }
1984
1985    #[test]
1986    fn type_like_export_without_call_or_type_ref_is_precise_dead() {
1987        let (_temp_dir, root, paths) =
1988            fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
1989        let aggregate = scan(job(
1990            &root,
1991            paths.clone(),
1992            snapshot(
1993                paths,
1994                vec![export(&root, "src/types.ts", "Widget", "interface")],
1995                Vec::new(),
1996            ),
1997        ));
1998
1999        assert_eq!(aggregate["count"], 1);
2000        assert_eq!(aggregate["items"][0]["symbol"], "Widget");
2001        assert_eq!(aggregate["uncertain_count"], 0);
2002        assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
2003    }
2004
2005    #[test]
2006    fn genuinely_unreachable_function_is_still_dead() {
2007        let (_temp_dir, root, paths) =
2008            fixture_project(&[("src/build.ts", "export function build() {}\n")]);
2009        let aggregate = scan(job(
2010            &root,
2011            paths.clone(),
2012            snapshot(
2013                paths,
2014                vec![export(&root, "src/build.ts", "build", "function")],
2015                Vec::new(),
2016            ),
2017        ));
2018
2019        assert_eq!(aggregate["count"], 1);
2020        assert_eq!(aggregate["items"][0]["symbol"], "build");
2021        assert_eq!(aggregate["uncertain_count"], 0);
2022    }
2023}