Skip to main content

aft/inspect/scanners/
dead_code.rs

1use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, VecDeque};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::time::{Instant, UNIX_EPOCH};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
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::{
15    canonicalize_normalized, is_test_file, is_test_support_file, CALLGRAPH_PROVENANCE_REEXPORT,
16    DISPATCHED_CALLEE_SEPARATOR,
17};
18use crate::inspect::oxc_engine::{
19    analyze_file_facts, AnalyzeOptions, DynamicImportFact, ExportFact, FileFacts, FileId,
20    ImportFact, LivenessVerdict, OxcEngineResult, OxcFileVerdicts, OxcReExportContext,
21    ReExportFact, ReExportKind, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
22};
23use crate::inspect::{
24    CallgraphOutboundCall, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
25    InspectResult, InspectScanSuccess,
26};
27use crate::parser::{detect_language, grammar_for, LangId};
28
29use super::DEFAULT_EXPORT_MARKER_KIND;
30
31const MAX_DRILL_DOWN_ITEMS: usize = 100;
32pub(crate) const DEAD_CODE_FACTS_FORMAT_VERSION: u32 = 2;
33
34type ExportNode = (String, String);
35type OutboundCallsByCallerFile<'a> = BTreeMap<PathBuf, Vec<&'a CallgraphOutboundCall>>;
36
37#[derive(Debug, Default)]
38struct ImportedExportLiveness {
39    root_exports: Vec<ImportedExportContribution>,
40    namespace_exports: Vec<ImportedExportContribution>,
41}
42
43#[derive(Debug, Default)]
44struct FileAnalysis {
45    raw_imports: Vec<RawImportContribution>,
46    raw_reexports: Vec<RawReexportContribution>,
47    attribute_entry_points: Vec<String>,
48    type_ref_names: BTreeSet<String>,
49}
50
51#[derive(Default)]
52struct DeadCodeFileAnalyzer {
53    parsers: HashMap<LangId, tree_sitter::Parser>,
54}
55
56#[derive(Debug, Serialize)]
57struct OxcDeadCodeFactsPayload<'a> {
58    format_version: u32,
59    content_hash: &'a str,
60    exports: &'a [ExportFact],
61    imports: &'a [ImportFact],
62    re_exports: &'a [ReExportFact],
63    dynamic_imports: &'a [DynamicImportFact],
64    same_file_value_references: &'a BTreeSet<String>,
65    used_import_bindings: &'a BTreeSet<String>,
66    type_referenced_import_bindings: &'a BTreeSet<String>,
67    value_referenced_import_bindings: &'a BTreeSet<String>,
68    parse_error: &'a Option<String>,
69}
70
71impl DeadCodeFileAnalyzer {
72    fn analyze_file(&mut self, file: &Path, has_oxc_file: bool) -> FileAnalysis {
73        let Some(lang) = detect_language(file) else {
74            return FileAnalysis::default();
75        };
76        let needs_type_refs = supports_type_refs(lang);
77        let is_ts_js = matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript);
78        // Oxc FileFacts are the raw TS/JS import/re-export/dynamic-import facts.
79        // Only the legacy non-oxc TS/JS path needs tree-sitter import/re-export facts here.
80        let needs_ts_raw_facts = is_ts_js && !has_oxc_file;
81        let needs_rust_reexports = matches!(lang, LangId::Rust);
82        let needs_rust_attribute_entry_points = matches!(lang, LangId::Rust);
83
84        if !needs_type_refs
85            && !needs_ts_raw_facts
86            && !needs_rust_reexports
87            && !needs_rust_attribute_entry_points
88        {
89            return FileAnalysis::default();
90        }
91
92        let Ok(source) = fs::read_to_string(file) else {
93            return FileAnalysis::default();
94        };
95        let needs_tree = needs_type_refs || needs_ts_raw_facts || needs_rust_attribute_entry_points;
96        let tree = needs_tree
97            .then(|| self.parse_source(lang, &source))
98            .flatten();
99
100        let type_ref_names = if needs_type_refs {
101            tree.as_ref()
102                .map(|tree| extract_type_references(&source, tree.root_node(), lang))
103                .unwrap_or_default()
104        } else {
105            BTreeSet::new()
106        };
107
108        let raw_imports = if needs_ts_raw_facts {
109            tree.as_ref()
110                .map(|tree| raw_imports_from_tree(&source, tree, lang))
111                .unwrap_or_default()
112        } else {
113            Vec::new()
114        };
115
116        let raw_reexports = if needs_ts_raw_facts {
117            tree.as_ref()
118                .map(|tree| ts_raw_reexport_contributions(&source, tree.root_node()))
119                .unwrap_or_default()
120        } else if needs_rust_reexports {
121            rust_raw_reexport_contributions(&source)
122        } else {
123            Vec::new()
124        };
125
126        let attribute_entry_points = if needs_rust_attribute_entry_points {
127            tree.as_ref()
128                .map(|tree| {
129                    let mut roots = BTreeSet::new();
130                    for entry in
131                        crate::parser::rust_attribute_entry_points(&source, tree.root_node())
132                    {
133                        roots.insert(entry.name);
134                        roots.insert(entry.scoped_name);
135                    }
136                    roots.into_iter().collect()
137                })
138                .unwrap_or_default()
139        } else {
140            Vec::new()
141        };
142
143        FileAnalysis {
144            raw_imports,
145            raw_reexports,
146            attribute_entry_points,
147            type_ref_names,
148        }
149    }
150
151    fn parse_source(&mut self, lang: LangId, source: &str) -> Option<tree_sitter::Tree> {
152        let parser = match self.parsers.entry(lang) {
153            Entry::Occupied(entry) => entry.into_mut(),
154            Entry::Vacant(entry) => {
155                let grammar = grammar_for(lang);
156                let mut parser = tree_sitter::Parser::new();
157                if parser.set_language(&grammar).is_err() {
158                    return None;
159                }
160                entry.insert(parser)
161            }
162        };
163
164        parser.parse(source, None)
165    }
166}
167
168pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
169    run_dead_code_scan_with_oxc_started(job, None, Instant::now())
170}
171
172pub(crate) fn run_dead_code_scan_with_oxc(
173    job: &InspectJob,
174    oxc_result: Option<&OxcEngineResult>,
175) -> InspectResult {
176    run_dead_code_scan_with_oxc_started(job, oxc_result, Instant::now())
177}
178
179fn run_dead_code_scan_with_oxc_started(
180    job: &InspectJob,
181    oxc_result: Option<&OxcEngineResult>,
182    started: Instant,
183) -> InspectResult {
184    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
185        let success = InspectScanSuccess {
186            scanned_files: job.scope_files.clone(),
187            contributions: Vec::new(),
188            aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
189        };
190        return InspectResult::success(job, success, started.elapsed());
191    };
192
193    let fallback_exports_by_file = fallback_export_contributions_by_file(job, snapshot);
194    let oxc_facts_by_file = oxc_result
195        .map(|result| {
196            result
197                .facts
198                .iter()
199                .cloned()
200                .map(|facts| (relative_path(&job.project_root, &facts.path), facts))
201                .collect::<BTreeMap<_, _>>()
202        })
203        .unwrap_or_default();
204    let oxc_parse_errors_by_file = oxc_result
205        .map(|result| {
206            result.errors.iter().fold(
207                BTreeMap::<String, Vec<String>>::new(),
208                |mut errors, error| {
209                    errors
210                        .entry(relative_path(&job.project_root, &error.file))
211                        .or_default()
212                        .push(error.message.clone());
213                    errors
214                },
215            )
216        })
217        .unwrap_or_default();
218    let oxc_skipped_files = oxc_result
219        .map(|result| oxc_skipped_files_payload(&job.project_root, result))
220        .unwrap_or_default();
221
222    let contributions = job
223        .scope_files
224        .par_iter()
225        .map_init(DeadCodeFileAnalyzer::default, |file_analyzer, file| {
226            gather_file_contribution(
227                job,
228                file,
229                &fallback_exports_by_file,
230                &oxc_facts_by_file,
231                &oxc_parse_errors_by_file,
232                &oxc_skipped_files,
233                file_analyzer,
234            )
235        })
236        .collect::<Vec<_>>();
237
238    let public_api_files = collect_public_api_files(&job.project_root);
239    let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
240    let aggregate = aggregate_dead_code_contributions_with_snapshot(
241        &job.project_root,
242        snapshot,
243        &contributions,
244        &public_api_files,
245        &roles,
246        Some(MAX_DRILL_DOWN_ITEMS),
247    );
248    let success = InspectScanSuccess {
249        scanned_files: job.scope_files.clone(),
250        contributions,
251        aggregate,
252    };
253
254    InspectResult::success(job, success, started.elapsed())
255}
256
257fn fallback_export_contributions_by_file(
258    job: &InspectJob,
259    snapshot: &CallgraphSnapshot,
260) -> BTreeMap<String, Vec<ExportContribution>> {
261    let mut by_file: BTreeMap<String, Vec<ExportContribution>> = BTreeMap::new();
262    for export in &snapshot.exported_symbols {
263        if export.kind == DEFAULT_EXPORT_MARKER_KIND {
264            continue;
265        }
266        by_file
267            .entry(relative_path(&job.project_root, &export.file))
268            .or_default()
269            .push(ExportContribution {
270                symbol: export.symbol.clone(),
271                kind: export.kind.clone(),
272                line: export.line,
273                is_type_like: is_type_like_kind(&export.kind),
274                is_entry_point: false,
275                has_references: false,
276                test_only_reference_files: Vec::new(),
277                verdict: None,
278                reason: None,
279                provenance: None,
280                also_reexported: Vec::new(),
281            });
282    }
283    by_file
284}
285
286fn group_outbound_calls_by_caller_file<'a>(
287    project_root: &Path,
288    outbound_calls: &'a [CallgraphOutboundCall],
289) -> OutboundCallsByCallerFile<'a> {
290    let mut by_file: OutboundCallsByCallerFile<'a> = BTreeMap::new();
291    for call in outbound_calls {
292        by_file
293            .entry(normalize_absolute(project_root, &call.caller_file))
294            .or_default()
295            .push(call);
296    }
297    by_file
298}
299
300fn gather_file_contribution(
301    job: &InspectJob,
302    file: &Path,
303    fallback_exports_by_file: &BTreeMap<String, Vec<ExportContribution>>,
304    oxc_facts_by_file: &BTreeMap<String, FileFacts>,
305    oxc_parse_errors_by_file: &BTreeMap<String, Vec<String>>,
306    oxc_skipped_files: &[Value],
307    file_analyzer: &mut DeadCodeFileAnalyzer,
308) -> FileContribution {
309    let file_name = relative_path(&job.project_root, file);
310    let oxc_facts = oxc_facts_by_file.get(&file_name);
311    let exports = oxc_facts
312        .map(oxc_fact_export_contributions)
313        .unwrap_or_else(|| {
314            fallback_exports_by_file
315                .get(&file_name)
316                .cloned()
317                .unwrap_or_default()
318        });
319    let FileAnalysis {
320        raw_imports,
321        raw_reexports,
322        attribute_entry_points,
323        type_ref_names,
324    } = file_analyzer.analyze_file(file, oxc_facts.is_some());
325
326    let mut payload = json!({
327        "file": file_name,
328        "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
329        "exports": exports
330            .iter()
331            .map(|export| {
332                let mut value = json!({
333                    "symbol": export.symbol,
334                    "kind": export.kind,
335                    "line": export.line,
336                });
337                if export.is_type_like {
338                    value["is_type_like"] = json!(true);
339                }
340                value
341            })
342            .collect::<Vec<_>>(),
343    });
344
345    if !raw_imports.is_empty() {
346        payload["raw_imports"] = json!(raw_imports);
347    }
348    if !raw_reexports.is_empty() {
349        payload["raw_reexports"] = json!(raw_reexports);
350    }
351    if !attribute_entry_points.is_empty() {
352        payload["attribute_entry_points"] = json!(attribute_entry_points);
353    }
354    if let Some(facts) = oxc_facts {
355        payload["provenance"] = json!(OXC_PROVENANCE);
356        payload["oxc_facts"] = json!(OxcDeadCodeFactsPayload {
357            format_version: FACTS_FORMAT_VERSION,
358            content_hash: &facts.content_hash,
359            exports: &facts.exports,
360            imports: &facts.imports,
361            re_exports: &facts.re_exports,
362            dynamic_imports: &facts.dynamic_imports,
363            same_file_value_references: &facts.same_file_value_references,
364            used_import_bindings: &facts.used_import_bindings,
365            type_referenced_import_bindings: &facts.type_referenced_import_bindings,
366            value_referenced_import_bindings: &facts.value_referenced_import_bindings,
367            parse_error: &facts.parse_error,
368        });
369    }
370    if let Some(parse_errors) = oxc_parse_errors_by_file.get(&file_name) {
371        payload["parse_errors"] = json!(parse_errors
372            .iter()
373            .map(|message| json!({
374                "file": file_name,
375                "message": message,
376            }))
377            .collect::<Vec<_>>());
378    }
379    if oxc_facts.is_some() && !oxc_skipped_files.is_empty() {
380        payload["skipped_files"] = Value::Array(oxc_skipped_files.to_vec());
381    }
382
383    FileContribution::new(
384        InspectCategory::DeadCode,
385        file.to_path_buf(),
386        collect_freshness(file),
387        payload,
388    )
389    .with_type_ref_names(type_ref_names)
390}
391
392fn oxc_fact_export_contributions(facts: &FileFacts) -> Vec<ExportContribution> {
393    facts
394        .exports
395        .iter()
396        .map(|export| ExportContribution {
397            symbol: export.name.as_symbol(),
398            kind: export.kind.clone(),
399            line: export.line,
400            is_type_like: export.is_type_only || is_type_like_kind(&export.kind),
401            is_entry_point: false,
402            has_references: false,
403            test_only_reference_files: Vec::new(),
404            verdict: None,
405            reason: None,
406            provenance: None,
407            also_reexported: Vec::new(),
408        })
409        .collect()
410}
411
412fn oxc_export_contributions(file: &OxcFileVerdicts) -> Vec<ExportContribution> {
413    file.exports
414        .iter()
415        .map(|export| ExportContribution {
416            symbol: export.symbol.clone(),
417            kind: export.kind.clone(),
418            line: export.line,
419            is_type_like: is_type_like_kind(&export.kind),
420            is_entry_point: matches!(export.verdict, LivenessVerdict::Used),
421            has_references: export.has_references,
422            test_only_reference_files: export.test_only_reference_files.clone(),
423            verdict: Some(export.verdict),
424            reason: Some(export.reason.clone()),
425            provenance: Some(export.provenance.clone()),
426            also_reexported: export.also_reexported.clone(),
427        })
428        .collect()
429}
430
431fn oxc_skipped_files_payload(project_root: &Path, oxc_result: &OxcEngineResult) -> Vec<Value> {
432    oxc_result
433        .skipped_outside_root
434        .iter()
435        .map(|path| {
436            json!({
437                "file": relative_path(project_root, path),
438                "reason": "outside_project_root",
439            })
440        })
441        .collect()
442}
443
444pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
445    json!({
446        "count": 0,
447        "items": [],
448        "by_language": {},
449        "drill_down_capped": false,
450        "uncertain_count": 0,
451        "uncertain_items": [],
452        "callgraph_available": false,
453        "scanned_files": scanned_files,
454        "notes": ["callgraph_unavailable"],
455    })
456}
457
458pub(crate) fn aggregate_dead_code_contributions_with_snapshot(
459    project_root: &Path,
460    snapshot: &CallgraphSnapshot,
461    contributions: &[FileContribution],
462    public_api_files: &BTreeSet<String>,
463    roles: &crate::inspect::entry_points::ProjectRoles,
464    drill_down_limit: Option<usize>,
465) -> serde_json::Value {
466    let parsed = parse_dead_code_contributions(contributions);
467    let materialized =
468        materialize_dead_code_contributions(project_root, snapshot, parsed, public_api_files);
469    aggregate_materialized_dead_code_contributions(
470        &materialized,
471        public_api_files,
472        roles,
473        drill_down_limit,
474        contributions.len(),
475    )
476}
477
478fn parse_dead_code_contributions(contributions: &[FileContribution]) -> Vec<DeadCodeContribution> {
479    contributions
480        .iter()
481        .filter_map(|contribution| {
482            serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
483        })
484        .collect::<Vec<_>>()
485}
486
487fn materialize_dead_code_contributions(
488    project_root: &Path,
489    snapshot: &CallgraphSnapshot,
490    parsed: Vec<DeadCodeContribution>,
491    public_api_files: &BTreeSet<String>,
492) -> Vec<DeadCodeContribution> {
493    let liveness_root_files = snapshot
494        .entry_points
495        .iter()
496        .map(|file| relative_path(project_root, file))
497        .collect::<BTreeSet<_>>();
498    let executable_root_exports_by_file =
499        crate::inspect::entry_points::resolve_entry_points(project_root)
500            .executable_root_exports()
501            .into_iter()
502            .map(|(file, exports)| (relative_path(project_root, &file), exports))
503            .collect::<BTreeMap<_, _>>();
504    let attribute_roots_from_snapshot = snapshot
505        .entry_point_symbols
506        .iter()
507        .map(|(file, symbols)| (relative_path(project_root, file), symbols.clone()))
508        .collect::<BTreeMap<_, _>>();
509    let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
510        exported_symbol_indexes_from_contributions(project_root, snapshot, &parsed);
511    let outbound_calls_by_caller_file =
512        group_outbound_calls_by_caller_file(project_root, &snapshot.outbound_calls);
513    let oxc_by_file = oxc_verdicts_by_file(project_root, snapshot, &parsed, public_api_files);
514
515    parsed
516        .into_iter()
517        .map(|mut contribution| {
518            let _facts_format_version = contribution.facts_format_version;
519            let absolute_file = project_root.join(&contribution.file);
520            let normalized_file = normalize_absolute(project_root, &absolute_file);
521            let outbound_calls_for_file = outbound_calls_by_caller_file
522                .get(&normalized_file)
523                .map(Vec::as_slice)
524                .unwrap_or(&[]);
525            let mut exports = oxc_by_file
526                .get(&contribution.file)
527                .map(oxc_export_contributions)
528                .unwrap_or_else(|| contribution.exports.clone());
529
530            let mut internal_calls = outbound_calls_for_file
531                .iter()
532                .copied()
533                .filter_map(|call| {
534                    project_internal_call(
535                        project_root,
536                        call,
537                        &contribution.file,
538                        &exported_symbols_by_file,
539                        &files_by_exported_symbol,
540                    )
541                })
542                .collect::<Vec<_>>();
543            internal_calls.extend(resolve_raw_reexport_liveness_edges(
544                project_root,
545                &contribution.file,
546                &contribution.raw_reexports,
547                &exported_symbols_by_file,
548                &default_export_symbols_by_file,
549            ));
550            if let Some(oxc_facts) = &contribution.oxc_facts {
551                internal_calls.extend(resolve_oxc_reexport_liveness_edges(
552                    project_root,
553                    &contribution.file,
554                    oxc_facts,
555                    &exported_symbols_by_file,
556                    &default_export_symbols_by_file,
557                ));
558            }
559            sort_dedup_internal_calls(&mut internal_calls);
560
561            let dispatched_method_names = outbound_calls_for_file
562                .iter()
563                .copied()
564                .filter_map(dispatched_method_name_from_call)
565                .collect::<BTreeSet<_>>()
566                .into_iter()
567                .collect::<Vec<_>>();
568            let imported_export_liveness = resolve_raw_imported_export_liveness_roots(
569                project_root,
570                &contribution.file,
571                &contribution.raw_imports,
572                &exported_symbols_by_file,
573                &default_export_symbols_by_file,
574            );
575            let mut attribute_entry_points = contribution
576                .attribute_entry_points
577                .iter()
578                .cloned()
579                .collect::<BTreeSet<_>>();
580            if let Some(snapshot_roots) = attribute_roots_from_snapshot.get(&contribution.file) {
581                attribute_entry_points.extend(snapshot_roots.iter().cloned());
582            }
583            let liveness_roots = liveness_roots_for_file(
584                &contribution.file,
585                &exports,
586                &internal_calls,
587                &attribute_entry_points,
588                executable_root_exports_by_file.get(&contribution.file),
589                liveness_root_files.contains(&contribution.file),
590                public_api_files.contains(&contribution.file),
591            );
592            for export in &mut exports {
593                export.is_entry_point = liveness_roots.contains(&export.symbol);
594            }
595
596            contribution.exports = exports;
597            contribution.internal_calls = internal_calls
598                .into_iter()
599                .map(InternalCallContribution::from)
600                .collect();
601            contribution.liveness_roots = liveness_roots;
602            contribution.imported_exports = imported_export_liveness.root_exports;
603            contribution.namespace_imported_exports = imported_export_liveness.namespace_exports;
604            contribution.dispatched_method_names = dispatched_method_names;
605            contribution
606        })
607        .collect()
608}
609
610fn exported_symbol_indexes_from_contributions(
611    project_root: &Path,
612    snapshot: &CallgraphSnapshot,
613    contributions: &[DeadCodeContribution],
614) -> (
615    BTreeMap<String, BTreeSet<String>>,
616    BTreeMap<String, BTreeSet<String>>,
617    BTreeMap<String, String>,
618) {
619    let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
620    let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
621    let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
622
623    for contribution in contributions {
624        for export in &contribution.exports {
625            exported_symbols_by_file
626                .entry(contribution.file.clone())
627                .or_default()
628                .insert(export.symbol.clone());
629            files_by_exported_symbol
630                .entry(export.symbol.clone())
631                .or_default()
632                .insert(contribution.file.clone());
633        }
634    }
635
636    for export in &snapshot.exported_symbols {
637        let file = relative_path(project_root, &export.file);
638        if export.kind == DEFAULT_EXPORT_MARKER_KIND {
639            default_export_symbols_by_file.insert(file, export.symbol.clone());
640        }
641    }
642
643    (
644        exported_symbols_by_file,
645        files_by_exported_symbol,
646        default_export_symbols_by_file,
647    )
648}
649
650fn oxc_verdicts_by_file(
651    project_root: &Path,
652    snapshot: &CallgraphSnapshot,
653    contributions: &[DeadCodeContribution],
654    public_api_files: &BTreeSet<String>,
655) -> BTreeMap<String, OxcFileVerdicts> {
656    let facts = contributions
657        .iter()
658        .filter_map(|contribution| {
659            let oxc_facts = contribution.oxc_facts.as_ref()?;
660            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
661                return None;
662            }
663            Some(FileFacts {
664                file_id: FileId(0),
665                path: canonical_or_normalized(project_root, &project_root.join(&contribution.file)),
666                content_hash: oxc_facts.content_hash.clone(),
667                exports: oxc_facts.exports.clone(),
668                imports: oxc_facts.imports.clone(),
669                re_exports: oxc_facts.re_exports.clone(),
670                dynamic_imports: oxc_facts.dynamic_imports.clone(),
671                same_file_value_references: oxc_facts.same_file_value_references.clone(),
672                used_import_bindings: oxc_facts.used_import_bindings.clone(),
673                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
674                value_referenced_import_bindings: oxc_facts
675                    .value_referenced_import_bindings
676                    .clone(),
677                parse_error: oxc_facts.parse_error.clone(),
678            })
679        })
680        .collect::<Vec<_>>();
681    if facts.is_empty() {
682        return BTreeMap::new();
683    }
684
685    let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
686    analyze_file_facts(
687        project_root,
688        facts,
689        AnalyzeOptions {
690            entry_points: snapshot.entry_points.iter().cloned().collect(),
691            public_api_files: public_api_files
692                .iter()
693                .map(|file| project_root.join(file))
694                .collect(),
695            executable_root_exports: entry_points.executable_root_exports(),
696            force_reparse_files: Vec::new(),
697            entry_reachability: true,
698        },
699        Vec::new(),
700    )
701    .files
702    .into_iter()
703    .map(|file| (file.relative_file.clone(), file))
704    .collect()
705}
706
707fn sort_dedup_internal_calls(internal_calls: &mut Vec<InternalCall>) {
708    internal_calls.sort_by(|left, right| {
709        left.caller_symbol
710            .cmp(&right.caller_symbol)
711            .then_with(|| left.file.cmp(&right.file))
712            .then_with(|| left.symbol.cmp(&right.symbol))
713            .then_with(|| left.line.cmp(&right.line))
714            .then_with(|| left.provenance.cmp(&right.provenance))
715    });
716    internal_calls.dedup_by(|left, right| {
717        left.caller_symbol == right.caller_symbol
718            && left.file == right.file
719            && left.symbol == right.symbol
720            && left.line == right.line
721            && left.provenance == right.provenance
722    });
723}
724
725fn aggregate_materialized_dead_code_contributions(
726    parsed: &[DeadCodeContribution],
727    public_api_files: &BTreeSet<String>,
728    roles: &crate::inspect::entry_points::ProjectRoles,
729    drill_down_limit: Option<usize>,
730    scanned_files: usize,
731) -> serde_json::Value {
732    let edges_by_source = edges_by_source(parsed);
733    let dispatched_method_names = collect_dispatched_method_names(parsed);
734    let reachable = reachable_exports(parsed, &edges_by_source, &dispatched_method_names);
735    let referenced_type_names = collect_referenced_type_names(parsed);
736
737    let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
738    let mut count = 0usize;
739    let mut dead_items = Vec::new();
740    let mut test_only_count = 0usize;
741    let mut test_only_items = Vec::new();
742    let mut uncertain_count = 0usize;
743    let mut uncertain_items: Vec<serde_json::Value> = Vec::new();
744    for contribution in parsed {
745        // Test-support files (fixtures, corpora, mock data) are consumed by
746        // path, never imported, so their exports always look dead. Skip
747        // REPORTING them — their edges already kept real code live above.
748        if is_test_support_file(&contribution.file) {
749            continue;
750        }
751        let is_public_api_file = public_api_files.contains(&contribution.file);
752        for export in &contribution.exports {
753            if export_uses_oxc(export) {
754                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
755                    LivenessVerdict::Used => {
756                        if !is_test_file(&contribution.file)
757                            && !export.test_only_reference_files.is_empty()
758                        {
759                            test_only_count += 1;
760                            let mut item = json!({
761                                "file": contribution.file,
762                                "symbol": export.symbol,
763                                "kind": export.kind,
764                                "line": export.line,
765                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
766                                "used_by": export.test_only_reference_files,
767                            });
768                            add_reexport_contexts(&mut item, &export.also_reexported);
769                            test_only_items.push(item);
770                        }
771                        continue;
772                    }
773                    LivenessVerdict::Uncertain => {
774                        uncertain_count += 1;
775                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
776                            let mut item = json!({
777                                "file": contribution.file,
778                                "symbol": export.symbol,
779                                "kind": export.kind,
780                                "line": export.line,
781                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
782                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
783                            });
784                            add_reexport_contexts(&mut item, &export.also_reexported);
785                            uncertain_items.push(item);
786                        }
787                        continue;
788                    }
789                    LivenessVerdict::Unused => {
790                        if !is_test_file(&contribution.file)
791                            && !export.test_only_reference_files.is_empty()
792                        {
793                            test_only_count += 1;
794                            let mut item = json!({
795                                "file": contribution.file,
796                                "symbol": export.symbol,
797                                "kind": export.kind,
798                                "line": export.line,
799                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
800                                "used_by": export.test_only_reference_files,
801                            });
802                            add_reexport_contexts(&mut item, &export.also_reexported);
803                            test_only_items.push(item);
804                            continue;
805                        }
806                        if export.has_references {
807                            continue;
808                        }
809                    }
810                }
811            } else {
812                let node = (contribution.file.clone(), export.symbol.clone());
813                if reachable.contains(&node)
814                    || is_public_api_file
815                    || dispatched_method_names.contains(symbol_liveness_name(&export.symbol))
816                {
817                    continue;
818                }
819
820                if (export.is_type_like || is_type_like_kind(&export.kind))
821                    && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
822                {
823                    continue;
824                }
825            }
826
827            count += 1;
828            *by_language
829                .entry(language_for_file(&contribution.file).to_string())
830                .or_default() += 1;
831            // Collect ALL items here; rank by signal tier and truncate below so
832            // product findings survive the cap instead of being eaten by
833            // alphabetically-first benchmark/tooling files.
834            let mut item = json!({
835                "file": contribution.file,
836                "symbol": export.symbol,
837                "kind": export.kind,
838                "line": export.line,
839            });
840            if let Some(provenance) = &export.provenance {
841                item["provenance"] = json!(provenance);
842            }
843            add_reexport_contexts(&mut item, &export.also_reexported);
844            dead_items.push(item);
845        }
846    }
847
848    let dead_items =
849        crate::inspect::entry_points::rank_and_truncate_items(dead_items, roles, drill_down_limit);
850    let top = crate::inspect::entry_points::top_preview_symbols(&dead_items);
851    let test_only_items = crate::inspect::entry_points::rank_and_truncate_items(
852        test_only_items,
853        roles,
854        drill_down_limit,
855    );
856    let test_only_top = test_only_items
857        .iter()
858        .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
859        .cloned()
860        .collect::<Vec<_>>();
861
862    let (parse_errors, skipped_files) = dead_code_honesty_fields(parsed);
863    let mut aggregate = json!({
864        "count": count,
865        "items": dead_items,
866        "top": top,
867        "test_only_count": test_only_count,
868        "test_only_items": test_only_items,
869        "test_only_top": test_only_top,
870        "by_language": by_language,
871        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
872        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
873        "uncertain_count": uncertain_count,
874        "uncertain_items": uncertain_items,
875        "callgraph_available": true,
876        "scanned_files": scanned_files,
877        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
878    });
879    if !parse_errors.is_empty() {
880        aggregate["parse_errors"] = Value::Array(parse_errors);
881    }
882    if !skipped_files.is_empty() {
883        aggregate["skipped_files"] = Value::Array(skipped_files);
884    }
885    aggregate
886}
887
888fn add_reexport_contexts(item: &mut Value, contexts: &[OxcReExportContext]) {
889    if !contexts.is_empty() {
890        item["also_reexported"] = json!(contexts);
891    }
892}
893
894fn export_uses_oxc(export: &ExportContribution) -> bool {
895    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
896}
897
898fn dead_code_honesty_fields(parsed: &[DeadCodeContribution]) -> (Vec<Value>, Vec<Value>) {
899    let mut parse_error_keys = BTreeSet::new();
900    let mut parse_errors = Vec::new();
901    let mut skipped_file_keys = BTreeSet::new();
902    let mut skipped_files = Vec::new();
903    for contribution in parsed {
904        for value in &contribution.parse_errors {
905            let key = value.to_string();
906            if parse_error_keys.insert(key) {
907                parse_errors.push(value.clone());
908            }
909        }
910        for value in &contribution.skipped_files {
911            let key = value.to_string();
912            if skipped_file_keys.insert(key) {
913                skipped_files.push(value.clone());
914            }
915        }
916    }
917    (parse_errors, skipped_files)
918}
919
920fn edges_by_source(
921    contributions: &[DeadCodeContribution],
922) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
923    let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
924
925    for contribution in contributions {
926        for call in &contribution.internal_calls {
927            // Keep EVERY resolved edge, regardless of whether the target is an
928            // exported symbol. Liveness must traverse through private
929            // intermediaries (a private router/helper that forwards a root to a
930            // public handler). Restricting targets to exports severed the chain
931            // at the first private hop and made every handler reachable only via
932            // a private function look dead. Node identity is (file, symbol);
933            // private and exported symbols share the same node space.
934            if call.caller_symbol.is_empty() {
935                continue;
936            }
937            let target = (call.file.clone(), call.symbol.clone());
938            let source = (contribution.file.clone(), call.caller_symbol.clone());
939            edges.entry(source).or_default().insert(target);
940        }
941    }
942
943    edges
944}
945
946fn collect_dispatched_method_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
947    contributions
948        .iter()
949        .flat_map(|contribution| contribution.dispatched_method_names.iter().cloned())
950        .collect()
951}
952
953fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
954    // A type-like export is live if it is referenced in type position ANYWHERE
955    // in the project — not only from call-reachable files. Filtering by
956    // call-reachability under-approximates
957    // liveness: the cross-file call graph is incomplete (constructor/method
958    // edges, workspace-package boundaries), so genuinely-used types referenced
959    // from files the call graph fails to mark reachable were flagged dead.
960    // This mirrors `collect_dispatched_method_names`, which is also unfiltered,
961    // and keeps dead_code biased toward under-reporting (it is a hint, not
962    // authority): a type with zero type-references anywhere is still precise
963    // dead.
964    contributions
965        .iter()
966        .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
967        .collect()
968}
969
970fn reachable_exports(
971    contributions: &[DeadCodeContribution],
972    edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
973    dispatched_method_names: &BTreeSet<String>,
974) -> BTreeSet<ExportNode> {
975    let imported_exports_by_file = imported_exports_by_file(contributions);
976    let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
977    let mut expanded_file_imports = BTreeSet::new();
978    let mut reachable = BTreeSet::new();
979    let mut queue = VecDeque::new();
980
981    for contribution in contributions {
982        for root in &contribution.liveness_roots {
983            queue.push_back((contribution.file.clone(), root.clone()));
984        }
985        for export in &contribution.exports {
986            if export.is_entry_point {
987                queue.push_back((contribution.file.clone(), export.symbol.clone()));
988            }
989        }
990    }
991
992    // Methods reached only via receiver dispatch (`obj.method()`) carry no
993    // resolvable call edge — the receiver type is unknown — so they never
994    // become reachable BFS nodes. They ARE rescued from the dead list by name
995    // (`dispatched_method_names`), but that rescue keeps only the method itself
996    // alive; it does NOT propagate liveness THROUGH the method body. Every free
997    // function the method calls is then orphaned and reported dead despite
998    // having real callers (e.g. `BgTaskRegistry::spawn` -> `task_paths`, which
999    // has 33 callers yet was flagged dead). Seed each dispatch-live method body
1000    // as a BFS root, keyed by its scoped caller identity (`Type::method`, the
1001    // form `edges_by_source` uses for sources) so liveness flows through to the
1002    // method's callees. This widens the existing dead_code under-reporting bias
1003    // by exactly one hop and never severs a live chain.
1004    for source in edges_by_source.keys() {
1005        if dispatched_method_names.contains(symbol_liveness_name(&source.1)) {
1006            queue.push_back(source.clone());
1007        }
1008    }
1009
1010    while let Some(node) = queue.pop_front() {
1011        if !reachable.insert(node.clone()) {
1012            continue;
1013        }
1014        if expanded_file_imports.insert(node.0.clone()) {
1015            // Static imports are file-level liveness edges: an imported export
1016            // should keep the target live only when the importer file itself is
1017            // reachable. This prevents dead consumers from making their imports
1018            // look live while still covering references the call graph cannot
1019            // see (type-only imports, JSX/value usage, barrel consumers, etc.).
1020            if let Some(targets) = imported_exports_by_file.get(&node.0) {
1021                for target in targets {
1022                    if !reachable.contains(target) {
1023                        queue.push_back(target.clone());
1024                    }
1025                }
1026            }
1027
1028            // Namespace imports remain conservative file-level edges: once the
1029            // importer file is reached, every export of the imported module is
1030            // considered live because member access is not tracked here.
1031            if let Some(targets) = namespace_imports_by_file.get(&node.0) {
1032                for target in targets {
1033                    if !reachable.contains(target) {
1034                        queue.push_back(target.clone());
1035                    }
1036                }
1037            }
1038        }
1039        if let Some(targets) = edges_by_source.get(&node) {
1040            for target in targets {
1041                if !reachable.contains(target) {
1042                    queue.push_back(target.clone());
1043                }
1044            }
1045        }
1046    }
1047
1048    reachable
1049}
1050
1051fn imported_exports_by_file(
1052    contributions: &[DeadCodeContribution],
1053) -> BTreeMap<String, BTreeSet<ExportNode>> {
1054    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1055
1056    for contribution in contributions {
1057        if contribution.imported_exports.is_empty() {
1058            continue;
1059        }
1060        by_file
1061            .entry(contribution.file.clone())
1062            .or_default()
1063            .extend(
1064                contribution
1065                    .imported_exports
1066                    .iter()
1067                    .map(|root| (root.file.clone(), root.symbol.clone())),
1068            );
1069    }
1070
1071    by_file
1072}
1073
1074fn namespace_imported_exports_by_file(
1075    contributions: &[DeadCodeContribution],
1076) -> BTreeMap<String, BTreeSet<ExportNode>> {
1077    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1078
1079    for contribution in contributions {
1080        if contribution.namespace_imported_exports.is_empty() {
1081            continue;
1082        }
1083        by_file
1084            .entry(contribution.file.clone())
1085            .or_default()
1086            .extend(
1087                contribution
1088                    .namespace_imported_exports
1089                    .iter()
1090                    .map(|root| (root.file.clone(), root.symbol.clone())),
1091            );
1092    }
1093
1094    by_file
1095}
1096
1097fn project_internal_call(
1098    project_root: &Path,
1099    call: &CallgraphOutboundCall,
1100    caller_file: &str,
1101    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1102    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1103) -> Option<InternalCall> {
1104    let target = parse_target(project_root, &call.target);
1105    let symbol = target.symbol?;
1106    let file = match target.file {
1107        // Qualified target (file::symbol). The snapshot builder already
1108        // resolved and validated this edge — cross-file targets are confirmed
1109        // exports of the target file, and same-file targets are confirmed
1110        // definitions (private functions included, e.g. `main.rs::dispatch`).
1111        // Keep the edge regardless of the target's export visibility: liveness
1112        // must flow THROUGH private intermediaries, otherwise a public handler
1113        // reached only via a private router/helper looks unreachable.
1114        Some(file) => file,
1115        None => resolve_unqualified_target(
1116            caller_file,
1117            &symbol,
1118            exported_symbols_by_file,
1119            files_by_exported_symbol,
1120        )?,
1121    };
1122
1123    Some(InternalCall {
1124        caller_symbol: call.caller_symbol.clone(),
1125        file,
1126        symbol,
1127        line: call.line,
1128        provenance: call.provenance.clone(),
1129    })
1130}
1131
1132fn raw_imports_from_tree(
1133    source: &str,
1134    tree: &tree_sitter::Tree,
1135    lang: LangId,
1136) -> Vec<RawImportContribution> {
1137    parse_imports(source, tree, lang)
1138        .imports
1139        .into_iter()
1140        .map(|import| RawImportContribution {
1141            source: import.module_path,
1142            names: import.names,
1143            default_import: import.default_import,
1144            namespace_import: import.namespace_import,
1145        })
1146        .collect()
1147}
1148
1149fn ts_raw_reexport_contributions(
1150    source: &str,
1151    root: tree_sitter::Node,
1152) -> Vec<RawReexportContribution> {
1153    let mut reexports = Vec::new();
1154    let mut cursor = root.walk();
1155    if !cursor.goto_first_child() {
1156        return reexports;
1157    }
1158
1159    loop {
1160        let node = cursor.node();
1161        if node.kind() == "export_statement" {
1162            if let Some(module_path) = export_source_module(source, node) {
1163                let line = (node.start_position().row + 1) as u32;
1164                let raw_export = node_text(source, node).trim();
1165                for specifier in ts_reexport_specifiers(raw_export) {
1166                    reexports.push(RawReexportContribution {
1167                        language: "ts".to_string(),
1168                        source: module_path.clone(),
1169                        kind: "named".to_string(),
1170                        imported: Some(specifier.imported),
1171                        exported: Some(specifier.exported),
1172                        line,
1173                    });
1174                }
1175                if raw_export.contains('*') {
1176                    if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
1177                        reexports.push(RawReexportContribution {
1178                            language: "ts".to_string(),
1179                            source: module_path.clone(),
1180                            kind: "namespace".to_string(),
1181                            imported: Some("*".to_string()),
1182                            exported: Some(namespace_export),
1183                            line,
1184                        });
1185                    } else {
1186                        reexports.push(RawReexportContribution {
1187                            language: "ts".to_string(),
1188                            source: module_path.clone(),
1189                            kind: "star".to_string(),
1190                            imported: Some("*".to_string()),
1191                            exported: None,
1192                            line,
1193                        });
1194                    }
1195                }
1196            }
1197        }
1198
1199        if !cursor.goto_next_sibling() {
1200            break;
1201        }
1202    }
1203
1204    reexports
1205}
1206
1207fn rust_raw_reexport_contributions(source: &str) -> Vec<RawReexportContribution> {
1208    rust_pub_use_statements(source)
1209        .into_iter()
1210        .flat_map(|(statement, line)| {
1211            rust_reexport_specifiers(&statement)
1212                .into_iter()
1213                .map(move |specifier| RawReexportContribution {
1214                    language: "rust".to_string(),
1215                    source: specifier.module_path.join("::"),
1216                    kind: if specifier.imported == "*" {
1217                        "star".to_string()
1218                    } else {
1219                        "named".to_string()
1220                    },
1221                    imported: Some(specifier.imported),
1222                    exported: Some(specifier.exported),
1223                    line,
1224                })
1225        })
1226        .collect()
1227}
1228
1229fn resolve_raw_reexport_liveness_edges(
1230    project_root: &Path,
1231    file_name: &str,
1232    raw_reexports: &[RawReexportContribution],
1233    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1234    default_export_symbols_by_file: &BTreeMap<String, String>,
1235) -> Vec<InternalCall> {
1236    let mut edges = Vec::new();
1237    let file = project_root.join(file_name);
1238    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1239
1240    for raw in raw_reexports {
1241        match raw.language.as_str() {
1242            "ts" => {
1243                let Some(module_entry) = resolve_import_module_path(from_dir, &raw.source) else {
1244                    continue;
1245                };
1246                edges.extend(resolve_reexport_fact_edge(
1247                    project_root,
1248                    file_name,
1249                    &module_entry,
1250                    raw.kind.as_str(),
1251                    raw.imported.as_deref(),
1252                    raw.exported.as_deref(),
1253                    raw.line,
1254                    exported_symbols_by_file,
1255                    default_export_symbols_by_file,
1256                ));
1257            }
1258            "rust" => {
1259                let module_path = raw
1260                    .source
1261                    .split("::")
1262                    .filter(|segment| !segment.is_empty())
1263                    .map(str::to_string)
1264                    .collect::<Vec<_>>();
1265                let Some(module_entry) =
1266                    rust_module_entry_from_file(project_root, file_name, &module_path)
1267                else {
1268                    continue;
1269                };
1270                edges.extend(resolve_reexport_fact_edge(
1271                    project_root,
1272                    file_name,
1273                    &module_entry,
1274                    raw.kind.as_str(),
1275                    raw.imported.as_deref(),
1276                    raw.exported.as_deref(),
1277                    raw.line,
1278                    exported_symbols_by_file,
1279                    default_export_symbols_by_file,
1280                ));
1281            }
1282            _ => {}
1283        }
1284    }
1285
1286    edges
1287}
1288
1289fn resolve_oxc_reexport_liveness_edges(
1290    project_root: &Path,
1291    file_name: &str,
1292    oxc_facts: &OxcFactsContribution,
1293    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1294    default_export_symbols_by_file: &BTreeMap<String, String>,
1295) -> Vec<InternalCall> {
1296    let file = project_root.join(file_name);
1297    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1298    let mut edges = Vec::new();
1299    for fact in &oxc_facts.re_exports {
1300        let Some(module_entry) = resolve_import_module_path(from_dir, &fact.source) else {
1301            continue;
1302        };
1303        let kind = match fact.kind {
1304            ReExportKind::Named => "named",
1305            ReExportKind::Star => "star",
1306            ReExportKind::Namespace => "namespace",
1307        };
1308        edges.extend(resolve_reexport_fact_edge(
1309            project_root,
1310            file_name,
1311            &module_entry,
1312            kind,
1313            fact.imported_name.as_deref(),
1314            fact.exported_name.as_deref(),
1315            fact.line,
1316            exported_symbols_by_file,
1317            default_export_symbols_by_file,
1318        ));
1319    }
1320    edges
1321}
1322
1323#[allow(clippy::too_many_arguments)]
1324fn resolve_reexport_fact_edge(
1325    project_root: &Path,
1326    file_name: &str,
1327    module_entry: &Path,
1328    kind: &str,
1329    imported: Option<&str>,
1330    exported: Option<&str>,
1331    line: u32,
1332    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1333    default_export_symbols_by_file: &BTreeMap<String, String>,
1334) -> Vec<InternalCall> {
1335    match kind {
1336        "star" => reexport_edges_for_all_target_symbols(
1337            project_root,
1338            file_name,
1339            "",
1340            module_entry,
1341            line,
1342            exported_symbols_by_file,
1343            default_export_symbols_by_file,
1344            true,
1345        ),
1346        "namespace" => {
1347            let namespace_export = exported.unwrap_or_default();
1348            if namespace_export.is_empty()
1349                || !file_exports_symbol(file_name, namespace_export, exported_symbols_by_file)
1350            {
1351                return Vec::new();
1352            }
1353            reexport_edges_for_all_target_symbols(
1354                project_root,
1355                file_name,
1356                namespace_export,
1357                module_entry,
1358                line,
1359                exported_symbols_by_file,
1360                default_export_symbols_by_file,
1361                false,
1362            )
1363        }
1364        _ => {
1365            let imported = imported.unwrap_or_default();
1366            let exported = exported.unwrap_or(imported);
1367            if imported.is_empty()
1368                || exported.is_empty()
1369                || !file_exports_symbol(file_name, exported, exported_symbols_by_file)
1370            {
1371                return Vec::new();
1372            }
1373            resolve_imported_export_liveness_root(
1374                project_root,
1375                module_entry,
1376                imported,
1377                exported_symbols_by_file,
1378                default_export_symbols_by_file,
1379            )
1380            .map(|(target_file, target_symbol)| {
1381                vec![InternalCall {
1382                    caller_symbol: exported.to_string(),
1383                    file: target_file,
1384                    symbol: target_symbol,
1385                    line,
1386                    provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
1387                }]
1388            })
1389            .unwrap_or_default()
1390        }
1391    }
1392}
1393
1394fn rust_module_entry_from_file(
1395    project_root: &Path,
1396    file_name: &str,
1397    module_path: &[String],
1398) -> Option<PathBuf> {
1399    let first = module_path.first()?;
1400    let file = project_root.join(file_name);
1401    let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
1402    resolve_rust_module_file(base_dir, first)
1403}
1404
1405fn resolve_raw_imported_export_liveness_roots(
1406    project_root: &Path,
1407    file_name: &str,
1408    raw_imports: &[RawImportContribution],
1409    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1410    default_export_symbols_by_file: &BTreeMap<String, String>,
1411) -> ImportedExportLiveness {
1412    let file = project_root.join(file_name);
1413    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1414    let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
1415    let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
1416
1417    for import in raw_imports {
1418        if import.namespace_import.is_some() {
1419            if let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) {
1420                namespace_exports.extend(resolve_namespace_import_liveness_roots(
1421                    project_root,
1422                    &module_entry,
1423                    exported_symbols_by_file,
1424                    default_export_symbols_by_file,
1425                ));
1426            }
1427        }
1428
1429        let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) else {
1430            continue;
1431        };
1432
1433        for imported_name in import
1434            .names
1435            .iter()
1436            .map(|name| specifier_imported_name(name))
1437        {
1438            if let Some(root) = resolve_imported_export_liveness_root(
1439                project_root,
1440                &module_entry,
1441                imported_name,
1442                exported_symbols_by_file,
1443                default_export_symbols_by_file,
1444            ) {
1445                root_exports.insert(root);
1446            }
1447        }
1448
1449        if import.default_import.is_some() {
1450            if let Some(root) = resolve_imported_export_liveness_root(
1451                project_root,
1452                &module_entry,
1453                "default",
1454                exported_symbols_by_file,
1455                default_export_symbols_by_file,
1456            ) {
1457                root_exports.insert(root);
1458            }
1459        }
1460    }
1461
1462    ImportedExportLiveness {
1463        root_exports: root_exports
1464            .into_iter()
1465            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1466            .collect(),
1467        namespace_exports: namespace_exports
1468            .into_iter()
1469            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1470            .collect(),
1471    }
1472}
1473
1474fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
1475    let Some(start) = raw_export.find('{').map(|index| index + 1) else {
1476        return Vec::new();
1477    };
1478    let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
1479        return Vec::new();
1480    };
1481
1482    raw_export[start..end]
1483        .split(',')
1484        .filter_map(|specifier| {
1485            let specifier = specifier.trim();
1486            if specifier.is_empty() {
1487                return None;
1488            }
1489            let imported = specifier_imported_name(specifier).trim();
1490            let exported = specifier_local_name(specifier).trim();
1491            if imported.is_empty() || exported.is_empty() {
1492                return None;
1493            }
1494            Some(ReexportSpecifier {
1495                imported: imported.to_string(),
1496                exported: exported.to_string(),
1497            })
1498        })
1499        .collect()
1500}
1501
1502fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
1503    let after_star = raw_export.split_once('*')?.1.trim_start();
1504    let after_as = after_star.strip_prefix("as")?.trim_start();
1505    let name = after_as
1506        .split_whitespace()
1507        .next()?
1508        .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
1509    (!name.is_empty()).then(|| name.to_string())
1510}
1511
1512fn reexport_edges_for_all_target_symbols(
1513    project_root: &Path,
1514    file_name: &str,
1515    namespace_export: &str,
1516    module_entry: &Path,
1517    line: u32,
1518    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1519    default_export_symbols_by_file: &BTreeMap<String, String>,
1520    match_current_export_names: bool,
1521) -> Vec<InternalCall> {
1522    let Some((_, target_symbols)) =
1523        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1524    else {
1525        return Vec::new();
1526    };
1527
1528    let mut edges = Vec::new();
1529    for target_symbol in target_symbols {
1530        let caller_symbol = if match_current_export_names {
1531            if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
1532                continue;
1533            }
1534            target_symbol.clone()
1535        } else {
1536            namespace_export.to_string()
1537        };
1538
1539        if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
1540            project_root,
1541            module_entry,
1542            target_symbol,
1543            exported_symbols_by_file,
1544            default_export_symbols_by_file,
1545        ) {
1546            edges.push(InternalCall {
1547                caller_symbol,
1548                file: target_file,
1549                symbol: resolved_symbol,
1550                line,
1551                provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
1552            });
1553        }
1554    }
1555
1556    edges
1557}
1558
1559fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
1560    let flat = base_dir.join(format!("{module}.rs"));
1561    if flat.is_file() {
1562        return Some(flat);
1563    }
1564    let nested = base_dir.join(module).join("mod.rs");
1565    nested.is_file().then_some(nested)
1566}
1567
1568fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
1569    let mut statements = Vec::new();
1570    let mut current = String::new();
1571    let mut start_line = 0u32;
1572
1573    for (index, line) in source.lines().enumerate() {
1574        let trimmed = line.trim();
1575        if current.is_empty() {
1576            if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
1577                continue;
1578            }
1579            start_line = (index + 1) as u32;
1580        }
1581
1582        current.push(' ');
1583        current.push_str(trimmed);
1584        if trimmed.ends_with(';') {
1585            statements.push((current.trim().to_string(), start_line));
1586            current.clear();
1587        }
1588    }
1589
1590    statements
1591}
1592
1593fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
1594    let statement = statement
1595        .trim()
1596        .trim_end_matches(';')
1597        .strip_prefix("pub(crate) use ")
1598        .or_else(|| {
1599            statement
1600                .trim()
1601                .trim_end_matches(';')
1602                .strip_prefix("pub use ")
1603        })
1604        .unwrap_or("")
1605        .trim();
1606    if statement.is_empty() {
1607        return Vec::new();
1608    }
1609
1610    if let Some((module_path, grouped)) = statement.split_once("::{") {
1611        let grouped = grouped.trim_end_matches('}');
1612        return grouped
1613            .split(',')
1614            .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
1615            .collect();
1616    }
1617
1618    let Some((module_path, imported)) = statement.rsplit_once("::") else {
1619        return Vec::new();
1620    };
1621    rust_reexport_specifier(module_path.trim(), imported.trim())
1622        .into_iter()
1623        .collect()
1624}
1625
1626fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
1627    if specifier.is_empty() {
1628        return None;
1629    }
1630    let (imported, exported) = specifier
1631        .split_once(" as ")
1632        .map(|(imported, exported)| (imported.trim(), exported.trim()))
1633        .unwrap_or((specifier.trim(), specifier.trim()));
1634    if imported.is_empty() || exported.is_empty() {
1635        return None;
1636    }
1637    Some(RustReexportSpecifier {
1638        module_path: rust_normalize_module_path(module_path),
1639        imported: imported.to_string(),
1640        exported: exported.to_string(),
1641    })
1642}
1643
1644fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
1645    module_path
1646        .split("::")
1647        .filter_map(|segment| {
1648            let segment = segment.trim();
1649            if segment.is_empty() || matches!(segment, "self" | "crate") {
1650                None
1651            } else {
1652                Some(segment.to_string())
1653            }
1654        })
1655        .collect()
1656}
1657
1658fn file_exports_symbol(
1659    file_name: &str,
1660    symbol: &str,
1661    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1662) -> bool {
1663    exported_symbols_by_file
1664        .get(file_name)
1665        .is_some_and(|symbols| symbols.contains(symbol))
1666}
1667
1668fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
1669    node.child_by_field_name("source")
1670        .or_else(|| find_child_by_kind(node, "string"))
1671        .and_then(|source_node| string_literal_content(source, source_node))
1672}
1673
1674fn find_child_by_kind<'tree>(
1675    node: tree_sitter::Node<'tree>,
1676    kind: &str,
1677) -> Option<tree_sitter::Node<'tree>> {
1678    let mut cursor = node.walk();
1679    if !cursor.goto_first_child() {
1680        return None;
1681    }
1682    loop {
1683        let child = cursor.node();
1684        if child.kind() == kind {
1685            return Some(child);
1686        }
1687        if let Some(descendant) = find_child_by_kind(child, kind) {
1688            return Some(descendant);
1689        }
1690        if !cursor.goto_next_sibling() {
1691            break;
1692        }
1693    }
1694    None
1695}
1696
1697fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
1698    let raw = node_text(source, node).trim();
1699    let quote = raw.chars().next()?;
1700    if quote != '\'' && quote != '"' {
1701        return None;
1702    }
1703    raw.strip_prefix(quote)
1704        .and_then(|value| value.strip_suffix(quote))
1705        .map(ToOwned::to_owned)
1706}
1707
1708fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
1709    &source[node.byte_range()]
1710}
1711
1712fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1713    if is_relative_module_path(module_path) {
1714        return resolve_js_ts_module_path(from_dir, module_path);
1715    }
1716    resolve_workspace_package_import(from_dir, module_path)
1717}
1718
1719fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1720    resolve_module_path(from_dir, module_path)
1721        .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
1722}
1723
1724fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1725    if !is_relative_module_path(module_path) {
1726        return None;
1727    }
1728    let base = from_dir.join(module_path);
1729    let ext = base.extension().and_then(|extension| extension.to_str())?;
1730    let candidates: &[&str] = match ext {
1731        "js" => &["ts", "tsx"],
1732        "jsx" => &["tsx", "ts"],
1733        "mjs" => &["mts", "ts"],
1734        "cjs" => &["cts", "ts"],
1735        _ => return None,
1736    };
1737
1738    candidates
1739        .iter()
1740        .map(|extension| base.with_extension(extension))
1741        .find(|candidate| candidate.is_file())
1742}
1743
1744fn is_relative_module_path(module_path: &str) -> bool {
1745    module_path.starts_with("./")
1746        || module_path.starts_with("../")
1747        || module_path == "."
1748        || module_path == ".."
1749}
1750
1751#[derive(Debug)]
1752struct ReexportSpecifier {
1753    imported: String,
1754    exported: String,
1755}
1756
1757#[derive(Debug)]
1758struct RustReexportSpecifier {
1759    module_path: Vec<String>,
1760    imported: String,
1761    exported: String,
1762}
1763
1764fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1765    let package_name = package_name_from_import(module_path)?;
1766    let module_entry = resolve_module_path(from_dir, module_path)?;
1767    let resolved_package_name = package_name_for_file(&module_entry)?;
1768    (resolved_package_name == package_name).then_some(module_entry)
1769}
1770
1771fn package_name_from_import(module_path: &str) -> Option<String> {
1772    if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
1773    {
1774        return None;
1775    }
1776
1777    let mut parts = module_path.split('/');
1778    let first = parts.next()?;
1779    if first.is_empty() {
1780        return None;
1781    }
1782
1783    if first.starts_with('@') {
1784        let second = parts.next()?;
1785        (!second.is_empty()).then(|| format!("{first}/{second}"))
1786    } else {
1787        Some(first.to_string())
1788    }
1789}
1790
1791fn package_name_for_file(file: &Path) -> Option<String> {
1792    let mut current = file.parent();
1793    while let Some(dir) = current {
1794        let manifest = dir.join("package.json");
1795        if manifest.is_file() {
1796            if let Ok(source) = fs::read_to_string(&manifest) {
1797                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
1798                    if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
1799                        return Some(name.to_string());
1800                    }
1801                }
1802            }
1803        }
1804        current = dir.parent();
1805    }
1806    None
1807}
1808
1809fn resolve_namespace_import_liveness_roots(
1810    project_root: &Path,
1811    module_entry: &Path,
1812    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1813    default_export_symbols_by_file: &BTreeMap<String, String>,
1814) -> Vec<ExportNode> {
1815    let Some((_, symbols)) =
1816        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1817    else {
1818        return Vec::new();
1819    };
1820    let mut roots = BTreeSet::new();
1821
1822    for symbol in symbols {
1823        if let Some(root) = resolve_imported_export_liveness_root(
1824            project_root,
1825            module_entry,
1826            symbol,
1827            exported_symbols_by_file,
1828            default_export_symbols_by_file,
1829        ) {
1830            roots.insert(root);
1831        }
1832    }
1833
1834    if default_export_symbol_for_resolved_file(
1835        project_root,
1836        module_entry,
1837        default_export_symbols_by_file,
1838    )
1839    .is_some()
1840    {
1841        if let Some(root) = resolve_imported_export_liveness_root(
1842            project_root,
1843            module_entry,
1844            "default",
1845            exported_symbols_by_file,
1846            default_export_symbols_by_file,
1847        ) {
1848            roots.insert(root);
1849        }
1850    }
1851
1852    roots.into_iter().collect()
1853}
1854
1855fn resolve_imported_export_liveness_root(
1856    project_root: &Path,
1857    module_entry: &Path,
1858    imported_symbol: &str,
1859    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1860    default_export_symbols_by_file: &BTreeMap<String, String>,
1861) -> Option<ExportNode> {
1862    let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
1863        exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1864            .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
1865    };
1866    let mut file_default_export_symbol = |path: &Path| {
1867        default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
1868            .or_else(|| {
1869                exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1870                    .and_then(|(_, symbols)| {
1871                        symbols.contains("default").then(|| "default".to_string())
1872                    })
1873            })
1874    };
1875
1876    let (target_file, symbol) = resolve_reexported_symbol_target(
1877        module_entry,
1878        imported_symbol,
1879        &mut file_exports_symbol,
1880        &mut file_default_export_symbol,
1881    )?;
1882
1883    let (file, symbols) =
1884        exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
1885    symbols.contains(&symbol).then_some((file, symbol))
1886}
1887
1888fn exported_symbols_for_resolved_file<'a>(
1889    project_root: &Path,
1890    file: &Path,
1891    exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
1892) -> Option<(String, &'a BTreeSet<String>)> {
1893    let relative = relative_path(project_root, file);
1894    if let Some(symbols) = exported_symbols_by_file.get(&relative) {
1895        return Some((relative, symbols));
1896    }
1897
1898    let canonical_root = fs::canonicalize(project_root).ok()?;
1899    let canonical_file = fs::canonicalize(file).ok()?;
1900    let relative = relative_path(&canonical_root, &canonical_file);
1901    exported_symbols_by_file
1902        .get(&relative)
1903        .map(|symbols| (relative, symbols))
1904}
1905
1906fn default_export_symbol_for_resolved_file(
1907    project_root: &Path,
1908    file: &Path,
1909    default_export_symbols_by_file: &BTreeMap<String, String>,
1910) -> Option<String> {
1911    let relative = relative_path(project_root, file);
1912    if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
1913        return Some(symbol.clone());
1914    }
1915
1916    let canonical_root = fs::canonicalize(project_root).ok()?;
1917    let canonical_file = fs::canonicalize(file).ok()?;
1918    let relative = relative_path(&canonical_root, &canonical_file);
1919    default_export_symbols_by_file.get(&relative).cloned()
1920}
1921
1922fn resolve_unqualified_target(
1923    caller_file: &str,
1924    symbol: &str,
1925    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1926    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1927) -> Option<String> {
1928    if exported_symbols_by_file
1929        .get(caller_file)
1930        .is_some_and(|symbols| symbols.contains(symbol))
1931    {
1932        return Some(caller_file.to_string());
1933    }
1934
1935    let files = files_by_exported_symbol.get(symbol)?;
1936    if files.len() == 1 {
1937        files.iter().next().cloned()
1938    } else {
1939        None
1940    }
1941}
1942
1943fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
1944    let (target, full_callee) = split_call_target_metadata(&call.target);
1945    if let Some(full_callee) = full_callee {
1946        return dispatched_method_name_from_callee(full_callee);
1947    }
1948    if target.contains("::") || target.contains('#') {
1949        return None;
1950    }
1951    dispatched_method_name_from_callee(target)
1952}
1953
1954fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
1955    let callee = callee.trim();
1956    if !callee.contains('.') {
1957        return None;
1958    }
1959
1960    clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
1961}
1962
1963fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
1964    target
1965        .split_once(DISPATCHED_CALLEE_SEPARATOR)
1966        .map_or((target, None), |(target, full_callee)| {
1967            (target, Some(full_callee))
1968        })
1969}
1970
1971fn symbol_liveness_name(symbol: &str) -> &str {
1972    symbol
1973        .rsplit(['.', ':', '#'])
1974        .find(|segment| !segment.is_empty())
1975        .unwrap_or(symbol)
1976}
1977
1978fn is_type_like_kind(kind: &str) -> bool {
1979    matches!(
1980        kind,
1981        "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
1982    )
1983}
1984
1985fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
1986    let (target, _) = split_call_target_metadata(target);
1987    let trimmed = target.trim();
1988    if trimmed.is_empty() {
1989        return ParsedTarget {
1990            file: None,
1991            symbol: None,
1992        };
1993    }
1994
1995    if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
1996        return ParsedTarget {
1997            file: Some(relative_path(project_root, Path::new(file))),
1998            symbol: clean_symbol(symbol),
1999        };
2000    }
2001
2002    if let Some((file, symbol)) = trimmed.rsplit_once('#') {
2003        return ParsedTarget {
2004            file: Some(relative_path(project_root, Path::new(file))),
2005            symbol: clean_symbol(symbol),
2006        };
2007    }
2008
2009    ParsedTarget {
2010        file: None,
2011        symbol: clean_symbol(trimmed),
2012    }
2013}
2014
2015fn split_file_symbol_target<'a>(
2016    project_root: &Path,
2017    target: &'a str,
2018    separator: &str,
2019) -> Option<(&'a str, &'a str)> {
2020    let mut search_start = 0;
2021    while let Some(offset) = target[search_start..].find(separator) {
2022        let split_at = search_start + offset;
2023        let file = &target[..split_at];
2024        let symbol = &target[split_at + separator.len()..];
2025        if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
2026            return Some((file, symbol));
2027        }
2028        search_start = split_at + separator.len();
2029    }
2030    None
2031}
2032
2033fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
2034    let path = Path::new(file);
2035    language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
2036}
2037
2038fn clean_symbol(symbol: &str) -> Option<String> {
2039    let trimmed = symbol.trim();
2040    if trimmed.is_empty() {
2041        None
2042    } else {
2043        Some(trimmed.to_string())
2044    }
2045}
2046
2047fn liveness_roots_for_file(
2048    file_name: &str,
2049    exports: &[ExportContribution],
2050    internal_calls: &[InternalCall],
2051    attribute_entry_points: &BTreeSet<String>,
2052    executable_root_exports: Option<&BTreeSet<String>>,
2053    is_liveness_root_file: bool,
2054    is_public_api_file: bool,
2055) -> Vec<String> {
2056    let mut roots = attribute_entry_points
2057        .iter()
2058        .filter_map(|symbol| clean_symbol(symbol))
2059        .collect::<BTreeSet<_>>();
2060
2061    if !is_liveness_root_file && !is_public_api_file {
2062        return roots.into_iter().collect();
2063    }
2064
2065    roots.insert("<top-level>".to_string());
2066    if is_public_api_file {
2067        roots.extend(exports.iter().map(|export| export.symbol.clone()));
2068    } else if let Some(executable_root_exports) = executable_root_exports {
2069        roots.extend(executable_root_exports.iter().cloned());
2070    } else {
2071        roots.extend(
2072            exports
2073                .iter()
2074                .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
2075                .map(|export| export.symbol.clone()),
2076        );
2077        roots.extend(
2078            internal_calls
2079                .iter()
2080                .map(|call| call.caller_symbol.as_str())
2081                .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
2082                .map(str::to_string),
2083        );
2084    }
2085
2086    roots.into_iter().collect()
2087}
2088
2089fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
2090    let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
2091    if symbol == "<top-level>" {
2092        return true;
2093    }
2094
2095    let lower = symbol.to_ascii_lowercase();
2096    if matches!(
2097        lower.as_str(),
2098        "main" | "init" | "setup" | "bootstrap" | "run"
2099    ) {
2100        return true;
2101    }
2102
2103    Path::new(file_name)
2104        .file_stem()
2105        .and_then(|stem| stem.to_str())
2106        .is_some_and(|stem| stem == symbol)
2107}
2108
2109pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
2110    crate::inspect::entry_points::resolve_entry_points(project_root)
2111        .public_api_files_relative(project_root)
2112}
2113
2114fn language_for_file(file: &str) -> &'static str {
2115    let extension = Path::new(file)
2116        .extension()
2117        .and_then(|extension| extension.to_str())
2118        .map(|extension| extension.to_ascii_lowercase())
2119        .unwrap_or_default();
2120
2121    match extension.as_str() {
2122        "rs" => "rust",
2123        "ts" | "tsx" | "mts" | "cts" => "typescript",
2124        "js" | "jsx" | "mjs" | "cjs" => "javascript",
2125        "py" => "python",
2126        "go" => "go",
2127        "c" | "h" => "c",
2128        "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
2129        "zig" => "zig",
2130        "cs" => "csharp",
2131        "sh" | "bash" | "zsh" | "fish" => "bash",
2132        "html" | "htm" => "html",
2133        "md" | "markdown" => "markdown",
2134        "sol" => "solidity",
2135        "vue" => "vue",
2136        "json" => "json",
2137        "scala" => "scala",
2138        "java" => "java",
2139        "rb" => "ruby",
2140        "kt" | "kts" => "kotlin",
2141        "swift" => "swift",
2142        "php" => "php",
2143        "lua" => "lua",
2144        "pl" | "pm" => "perl",
2145        _ => "unknown",
2146    }
2147}
2148
2149fn supports_type_refs(lang: LangId) -> bool {
2150    matches!(
2151        lang,
2152        LangId::TypeScript
2153            | LangId::Tsx
2154            | LangId::JavaScript
2155            | LangId::Python
2156            | LangId::Rust
2157            | LangId::Go
2158    )
2159}
2160
2161fn collect_freshness(file: &Path) -> FileFreshness {
2162    cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
2163        mtime: UNIX_EPOCH,
2164        size: 0,
2165        content_hash: cache_freshness::zero_hash(),
2166    })
2167}
2168
2169fn relative_path(project_root: &Path, path: &Path) -> String {
2170    let absolute = if path.is_absolute() {
2171        path.to_path_buf()
2172    } else {
2173        project_root.join(path)
2174    };
2175    let normalized_root = canonicalize_normalized(project_root);
2176    let normalized = canonicalize_normalized(&absolute);
2177    normalized
2178        .strip_prefix(&normalized_root)
2179        .unwrap_or(normalized.as_path())
2180        .to_string_lossy()
2181        .replace('\\', "/")
2182}
2183
2184fn canonical_or_normalized(project_root: &Path, path: &Path) -> PathBuf {
2185    // Delegates to the oxc engine's input normalizer so FileFacts paths built
2186    // here compare equal to the engine's entry-point/executable-root sets.
2187    // Calling fs::canonicalize directly is wrong on Windows: it returns
2188    // verbatim (\\?\C:\) paths while those sets are de-verbatimed, and the
2189    // membership miss silently drops entry-point liveness.
2190    crate::inspect::oxc_engine::normalize_input_path(project_root, path)
2191}
2192
2193fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
2194    let absolute = if path.is_absolute() {
2195        path.to_path_buf()
2196    } else {
2197        project_root.join(path)
2198    };
2199    normalize_path(&absolute)
2200}
2201
2202fn normalize_path(path: &Path) -> PathBuf {
2203    let mut normalized = PathBuf::new();
2204    for component in path.components() {
2205        match component {
2206            Component::CurDir => {}
2207            Component::ParentDir => {
2208                if !normalized.pop() {
2209                    normalized.push(component.as_os_str());
2210                }
2211            }
2212            _ => normalized.push(component.as_os_str()),
2213        }
2214    }
2215    normalized
2216}
2217
2218#[derive(Debug, Clone, Deserialize)]
2219struct DeadCodeContribution {
2220    file: String,
2221    exports: Vec<ExportContribution>,
2222    #[serde(default)]
2223    facts_format_version: Option<u32>,
2224    #[serde(default)]
2225    raw_imports: Vec<RawImportContribution>,
2226    #[serde(default)]
2227    raw_reexports: Vec<RawReexportContribution>,
2228    #[serde(default)]
2229    attribute_entry_points: Vec<String>,
2230    #[serde(default)]
2231    oxc_facts: Option<OxcFactsContribution>,
2232    #[serde(default)]
2233    internal_calls: Vec<InternalCallContribution>,
2234    #[serde(default)]
2235    liveness_roots: Vec<String>,
2236    #[serde(default)]
2237    imported_exports: Vec<ImportedExportContribution>,
2238    #[serde(default)]
2239    namespace_imported_exports: Vec<ImportedExportContribution>,
2240    #[serde(default)]
2241    dispatched_method_names: Vec<String>,
2242    #[serde(default)]
2243    type_ref_names: Vec<String>,
2244    #[serde(default)]
2245    parse_errors: Vec<Value>,
2246    #[serde(default)]
2247    skipped_files: Vec<Value>,
2248}
2249
2250#[derive(Debug, Clone, Serialize, Deserialize)]
2251struct RawImportContribution {
2252    source: String,
2253    #[serde(default)]
2254    names: Vec<String>,
2255    #[serde(default)]
2256    default_import: Option<String>,
2257    #[serde(default)]
2258    namespace_import: Option<String>,
2259}
2260
2261#[derive(Debug, Clone, Serialize, Deserialize)]
2262struct RawReexportContribution {
2263    language: String,
2264    source: String,
2265    kind: String,
2266    #[serde(default)]
2267    imported: Option<String>,
2268    #[serde(default)]
2269    exported: Option<String>,
2270    line: u32,
2271}
2272
2273#[derive(Debug, Clone, Deserialize)]
2274struct OxcFactsContribution {
2275    format_version: u32,
2276    content_hash: String,
2277    exports: Vec<ExportFact>,
2278    imports: Vec<ImportFact>,
2279    re_exports: Vec<ReExportFact>,
2280    dynamic_imports: Vec<DynamicImportFact>,
2281    same_file_value_references: BTreeSet<String>,
2282    used_import_bindings: BTreeSet<String>,
2283    type_referenced_import_bindings: BTreeSet<String>,
2284    value_referenced_import_bindings: BTreeSet<String>,
2285    #[serde(default)]
2286    parse_error: Option<String>,
2287}
2288
2289#[derive(Debug, Clone, Deserialize)]
2290struct ImportedExportContribution {
2291    file: String,
2292    symbol: String,
2293}
2294
2295#[derive(Debug, Clone, Deserialize)]
2296struct ExportContribution {
2297    symbol: String,
2298    kind: String,
2299    line: u32,
2300    #[serde(default)]
2301    is_type_like: bool,
2302    #[serde(default)]
2303    is_entry_point: bool,
2304    #[serde(default)]
2305    has_references: bool,
2306    #[serde(default)]
2307    test_only_reference_files: Vec<String>,
2308    #[serde(default)]
2309    verdict: Option<LivenessVerdict>,
2310    #[serde(default)]
2311    reason: Option<String>,
2312    #[serde(default)]
2313    provenance: Option<String>,
2314    #[serde(default)]
2315    also_reexported: Vec<OxcReExportContext>,
2316}
2317
2318#[derive(Debug, Clone, Deserialize)]
2319struct InternalCallContribution {
2320    #[serde(default)]
2321    caller_symbol: String,
2322    file: String,
2323    symbol: String,
2324}
2325
2326impl From<InternalCall> for InternalCallContribution {
2327    fn from(call: InternalCall) -> Self {
2328        Self {
2329            caller_symbol: call.caller_symbol,
2330            file: call.file,
2331            symbol: call.symbol,
2332        }
2333    }
2334}
2335
2336#[derive(Debug, Clone)]
2337struct InternalCall {
2338    caller_symbol: String,
2339    file: String,
2340    symbol: String,
2341    line: u32,
2342    provenance: String,
2343}
2344
2345#[derive(Debug, Clone)]
2346struct ParsedTarget {
2347    file: Option<String>,
2348    symbol: Option<String>,
2349}
2350
2351#[cfg(test)]
2352mod tests {
2353    use super::*;
2354    use std::fs;
2355    use std::path::{Path, PathBuf};
2356    use std::sync::{Arc, RwLock};
2357
2358    use crate::config::Config;
2359    use crate::inspect::job::{CALLGRAPH_PROVENANCE_TREESITTER, DISPATCHED_CALLEE_SEPARATOR};
2360    use crate::inspect::{CallgraphExport, JobKey};
2361    use crate::parser::SymbolCache;
2362
2363    fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
2364        let temp_dir = tempfile::tempdir().expect("tempdir");
2365        let root = temp_dir.path().join("project");
2366        fs::create_dir_all(&root).expect("create project root");
2367
2368        let paths = files
2369            .iter()
2370            .map(|(relative, contents)| {
2371                let path = root.join(relative);
2372                if let Some(parent) = path.parent() {
2373                    fs::create_dir_all(parent).expect("create parent");
2374                }
2375                fs::write(&path, contents).expect("write fixture file");
2376                path
2377            })
2378            .collect::<Vec<_>>();
2379
2380        (temp_dir, root, paths)
2381    }
2382
2383    fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
2384        InspectJob {
2385            job_id: 1,
2386            key: JobKey::for_project_category(InspectCategory::DeadCode),
2387            category: InspectCategory::DeadCode,
2388            scope_files,
2389            project_root: root.to_path_buf(),
2390            inspect_dir: root.join(".aft-cache").join("inspect"),
2391            config: Arc::new(Config {
2392                project_root: Some(root.to_path_buf()),
2393                ..Config::default()
2394            }),
2395            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2396            callgraph_snapshot: Some(Arc::new(snapshot)),
2397        }
2398    }
2399
2400    fn snapshot(
2401        files: Vec<PathBuf>,
2402        exported_symbols: Vec<CallgraphExport>,
2403        outbound_calls: Vec<CallgraphOutboundCall>,
2404    ) -> CallgraphSnapshot {
2405        snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
2406    }
2407
2408    fn snapshot_with_entry_points(
2409        files: Vec<PathBuf>,
2410        exported_symbols: Vec<CallgraphExport>,
2411        outbound_calls: Vec<CallgraphOutboundCall>,
2412        entry_points: BTreeSet<PathBuf>,
2413    ) -> CallgraphSnapshot {
2414        CallgraphSnapshot {
2415            generated_at: None,
2416            files,
2417            exported_symbols,
2418            outbound_calls,
2419            entry_points,
2420            entry_point_symbols: BTreeMap::new(),
2421        }
2422    }
2423
2424    fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
2425        CallgraphExport {
2426            file: root.join(file),
2427            symbol: symbol.to_string(),
2428            kind: kind.to_string(),
2429            line: 1,
2430        }
2431    }
2432
2433    fn outbound(
2434        root: &Path,
2435        caller_file: &str,
2436        caller_symbol: &str,
2437        target: &str,
2438    ) -> CallgraphOutboundCall {
2439        CallgraphOutboundCall {
2440            caller_file: root.join(caller_file),
2441            caller_symbol: caller_symbol.to_string(),
2442            target: target.to_string(),
2443            line: 1,
2444            provenance: CALLGRAPH_PROVENANCE_TREESITTER.to_string(),
2445        }
2446    }
2447
2448    fn dispatched_target(target: &str, full_callee: &str) -> String {
2449        format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
2450    }
2451
2452    fn scan(job: InspectJob) -> serde_json::Value {
2453        run_dead_code_scan(&job)
2454            .outcome
2455            .expect("scan succeeds")
2456            .aggregate
2457    }
2458
2459    fn aggregate_has_item(aggregate: &serde_json::Value, file: &str, symbol: &str) -> bool {
2460        aggregate
2461            .get("items")
2462            .and_then(serde_json::Value::as_array)
2463            .into_iter()
2464            .flatten()
2465            .any(|item| {
2466                item.get("file").and_then(serde_json::Value::as_str) == Some(file)
2467                    && item.get("symbol").and_then(serde_json::Value::as_str) == Some(symbol)
2468            })
2469    }
2470
2471    fn scan_success_with_oxc(job: InspectJob) -> InspectScanSuccess {
2472        let entry_points = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
2473        let options = AnalyzeOptions {
2474            entry_points: job
2475                .callgraph_snapshot
2476                .as_ref()
2477                .map(|snapshot| snapshot.entry_points.iter().cloned().collect())
2478                .unwrap_or_default(),
2479            public_api_files: Vec::new(),
2480            executable_root_exports: entry_points.executable_root_exports(),
2481            force_reparse_files: Vec::new(),
2482            entry_reachability: true,
2483        };
2484        let oxc_result =
2485            crate::inspect::oxc_engine::analyze_files(&job.project_root, &job.scope_files, options)
2486                .expect("oxc analyze succeeds");
2487        run_dead_code_scan_with_oxc(&job, Some(&oxc_result))
2488            .outcome
2489            .expect("scan succeeds")
2490    }
2491
2492    fn scan_with_oxc(job: InspectJob) -> serde_json::Value {
2493        scan_success_with_oxc(job).aggregate
2494    }
2495
2496    fn aggregate_item<'a>(
2497        aggregate: &'a serde_json::Value,
2498        file: &str,
2499        symbol: &str,
2500    ) -> Option<&'a serde_json::Value> {
2501        aggregate["items"].as_array()?.iter().find(|item| {
2502            item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
2503        })
2504    }
2505
2506    fn aggregate_test_only_item<'a>(
2507        aggregate: &'a serde_json::Value,
2508        file: &str,
2509        symbol: &str,
2510    ) -> Option<&'a serde_json::Value> {
2511        aggregate["test_only_items"]
2512            .as_array()?
2513            .iter()
2514            .find(|item| {
2515                item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
2516            })
2517    }
2518
2519    #[test]
2520    fn oxc_dead_code_splits_test_only_references_from_headline() {
2521        let (_temp_dir, root, paths) = fixture_project(&[
2522            ("package.json", r#"{"main":"src/main.ts"}"#),
2523            (
2524                "src/main.ts",
2525                "import { productUsed } from './api';
2526export function main() { productUsed(); }
2527",
2528            ),
2529            (
2530                "src/api.ts",
2531                "export function testOnly() {}
2532export function productUsed() {}
2533",
2534            ),
2535            (
2536                "src/dead.ts",
2537                "export function plantedDead() {}
2538",
2539            ),
2540            (
2541                "src/api.test.ts",
2542                "import { testOnly } from './api';
2543testOnly();
2544",
2545            ),
2546            (
2547                "src/barrel-target.ts",
2548                "export function throughBarrel() {}
2549export function barrelDead() {}
2550",
2551            ),
2552            (
2553                "src/barrel.ts",
2554                "export { throughBarrel } from './barrel-target';
2555",
2556            ),
2557            (
2558                "src/barrel.test.ts",
2559                "import { throughBarrel } from './barrel';
2560throughBarrel();
2561",
2562            ),
2563        ]);
2564        let root = fs::canonicalize(root).expect("canonical project root");
2565        let paths = paths
2566            .into_iter()
2567            .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
2568            .collect::<Vec<_>>();
2569        let entry_points = BTreeSet::from([root.join("src/main.ts")]);
2570        let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
2571
2572        let aggregate = scan_with_oxc(job(&root, paths, graph));
2573
2574        assert_eq!(aggregate["count"], 2, "{aggregate:#}");
2575        assert!(aggregate_item(&aggregate, "src/dead.ts", "plantedDead").is_some());
2576        assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "barrelDead").is_some());
2577        assert!(aggregate_item(&aggregate, "src/api.ts", "testOnly").is_none());
2578        assert!(aggregate_item(&aggregate, "src/api.ts", "productUsed").is_none());
2579        assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "throughBarrel").is_none());
2580
2581        assert_eq!(aggregate["test_only_count"], 2, "{aggregate:#}");
2582        assert_eq!(
2583            aggregate_test_only_item(&aggregate, "src/api.ts", "testOnly")
2584                .and_then(|item| item["used_by"].as_array())
2585                .and_then(|items| items.first())
2586                .and_then(serde_json::Value::as_str),
2587            Some("api.test.ts")
2588        );
2589        assert_eq!(
2590            aggregate_test_only_item(&aggregate, "src/barrel-target.ts", "throughBarrel")
2591                .and_then(|item| item["used_by"].as_array())
2592                .and_then(|items| items.first())
2593                .and_then(serde_json::Value::as_str),
2594            Some("barrel.test.ts")
2595        );
2596    }
2597
2598    #[test]
2599    fn oxc_dead_code_test_file_edit_cached_rollup_matches_cold() {
2600        let (_temp_dir, root, paths) = fixture_project(&[
2601            (
2602                "src/api.ts",
2603                "export function testOnly() {}
2604export function plantedDead() {}
2605",
2606            ),
2607            (
2608                "src/api.test.ts",
2609                "import { testOnly } from './api';
2610testOnly();
2611",
2612            ),
2613        ]);
2614        let root = fs::canonicalize(root).expect("canonical project root");
2615        let paths = paths
2616            .into_iter()
2617            .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
2618            .collect::<Vec<_>>();
2619        let graph =
2620            snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), BTreeSet::new());
2621        let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
2622        assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
2623        assert_eq!(
2624            first.aggregate["test_only_count"], 1,
2625            "{:#}",
2626            first.aggregate
2627        );
2628
2629        fs::write(
2630            root.join("src/api.test.ts"),
2631            "console.log('import removed');
2632",
2633        )
2634        .expect("edit test file");
2635
2636        let cold = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
2637        let changed_test = scan_success_with_oxc(job(
2638            &root,
2639            vec![root.join("src/api.test.ts")],
2640            graph.clone(),
2641        ));
2642        let mut cached_contributions = first.contributions.clone();
2643        for changed in changed_test.contributions {
2644            let slot = cached_contributions
2645                .iter_mut()
2646                .find(|contribution| contribution.file_path == changed.file_path)
2647                .expect("cached test contribution exists");
2648            *slot = changed;
2649        }
2650        let roles = crate::inspect::entry_points::resolve_project_roles(&root);
2651        let rolled_up = aggregate_dead_code_contributions_with_snapshot(
2652            &root,
2653            &graph,
2654            &cached_contributions,
2655            &collect_public_api_files(&root),
2656            &roles,
2657            Some(MAX_DRILL_DOWN_ITEMS),
2658        );
2659
2660        assert_eq!(rolled_up, cold.aggregate);
2661        assert_eq!(rolled_up["count"], 2, "{rolled_up:#}");
2662        assert_eq!(rolled_up["test_only_count"], 0, "{rolled_up:#}");
2663    }
2664
2665    #[test]
2666    fn method_dispatched_by_receiver_call_is_live() {
2667        let (_temp_dir, root, paths) = fixture_project(&[
2668            ("src/service.ts", "export class Service { render() {} }\n"),
2669            (
2670                "src/consumer.ts",
2671                "function run(service: Service) { service.render(); }\n",
2672            ),
2673        ]);
2674        let aggregate = scan(job(
2675            &root,
2676            paths.clone(),
2677            snapshot(
2678                paths,
2679                vec![export(&root, "src/service.ts", "render", "method")],
2680                vec![outbound(
2681                    &root,
2682                    "src/consumer.ts",
2683                    "run",
2684                    &dispatched_target("render", "service.render"),
2685                )],
2686            ),
2687        ));
2688
2689        assert_eq!(aggregate["count"], 0);
2690        assert_eq!(aggregate["uncertain_count"], 0);
2691        assert!(aggregate["items"].as_array().unwrap().is_empty());
2692    }
2693
2694    #[test]
2695    fn method_without_any_dispatch_is_still_dead() {
2696        let (_temp_dir, root, paths) =
2697            fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
2698        let aggregate = scan(job(
2699            &root,
2700            paths.clone(),
2701            snapshot(
2702                paths,
2703                vec![export(&root, "src/service.ts", "render", "method")],
2704                Vec::new(),
2705            ),
2706        ));
2707
2708        assert_eq!(aggregate["count"], 1);
2709        assert_eq!(aggregate["items"][0]["symbol"], "render");
2710        assert_eq!(aggregate["uncertain_count"], 0);
2711    }
2712
2713    #[test]
2714    fn free_function_called_from_dispatch_live_method_body_is_live() {
2715        // Regression for the dead_code reachability bug: a free function reached
2716        // only through a method whose only caller is a receiver dispatch
2717        // (`obj.method()`) must NOT be reported dead. The method ("render") is
2718        // rescued from the dead list by dispatch-name, but liveness must also
2719        // flow THROUGH its body to the free function it calls ("helper").
2720        // Mirrors the real `BgTaskRegistry::spawn` -> `task_paths` case, where
2721        // `task_paths` had 33 callers yet was flagged dead because the BFS never
2722        // entered the dispatch-only method body. Method bodies are keyed by
2723        // scoped identity (`Service::render`) while exports are bare (`render`),
2724        // so the body edge is unreachable without seeding the scoped method node.
2725        let (_temp_dir, root, paths) = fixture_project(&[
2726            (
2727                "src/service.ts",
2728                "export class Service { render() { helper(); } }\n",
2729            ),
2730            ("src/helper.ts", "export function helper() {}\n"),
2731            (
2732                "src/consumer.ts",
2733                "function run(service: Service) { service.render(); }\n",
2734            ),
2735        ]);
2736        let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
2737        let aggregate = scan(job(
2738            &root,
2739            paths.clone(),
2740            snapshot(
2741                paths,
2742                vec![
2743                    export(&root, "src/service.ts", "render", "method"),
2744                    export(&root, "src/helper.ts", "helper", "function"),
2745                ],
2746                vec![
2747                    // The method's ONLY caller is a receiver dispatch — no
2748                    // resolvable edge into `Service::render`.
2749                    outbound(
2750                        &root,
2751                        "src/consumer.ts",
2752                        "run",
2753                        &dispatched_target("render", "service.render"),
2754                    ),
2755                    // The dispatch-only method body calls a free function. The
2756                    // caller identity is scoped (`Service::render`), the form the
2757                    // edge map uses for sources.
2758                    outbound(&root, "src/service.ts", "Service::render", &helper_target),
2759                ],
2760            ),
2761        ));
2762
2763        assert_eq!(
2764            aggregate["count"], 0,
2765            "free function reached via dispatch-live method body must be live: {aggregate:#}"
2766        );
2767        assert!(aggregate["items"].as_array().unwrap().is_empty());
2768    }
2769
2770    #[test]
2771    fn rust_struct_referenced_only_in_types_is_live() {
2772        let (_temp_dir, root, paths) = fixture_project(&[
2773            ("src/types.rs", "pub struct Widget { id: u64 }\n"),
2774            (
2775                "src/main.rs",
2776                "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
2777            ),
2778        ]);
2779        let aggregate = scan(job(
2780            &root,
2781            paths.clone(),
2782            snapshot_with_entry_points(
2783                paths,
2784                vec![
2785                    export(&root, "src/types.rs", "Widget", "struct"),
2786                    export(&root, "src/main.rs", "main", "function"),
2787                ],
2788                Vec::new(),
2789                BTreeSet::from([root.join("src/main.rs")]),
2790            ),
2791        ));
2792
2793        assert_eq!(aggregate["count"], 0);
2794        assert_eq!(aggregate["uncertain_count"], 0);
2795        assert!(aggregate["items"].as_array().unwrap().is_empty());
2796    }
2797
2798    #[test]
2799    fn ts_interface_referenced_only_in_type_annotation_is_live() {
2800        let (_temp_dir, root, paths) = fixture_project(&[
2801            ("src/types.ts", "export interface Widget { id: string }\n"),
2802            (
2803                "src/main.ts",
2804                "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
2805            ),
2806        ]);
2807        let aggregate = scan(job(
2808            &root,
2809            paths.clone(),
2810            snapshot_with_entry_points(
2811                paths,
2812                vec![
2813                    export(&root, "src/types.ts", "Widget", "interface"),
2814                    export(&root, "src/main.ts", "run", "function"),
2815                ],
2816                Vec::new(),
2817                BTreeSet::from([root.join("src/main.ts")]),
2818            ),
2819        ));
2820
2821        assert_eq!(aggregate["count"], 0);
2822        assert_eq!(aggregate["uncertain_count"], 0);
2823        assert!(aggregate["items"].as_array().unwrap().is_empty());
2824    }
2825
2826    #[test]
2827    fn type_like_export_without_call_or_type_ref_is_precise_dead() {
2828        let (_temp_dir, root, paths) =
2829            fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
2830        let aggregate = scan(job(
2831            &root,
2832            paths.clone(),
2833            snapshot(
2834                paths,
2835                vec![export(&root, "src/types.ts", "Widget", "interface")],
2836                Vec::new(),
2837            ),
2838        ));
2839
2840        assert_eq!(aggregate["count"], 1);
2841        assert_eq!(aggregate["items"][0]["symbol"], "Widget");
2842        assert_eq!(aggregate["uncertain_count"], 0);
2843        assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
2844    }
2845
2846    #[test]
2847    fn rust_attribute_entry_points_seed_command_liveness() {
2848        let (_temp_dir, root, paths) = fixture_project(&[
2849            (
2850                "src/commands.rs",
2851                r#"use crate::db;
2852
2853#[tauri::command]
2854pub fn get_primers() -> String {
2855    db::helper()
2856}
2857
2858pub fn planted_dead() -> String {
2859    "dead".to_string()
2860}
2861
2862#[tauri::command]
2863fn private_command() -> String {
2864    db::private_helper()
2865}
2866"#,
2867            ),
2868            (
2869                "src/imported.rs",
2870                r#"use crate::db;
2871use tauri::command;
2872
2873#[command]
2874pub fn imported_command() -> String {
2875    db::imported_helper()
2876}
2877"#,
2878            ),
2879            (
2880                "src/unimported.rs",
2881                r#"use crate::db;
2882
2883#[command]
2884pub fn false_command() -> String {
2885    db::false_helper()
2886}
2887"#,
2888            ),
2889            (
2890                "src/db.rs",
2891                r#"pub fn helper() -> String { "live".to_string() }
2892pub fn imported_helper() -> String { "live".to_string() }
2893pub fn private_helper() -> String { "live".to_string() }
2894pub fn false_helper() -> String { "dead".to_string() }
2895"#,
2896            ),
2897        ]);
2898        let helper_target = format!("{}::helper", root.join("src/db.rs").display());
2899        let imported_helper_target =
2900            format!("{}::imported_helper", root.join("src/db.rs").display());
2901        let private_helper_target = format!("{}::private_helper", root.join("src/db.rs").display());
2902        let false_helper_target = format!("{}::false_helper", root.join("src/db.rs").display());
2903        let aggregate = scan(job(
2904            &root,
2905            paths.clone(),
2906            snapshot(
2907                paths,
2908                vec![
2909                    export(&root, "src/commands.rs", "get_primers", "function"),
2910                    export(&root, "src/commands.rs", "planted_dead", "function"),
2911                    export(&root, "src/imported.rs", "imported_command", "function"),
2912                    export(&root, "src/unimported.rs", "false_command", "function"),
2913                    export(&root, "src/db.rs", "helper", "function"),
2914                    export(&root, "src/db.rs", "imported_helper", "function"),
2915                    export(&root, "src/db.rs", "private_helper", "function"),
2916                    export(&root, "src/db.rs", "false_helper", "function"),
2917                ],
2918                vec![
2919                    outbound(&root, "src/commands.rs", "get_primers", &helper_target),
2920                    outbound(
2921                        &root,
2922                        "src/imported.rs",
2923                        "imported_command",
2924                        &imported_helper_target,
2925                    ),
2926                    outbound(
2927                        &root,
2928                        "src/commands.rs",
2929                        "private_command",
2930                        &private_helper_target,
2931                    ),
2932                    outbound(
2933                        &root,
2934                        "src/unimported.rs",
2935                        "false_command",
2936                        &false_helper_target,
2937                    ),
2938                ],
2939            ),
2940        ));
2941
2942        assert!(!aggregate_has_item(
2943            &aggregate,
2944            "src/commands.rs",
2945            "get_primers"
2946        ));
2947        assert!(!aggregate_has_item(&aggregate, "src/db.rs", "helper"));
2948        assert!(!aggregate_has_item(
2949            &aggregate,
2950            "src/imported.rs",
2951            "imported_command"
2952        ));
2953        assert!(!aggregate_has_item(
2954            &aggregate,
2955            "src/db.rs",
2956            "imported_helper"
2957        ));
2958        assert!(!aggregate_has_item(
2959            &aggregate,
2960            "src/db.rs",
2961            "private_helper"
2962        ));
2963        assert!(aggregate_has_item(
2964            &aggregate,
2965            "src/commands.rs",
2966            "planted_dead"
2967        ));
2968        assert!(aggregate_has_item(
2969            &aggregate,
2970            "src/unimported.rs",
2971            "false_command"
2972        ));
2973        assert!(aggregate_has_item(&aggregate, "src/db.rs", "false_helper"));
2974    }
2975
2976    #[test]
2977    fn genuinely_unreachable_function_is_still_dead() {
2978        let (_temp_dir, root, paths) =
2979            fixture_project(&[("src/build.ts", "export function build() {}\n")]);
2980        let aggregate = scan(job(
2981            &root,
2982            paths.clone(),
2983            snapshot(
2984                paths,
2985                vec![export(&root, "src/build.ts", "build", "function")],
2986                Vec::new(),
2987            ),
2988        ));
2989
2990        assert_eq!(aggregate["count"], 1);
2991        assert_eq!(aggregate["items"][0]["symbol"], "build");
2992        assert_eq!(aggregate["uncertain_count"], 0);
2993    }
2994}