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