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::{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 = 3;
33const MACRO_TOKEN_LIVENESS_PROVENANCE: &str = "macro_token_liveness";
34const RUST_MACRO_REF_SHAPE_CALL: &str = "call";
35const RUST_MACRO_REF_SHAPE_METHOD: &str = "method";
36const RUST_MACRO_REF_SHAPE_STRUCT: &str = "struct";
37const TOP_LEVEL_SYMBOL: &str = "<top-level>";
38
39type ExportNode = (String, String);
40type OutboundCallsByCallerFile<'a> = BTreeMap<PathBuf, Vec<&'a CallgraphOutboundCall>>;
41type MethodNamesByLanguage = BTreeMap<String, BTreeSet<String>>;
42
43#[derive(Debug, Default)]
44struct ImportedExportLiveness {
45    root_exports: Vec<ImportedExportContribution>,
46    namespace_exports: Vec<ImportedExportContribution>,
47}
48
49#[derive(Debug, Default)]
50struct FileAnalysis {
51    raw_imports: Vec<RawImportContribution>,
52    rust_imports: Vec<RawImportContribution>,
53    raw_reexports: Vec<RawReexportContribution>,
54    attribute_entry_points: Vec<String>,
55    macro_token_refs: Vec<MacroTokenRefContribution>,
56    type_ref_names: BTreeSet<String>,
57}
58
59#[derive(Debug, Clone)]
60struct RustMacroToken<'a> {
61    text: &'a str,
62    kind: &'a str,
63    line: u32,
64}
65
66#[derive(Debug, Clone)]
67struct RustImportedSymbolSpec {
68    local_name: String,
69    module_segments: Vec<String>,
70    imported_name: String,
71}
72
73#[derive(Default)]
74struct DeadCodeFileAnalyzer {
75    parsers: HashMap<LangId, tree_sitter::Parser>,
76}
77
78#[derive(Debug, Serialize)]
79struct OxcDeadCodeFactsPayload<'a> {
80    format_version: u32,
81    content_hash: &'a str,
82    exports: &'a [ExportFact],
83    imports: &'a [ImportFact],
84    re_exports: &'a [ReExportFact],
85    dynamic_imports: &'a [DynamicImportFact],
86    same_file_value_references: &'a BTreeSet<String>,
87    used_import_bindings: &'a BTreeSet<String>,
88    type_referenced_import_bindings: &'a BTreeSet<String>,
89    value_referenced_import_bindings: &'a BTreeSet<String>,
90    parse_error: &'a Option<String>,
91}
92
93impl DeadCodeFileAnalyzer {
94    fn analyze_file(&mut self, file: &Path, has_oxc_file: bool) -> FileAnalysis {
95        let Some(lang) = detect_language(file) else {
96            return FileAnalysis::default();
97        };
98        let needs_type_refs = supports_type_refs(lang);
99        let is_ts_js = matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript);
100        // Oxc FileFacts are the raw TS/JS import/re-export/dynamic-import facts.
101        // Only the legacy non-oxc TS/JS path needs tree-sitter import/re-export facts here.
102        let needs_ts_raw_facts = is_ts_js && !has_oxc_file;
103        let needs_rust_reexports = matches!(lang, LangId::Rust);
104        let needs_rust_attribute_entry_points = matches!(lang, LangId::Rust);
105        let needs_rust_macro_token_refs = matches!(lang, LangId::Rust);
106
107        if !needs_type_refs
108            && !needs_ts_raw_facts
109            && !needs_rust_reexports
110            && !needs_rust_attribute_entry_points
111            && !needs_rust_macro_token_refs
112        {
113            return FileAnalysis::default();
114        }
115
116        let Ok(source) = fs::read_to_string(file) else {
117            return FileAnalysis::default();
118        };
119        let needs_tree = needs_type_refs
120            || needs_ts_raw_facts
121            || needs_rust_attribute_entry_points
122            || needs_rust_macro_token_refs;
123        let tree = needs_tree
124            .then(|| self.parse_source(lang, &source))
125            .flatten();
126
127        let type_ref_names = if needs_type_refs {
128            tree.as_ref()
129                .map(|tree| extract_type_references(&source, tree.root_node(), lang))
130                .unwrap_or_default()
131        } else {
132            BTreeSet::new()
133        };
134
135        let raw_imports = if needs_ts_raw_facts {
136            tree.as_ref()
137                .map(|tree| raw_imports_from_tree(&source, tree, lang))
138                .unwrap_or_default()
139        } else {
140            Vec::new()
141        };
142
143        let rust_imports = if needs_rust_macro_token_refs {
144            tree.as_ref()
145                .map(|tree| rust_raw_import_contributions(&source, tree))
146                .unwrap_or_default()
147        } else {
148            Vec::new()
149        };
150
151        let raw_reexports = if needs_ts_raw_facts {
152            tree.as_ref()
153                .map(|tree| ts_raw_reexport_contributions(&source, tree.root_node()))
154                .unwrap_or_default()
155        } else if needs_rust_reexports {
156            rust_raw_reexport_contributions(&source)
157        } else {
158            Vec::new()
159        };
160
161        let attribute_entry_points = if needs_rust_attribute_entry_points {
162            tree.as_ref()
163                .map(|tree| {
164                    let mut roots = BTreeSet::new();
165                    for entry in
166                        crate::parser::rust_attribute_entry_points(&source, tree.root_node())
167                    {
168                        roots.insert(entry.name);
169                        roots.insert(entry.scoped_name);
170                    }
171                    roots.into_iter().collect()
172                })
173                .unwrap_or_default()
174        } else {
175            Vec::new()
176        };
177
178        let macro_token_refs = if needs_rust_macro_token_refs {
179            tree.as_ref()
180                .map(|tree| rust_macro_token_refs(&source, tree.root_node()))
181                .unwrap_or_default()
182        } else {
183            Vec::new()
184        };
185
186        FileAnalysis {
187            raw_imports,
188            rust_imports,
189            raw_reexports,
190            attribute_entry_points,
191            macro_token_refs,
192            type_ref_names,
193        }
194    }
195
196    fn parse_source(&mut self, lang: LangId, source: &str) -> Option<tree_sitter::Tree> {
197        let parser = match self.parsers.entry(lang) {
198            Entry::Occupied(entry) => entry.into_mut(),
199            Entry::Vacant(entry) => {
200                let grammar = grammar_for(lang);
201                let mut parser = tree_sitter::Parser::new();
202                if parser.set_language(&grammar).is_err() {
203                    return None;
204                }
205                entry.insert(parser)
206            }
207        };
208
209        parser.parse(source, None)
210    }
211}
212
213pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
214    run_dead_code_scan_with_oxc_started(job, None, Instant::now())
215}
216
217pub(crate) fn run_dead_code_scan_with_oxc(
218    job: &InspectJob,
219    oxc_result: Option<&OxcEngineResult>,
220) -> InspectResult {
221    run_dead_code_scan_with_oxc_started(job, oxc_result, Instant::now())
222}
223
224fn run_dead_code_scan_with_oxc_started(
225    job: &InspectJob,
226    oxc_result: Option<&OxcEngineResult>,
227    started: Instant,
228) -> InspectResult {
229    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
230        let success = InspectScanSuccess {
231            scanned_files: job.scope_files.clone(),
232            contributions: Vec::new(),
233            aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
234        };
235        return InspectResult::success(job, success, started.elapsed());
236    };
237
238    let fallback_exports_by_file = fallback_export_contributions_by_file(job, snapshot);
239    let oxc_facts_by_file = oxc_result
240        .map(|result| {
241            result
242                .facts
243                .iter()
244                .cloned()
245                .map(|facts| (relative_path(&job.project_root, &facts.path), facts))
246                .collect::<BTreeMap<_, _>>()
247        })
248        .unwrap_or_default();
249    let oxc_parse_errors_by_file = oxc_result
250        .map(|result| {
251            result.errors.iter().fold(
252                BTreeMap::<String, Vec<String>>::new(),
253                |mut errors, error| {
254                    errors
255                        .entry(relative_path(&job.project_root, &error.file))
256                        .or_default()
257                        .push(error.message.clone());
258                    errors
259                },
260            )
261        })
262        .unwrap_or_default();
263    let oxc_skipped_files = oxc_result
264        .map(|result| oxc_skipped_files_payload(&job.project_root, result))
265        .unwrap_or_default();
266
267    let contributions = job
268        .scope_files
269        .par_iter()
270        .map_init(DeadCodeFileAnalyzer::default, |file_analyzer, file| {
271            gather_file_contribution(
272                job,
273                file,
274                &fallback_exports_by_file,
275                &oxc_facts_by_file,
276                &oxc_parse_errors_by_file,
277                &oxc_skipped_files,
278                file_analyzer,
279            )
280        })
281        .collect::<Vec<_>>();
282
283    let public_api_files = collect_public_api_files(&job.project_root);
284    let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
285    let aggregate = aggregate_dead_code_contributions_with_snapshot(
286        &job.project_root,
287        snapshot,
288        &contributions,
289        &public_api_files,
290        &roles,
291        Some(MAX_DRILL_DOWN_ITEMS),
292    );
293    let success = InspectScanSuccess {
294        scanned_files: job.scope_files.clone(),
295        contributions,
296        aggregate,
297    };
298
299    InspectResult::success(job, success, started.elapsed())
300}
301
302fn fallback_export_contributions_by_file(
303    job: &InspectJob,
304    snapshot: &CallgraphSnapshot,
305) -> BTreeMap<String, Vec<ExportContribution>> {
306    let mut by_file: BTreeMap<String, Vec<ExportContribution>> = BTreeMap::new();
307    for export in &snapshot.exported_symbols {
308        if export.kind == DEFAULT_EXPORT_MARKER_KIND {
309            continue;
310        }
311        by_file
312            .entry(relative_path(&job.project_root, &export.file))
313            .or_default()
314            .push(ExportContribution {
315                symbol: export.symbol.clone(),
316                kind: export.kind.clone(),
317                line: export.line,
318                is_type_like: is_type_like_kind(&export.kind),
319                is_entry_point: false,
320                has_references: false,
321                test_only_reference_files: Vec::new(),
322                verdict: None,
323                reason: None,
324                provenance: None,
325                also_reexported: Vec::new(),
326            });
327    }
328    by_file
329}
330
331fn group_outbound_calls_by_caller_file<'a>(
332    project_root: &Path,
333    outbound_calls: &'a [CallgraphOutboundCall],
334) -> OutboundCallsByCallerFile<'a> {
335    let mut by_file: OutboundCallsByCallerFile<'a> = BTreeMap::new();
336    for call in outbound_calls {
337        by_file
338            .entry(normalize_absolute(project_root, &call.caller_file))
339            .or_default()
340            .push(call);
341    }
342    by_file
343}
344
345fn gather_file_contribution(
346    job: &InspectJob,
347    file: &Path,
348    fallback_exports_by_file: &BTreeMap<String, Vec<ExportContribution>>,
349    oxc_facts_by_file: &BTreeMap<String, FileFacts>,
350    oxc_parse_errors_by_file: &BTreeMap<String, Vec<String>>,
351    oxc_skipped_files: &[Value],
352    file_analyzer: &mut DeadCodeFileAnalyzer,
353) -> FileContribution {
354    let file_name = relative_path(&job.project_root, file);
355    let generated = crate::inspect::generated::is_generated_file(&job.project_root, file);
356    if let Some(language) = dead_code_skipped_language(file) {
357        return FileContribution::new(
358            InspectCategory::DeadCode,
359            file.to_path_buf(),
360            collect_freshness(file),
361            json!({
362                "file": file_name,
363                "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
364                "generated": generated,
365                "exports": [],
366                "skipped_languages": [language],
367            }),
368        );
369    }
370
371    let oxc_facts = oxc_facts_by_file.get(&file_name);
372    let exports = oxc_facts
373        .map(oxc_fact_export_contributions)
374        .unwrap_or_else(|| {
375            fallback_exports_by_file
376                .get(&file_name)
377                .cloned()
378                .unwrap_or_default()
379        });
380    let FileAnalysis {
381        raw_imports,
382        rust_imports,
383        raw_reexports,
384        attribute_entry_points,
385        macro_token_refs,
386        type_ref_names,
387    } = file_analyzer.analyze_file(file, oxc_facts.is_some());
388
389    let mut payload = json!({
390        "file": file_name,
391        "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
392        "generated": generated,
393        "exports": exports
394            .iter()
395            .map(|export| {
396                let mut value = json!({
397                    "symbol": export.symbol,
398                    "kind": export.kind,
399                    "line": export.line,
400                });
401                if export.is_type_like {
402                    value["is_type_like"] = json!(true);
403                }
404                value
405            })
406            .collect::<Vec<_>>(),
407    });
408
409    if !raw_imports.is_empty() {
410        payload["raw_imports"] = json!(raw_imports);
411    }
412    if !raw_reexports.is_empty() {
413        payload["raw_reexports"] = json!(raw_reexports);
414    }
415    if !rust_imports.is_empty() {
416        payload["rust_imports"] = json!(rust_imports);
417    }
418    if !macro_token_refs.is_empty() {
419        payload["macro_token_refs"] = json!(macro_token_refs);
420    }
421    if !attribute_entry_points.is_empty() {
422        payload["attribute_entry_points"] = json!(attribute_entry_points);
423    }
424    if let Some(facts) = oxc_facts {
425        payload["provenance"] = json!(OXC_PROVENANCE);
426        payload["oxc_facts"] = json!(OxcDeadCodeFactsPayload {
427            format_version: FACTS_FORMAT_VERSION,
428            content_hash: &facts.content_hash,
429            exports: &facts.exports,
430            imports: &facts.imports,
431            re_exports: &facts.re_exports,
432            dynamic_imports: &facts.dynamic_imports,
433            same_file_value_references: &facts.same_file_value_references,
434            used_import_bindings: &facts.used_import_bindings,
435            type_referenced_import_bindings: &facts.type_referenced_import_bindings,
436            value_referenced_import_bindings: &facts.value_referenced_import_bindings,
437            parse_error: &facts.parse_error,
438        });
439    }
440    if let Some(parse_errors) = oxc_parse_errors_by_file.get(&file_name) {
441        payload["parse_errors"] = json!(parse_errors
442            .iter()
443            .map(|message| json!({
444                "file": file_name,
445                "message": message,
446            }))
447            .collect::<Vec<_>>());
448    }
449    if oxc_facts.is_some() && !oxc_skipped_files.is_empty() {
450        payload["skipped_files"] = Value::Array(oxc_skipped_files.to_vec());
451    }
452
453    FileContribution::new(
454        InspectCategory::DeadCode,
455        file.to_path_buf(),
456        collect_freshness(file),
457        payload,
458    )
459    .with_type_ref_names(type_ref_names)
460}
461
462fn oxc_fact_export_contributions(facts: &FileFacts) -> Vec<ExportContribution> {
463    facts
464        .exports
465        .iter()
466        .map(|export| ExportContribution {
467            symbol: export.name.as_symbol(),
468            kind: export.kind.clone(),
469            line: export.line,
470            is_type_like: export.is_type_only || is_type_like_kind(&export.kind),
471            is_entry_point: false,
472            has_references: false,
473            test_only_reference_files: Vec::new(),
474            verdict: None,
475            reason: None,
476            provenance: None,
477            also_reexported: Vec::new(),
478        })
479        .collect()
480}
481
482fn oxc_export_contributions(file: &OxcFileVerdicts) -> Vec<ExportContribution> {
483    file.exports
484        .iter()
485        .map(|export| ExportContribution {
486            symbol: export.symbol.clone(),
487            kind: export.kind.clone(),
488            line: export.line,
489            is_type_like: is_type_like_kind(&export.kind),
490            is_entry_point: matches!(export.verdict, LivenessVerdict::Used),
491            has_references: export.has_references,
492            test_only_reference_files: export.test_only_reference_files.clone(),
493            verdict: Some(export.verdict),
494            reason: Some(export.reason.clone()),
495            provenance: Some(export.provenance.clone()),
496            also_reexported: export.also_reexported.clone(),
497        })
498        .collect()
499}
500
501fn oxc_skipped_files_payload(project_root: &Path, oxc_result: &OxcEngineResult) -> Vec<Value> {
502    oxc_result
503        .skipped_outside_root
504        .iter()
505        .map(|path| {
506            json!({
507                "file": relative_path(project_root, path),
508                "reason": "outside_project_root",
509            })
510        })
511        .collect()
512}
513
514pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
515    json!({
516        "count": 0,
517        "items": [],
518        "by_language": {},
519        "languages_skipped": [],
520        "drill_down_capped": false,
521        "uncertain_count": 0,
522        "uncertain_items": [],
523        "callgraph_available": false,
524        "scanned_files": scanned_files,
525        "notes": ["callgraph_unavailable"],
526    })
527}
528
529pub(crate) fn aggregate_dead_code_contributions_with_snapshot(
530    project_root: &Path,
531    snapshot: &CallgraphSnapshot,
532    contributions: &[FileContribution],
533    public_api_files: &BTreeSet<String>,
534    roles: &crate::inspect::entry_points::ProjectRoles,
535    drill_down_limit: Option<usize>,
536) -> serde_json::Value {
537    let parsed = parse_dead_code_contributions(contributions);
538    let materialized =
539        materialize_dead_code_contributions(project_root, snapshot, parsed, public_api_files);
540    aggregate_materialized_dead_code_contributions(
541        project_root,
542        &materialized,
543        public_api_files,
544        roles,
545        drill_down_limit,
546        contributions.len(),
547    )
548}
549
550fn parse_dead_code_contributions(contributions: &[FileContribution]) -> Vec<DeadCodeContribution> {
551    contributions
552        .iter()
553        .filter_map(|contribution| {
554            serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
555        })
556        .collect::<Vec<_>>()
557}
558
559fn materialize_dead_code_contributions(
560    project_root: &Path,
561    snapshot: &CallgraphSnapshot,
562    parsed: Vec<DeadCodeContribution>,
563    public_api_files: &BTreeSet<String>,
564) -> Vec<DeadCodeContribution> {
565    let liveness_root_files = snapshot
566        .entry_points
567        .iter()
568        .map(|file| relative_path(project_root, file))
569        .collect::<BTreeSet<_>>();
570    let executable_root_exports_by_file =
571        crate::inspect::entry_points::resolve_entry_points(project_root)
572            .executable_root_exports()
573            .into_iter()
574            .map(|(file, exports)| (relative_path(project_root, &file), exports))
575            .collect::<BTreeMap<_, _>>();
576    let attribute_roots_from_snapshot = snapshot
577        .entry_point_symbols
578        .iter()
579        .map(|(file, symbols)| (relative_path(project_root, file), symbols.clone()))
580        .collect::<BTreeMap<_, _>>();
581    let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
582        exported_symbol_indexes_from_contributions(project_root, snapshot, &parsed);
583    let outbound_calls_by_caller_file =
584        group_outbound_calls_by_caller_file(project_root, &snapshot.outbound_calls);
585    let oxc_by_file = oxc_verdicts_by_file(project_root, snapshot, &parsed, public_api_files);
586
587    parsed
588        .into_iter()
589        .map(|mut contribution| {
590            let _facts_format_version = contribution.facts_format_version;
591            let absolute_file = project_root.join(&contribution.file);
592            let normalized_file = normalize_absolute(project_root, &absolute_file);
593            let outbound_calls_for_file = outbound_calls_by_caller_file
594                .get(&normalized_file)
595                .map(Vec::as_slice)
596                .unwrap_or(&[]);
597            let mut exports = oxc_by_file
598                .get(&contribution.file)
599                .map(oxc_export_contributions)
600                .unwrap_or_else(|| contribution.exports.clone());
601
602            let mut internal_calls = outbound_calls_for_file
603                .iter()
604                .copied()
605                .filter_map(|call| {
606                    project_internal_call(
607                        project_root,
608                        call,
609                        &contribution.file,
610                        &exported_symbols_by_file,
611                        &files_by_exported_symbol,
612                    )
613                })
614                .collect::<Vec<_>>();
615            internal_calls.extend(resolve_raw_reexport_liveness_edges(
616                project_root,
617                &contribution.file,
618                &contribution.raw_reexports,
619                &exported_symbols_by_file,
620                &default_export_symbols_by_file,
621            ));
622            if let Some(oxc_facts) = &contribution.oxc_facts {
623                internal_calls.extend(resolve_oxc_reexport_liveness_edges(
624                    project_root,
625                    &contribution.file,
626                    oxc_facts,
627                    &exported_symbols_by_file,
628                    &default_export_symbols_by_file,
629                ));
630            }
631            internal_calls.extend(resolve_macro_token_liveness_edges(
632                project_root,
633                &contribution.file,
634                &contribution.macro_token_refs,
635                &contribution.rust_imports,
636                &exported_symbols_by_file,
637            ));
638            sort_dedup_internal_calls(&mut internal_calls);
639
640            let dispatched_method_names = outbound_calls_for_file
641                .iter()
642                .copied()
643                .flat_map(|call| dispatched_method_names_from_call(call, &contribution.file))
644                .collect::<BTreeSet<_>>()
645                .into_iter()
646                .collect::<Vec<_>>();
647            let imported_export_liveness = resolve_raw_imported_export_liveness_roots(
648                project_root,
649                &contribution.file,
650                &contribution.raw_imports,
651                &exported_symbols_by_file,
652                &default_export_symbols_by_file,
653            );
654            let mut attribute_entry_points = contribution
655                .attribute_entry_points
656                .iter()
657                .cloned()
658                .collect::<BTreeSet<_>>();
659            if let Some(snapshot_roots) = attribute_roots_from_snapshot.get(&contribution.file) {
660                attribute_entry_points.extend(snapshot_roots.iter().cloned());
661            }
662            let liveness_roots = liveness_roots_for_file(
663                &contribution.file,
664                &exports,
665                &internal_calls,
666                &attribute_entry_points,
667                executable_root_exports_by_file.get(&contribution.file),
668                liveness_root_files.contains(&contribution.file),
669                public_api_files.contains(&contribution.file),
670            );
671            for export in &mut exports {
672                export.is_entry_point = liveness_roots.contains(&export.symbol);
673            }
674
675            contribution.exports = exports;
676            contribution.internal_calls = internal_calls
677                .into_iter()
678                .map(InternalCallContribution::from)
679                .collect();
680            contribution.liveness_roots = liveness_roots;
681            contribution.imported_exports = imported_export_liveness.root_exports;
682            contribution.namespace_imported_exports = imported_export_liveness.namespace_exports;
683            contribution.dispatched_method_names = dispatched_method_names;
684            contribution
685        })
686        .collect()
687}
688
689fn exported_symbol_indexes_from_contributions(
690    project_root: &Path,
691    snapshot: &CallgraphSnapshot,
692    contributions: &[DeadCodeContribution],
693) -> (
694    BTreeMap<String, BTreeSet<String>>,
695    BTreeMap<String, BTreeSet<String>>,
696    BTreeMap<String, String>,
697) {
698    let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
699    let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
700    let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
701
702    for contribution in contributions {
703        for export in &contribution.exports {
704            exported_symbols_by_file
705                .entry(contribution.file.clone())
706                .or_default()
707                .insert(export.symbol.clone());
708            files_by_exported_symbol
709                .entry(export.symbol.clone())
710                .or_default()
711                .insert(contribution.file.clone());
712        }
713    }
714
715    for export in &snapshot.exported_symbols {
716        let file = relative_path(project_root, &export.file);
717        if export.kind == DEFAULT_EXPORT_MARKER_KIND {
718            default_export_symbols_by_file.insert(file, export.symbol.clone());
719        }
720    }
721
722    (
723        exported_symbols_by_file,
724        files_by_exported_symbol,
725        default_export_symbols_by_file,
726    )
727}
728
729fn oxc_verdicts_by_file(
730    project_root: &Path,
731    snapshot: &CallgraphSnapshot,
732    contributions: &[DeadCodeContribution],
733    public_api_files: &BTreeSet<String>,
734) -> BTreeMap<String, OxcFileVerdicts> {
735    let facts = contributions
736        .iter()
737        .filter_map(|contribution| {
738            let oxc_facts = contribution.oxc_facts.as_ref()?;
739            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
740                return None;
741            }
742            Some(FileFacts {
743                file_id: FileId(0),
744                path: canonical_or_normalized(project_root, &project_root.join(&contribution.file)),
745                content_hash: oxc_facts.content_hash.clone(),
746                exports: oxc_facts.exports.clone(),
747                imports: oxc_facts.imports.clone(),
748                re_exports: oxc_facts.re_exports.clone(),
749                dynamic_imports: oxc_facts.dynamic_imports.clone(),
750                same_file_value_references: oxc_facts.same_file_value_references.clone(),
751                used_import_bindings: oxc_facts.used_import_bindings.clone(),
752                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
753                value_referenced_import_bindings: oxc_facts
754                    .value_referenced_import_bindings
755                    .clone(),
756                parse_error: oxc_facts.parse_error.clone(),
757            })
758        })
759        .collect::<Vec<_>>();
760    if facts.is_empty() {
761        return BTreeMap::new();
762    }
763
764    let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
765    analyze_file_facts(
766        project_root,
767        facts,
768        AnalyzeOptions {
769            entry_points: snapshot.entry_points.iter().cloned().collect(),
770            public_api_files: public_api_files
771                .iter()
772                .map(|file| project_root.join(file))
773                .collect(),
774            executable_root_exports: entry_points.executable_root_exports(),
775            force_reparse_files: Vec::new(),
776            entry_reachability: true,
777        },
778        Vec::new(),
779    )
780    .files
781    .into_iter()
782    .map(|file| (file.relative_file.clone(), file))
783    .collect()
784}
785
786fn sort_dedup_internal_calls(internal_calls: &mut Vec<InternalCall>) {
787    internal_calls.sort_by(|left, right| {
788        left.caller_symbol
789            .cmp(&right.caller_symbol)
790            .then_with(|| left.file.cmp(&right.file))
791            .then_with(|| left.symbol.cmp(&right.symbol))
792            .then_with(|| left.line.cmp(&right.line))
793            .then_with(|| left.provenance.cmp(&right.provenance))
794    });
795    internal_calls.dedup_by(|left, right| {
796        left.caller_symbol == right.caller_symbol
797            && left.file == right.file
798            && left.symbol == right.symbol
799            && left.line == right.line
800            && left.provenance == right.provenance
801    });
802}
803
804fn aggregate_materialized_dead_code_contributions(
805    project_root: &Path,
806    parsed: &[DeadCodeContribution],
807    public_api_files: &BTreeSet<String>,
808    roles: &crate::inspect::entry_points::ProjectRoles,
809    drill_down_limit: Option<usize>,
810    scanned_files: usize,
811) -> serde_json::Value {
812    let edges_by_source = edges_by_source(parsed);
813    let dispatched_method_names = collect_dispatched_method_names_by_language(parsed);
814    let reachable = reachable_exports(parsed, &edges_by_source, &dispatched_method_names);
815    let referenced_type_names = collect_referenced_type_names(parsed);
816
817    let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
818    let mut count = 0usize;
819    let mut headline_items = Vec::new();
820    let mut generated_count = 0usize;
821    let mut generated_items = Vec::new();
822    let mut test_only_count = 0usize;
823    let mut test_only_items = Vec::new();
824    let mut uncertain_count = 0usize;
825    let mut uncertain_items: Vec<serde_json::Value> = Vec::new();
826    for contribution in parsed {
827        let generated_file = crate::inspect::generated::is_generated_file_with_cached_hint(
828            project_root,
829            &contribution.file,
830            contribution.generated,
831        );
832        // Test-support files (fixtures, corpora, mock data) are consumed by
833        // path, never imported, so their exports always look dead. Skip
834        // REPORTING them — their edges already kept real code live above.
835        if is_test_support_file(&contribution.file) {
836            continue;
837        }
838        let is_public_api_file = public_api_files.contains(&contribution.file);
839        for export in &contribution.exports {
840            if export_uses_oxc(export) {
841                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
842                    LivenessVerdict::Used => {
843                        if !is_test_file(&contribution.file)
844                            && !export.test_only_reference_files.is_empty()
845                        {
846                            let mut item = json!({
847                                "file": contribution.file,
848                                "symbol": export.symbol,
849                                "kind": export.kind,
850                                "line": export.line,
851                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
852                                "used_by": export.test_only_reference_files,
853                            });
854                            add_reexport_contexts(&mut item, &export.also_reexported);
855                            if generated_file {
856                                item["generated"] = json!(true);
857                                generated_count += 1;
858                                generated_items.push(item);
859                            } else {
860                                test_only_count += 1;
861                                test_only_items.push(item);
862                            }
863                        }
864                        continue;
865                    }
866                    LivenessVerdict::Uncertain => {
867                        uncertain_count += 1;
868                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
869                            let mut item = json!({
870                                "file": contribution.file,
871                                "symbol": export.symbol,
872                                "kind": export.kind,
873                                "line": export.line,
874                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
875                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
876                            });
877                            add_reexport_contexts(&mut item, &export.also_reexported);
878                            uncertain_items.push(item);
879                        }
880                        continue;
881                    }
882                    LivenessVerdict::Unused => {
883                        if !is_test_file(&contribution.file)
884                            && !export.test_only_reference_files.is_empty()
885                        {
886                            let mut item = json!({
887                                "file": contribution.file,
888                                "symbol": export.symbol,
889                                "kind": export.kind,
890                                "line": export.line,
891                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
892                                "used_by": export.test_only_reference_files,
893                            });
894                            add_reexport_contexts(&mut item, &export.also_reexported);
895                            if generated_file {
896                                item["generated"] = json!(true);
897                                generated_count += 1;
898                                generated_items.push(item);
899                            } else {
900                                test_only_count += 1;
901                                test_only_items.push(item);
902                            }
903                            continue;
904                        }
905                        if export.has_references {
906                            continue;
907                        }
908                    }
909                }
910            } else {
911                let node = (contribution.file.clone(), export.symbol.clone());
912                if reachable.contains(&node)
913                    || is_public_api_file
914                    || dispatch_liveness_keeps_export_live(
915                        contribution,
916                        export,
917                        &dispatched_method_names,
918                    )
919                {
920                    continue;
921                }
922
923                if (export.is_type_like || is_type_like_kind(&export.kind))
924                    && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
925                {
926                    continue;
927                }
928            }
929
930            let mut item = json!({
931                "file": contribution.file,
932                "symbol": export.symbol,
933                "kind": export.kind,
934                "line": export.line,
935            });
936            if let Some(provenance) = &export.provenance {
937                item["provenance"] = json!(provenance);
938            }
939            add_reexport_contexts(&mut item, &export.also_reexported);
940            if generated_file {
941                item["generated"] = json!(true);
942                generated_count += 1;
943                generated_items.push(item);
944            } else {
945                count += 1;
946                *by_language
947                    .entry(language_for_file(&contribution.file).to_string())
948                    .or_default() += 1;
949                headline_items.push(item);
950            }
951        }
952    }
953
954    let headline_items = crate::inspect::entry_points::rank_and_truncate_items(
955        headline_items,
956        roles,
957        drill_down_limit,
958    );
959    let generated_items = crate::inspect::entry_points::rank_and_truncate_items(
960        generated_items,
961        roles,
962        drill_down_limit,
963    );
964    let top = crate::inspect::entry_points::top_preview_symbols(&headline_items);
965    let mut dead_items = headline_items;
966    dead_items.extend(generated_items.iter().cloned());
967    if let Some(limit) = drill_down_limit {
968        dead_items.truncate(limit);
969    }
970    let generated_top = generated_items
971        .iter()
972        .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
973        .cloned()
974        .collect::<Vec<_>>();
975    let test_only_items = crate::inspect::entry_points::rank_and_truncate_items(
976        test_only_items,
977        roles,
978        drill_down_limit,
979    );
980    let test_only_top = test_only_items
981        .iter()
982        .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
983        .cloned()
984        .collect::<Vec<_>>();
985
986    let (parse_errors, skipped_files, languages_skipped) = dead_code_honesty_fields(parsed);
987    let mut aggregate = json!({
988        "count": count,
989        "generated_count": generated_count,
990        "total_count": count + test_only_count + generated_count,
991        "items": dead_items,
992        "top": top,
993        "generated_items": generated_items,
994        "generated_top": generated_top,
995        "test_only_count": test_only_count,
996        "test_only_items": test_only_items,
997        "test_only_top": test_only_top,
998        "by_language": by_language,
999        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
1000        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
1001        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
1002        "uncertain_count": uncertain_count,
1003        "uncertain_items": uncertain_items,
1004        "languages_skipped": languages_skipped,
1005        "callgraph_available": true,
1006        "scanned_files": scanned_files,
1007        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
1008    });
1009    if !parse_errors.is_empty() {
1010        aggregate["parse_errors"] = Value::Array(parse_errors);
1011    }
1012    if !skipped_files.is_empty() {
1013        aggregate["skipped_files"] = Value::Array(skipped_files);
1014    }
1015    aggregate
1016}
1017
1018fn add_reexport_contexts(item: &mut Value, contexts: &[OxcReExportContext]) {
1019    if !contexts.is_empty() {
1020        item["also_reexported"] = json!(contexts);
1021    }
1022}
1023
1024fn export_uses_oxc(export: &ExportContribution) -> bool {
1025    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
1026}
1027
1028fn dead_code_honesty_fields(
1029    parsed: &[DeadCodeContribution],
1030) -> (Vec<Value>, Vec<Value>, Vec<String>) {
1031    let mut parse_error_keys = BTreeSet::new();
1032    let mut parse_errors = Vec::new();
1033    let mut skipped_file_keys = BTreeSet::new();
1034    let mut skipped_files = Vec::new();
1035    let mut languages_skipped = BTreeSet::new();
1036    for contribution in parsed {
1037        for value in &contribution.parse_errors {
1038            let key = value.to_string();
1039            if parse_error_keys.insert(key) {
1040                parse_errors.push(value.clone());
1041            }
1042        }
1043        for value in &contribution.skipped_files {
1044            let key = value.to_string();
1045            if skipped_file_keys.insert(key) {
1046                skipped_files.push(value.clone());
1047            }
1048        }
1049        languages_skipped.extend(contribution.skipped_languages.iter().cloned());
1050    }
1051    (
1052        parse_errors,
1053        skipped_files,
1054        languages_skipped.into_iter().collect(),
1055    )
1056}
1057
1058fn edges_by_source(
1059    contributions: &[DeadCodeContribution],
1060) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
1061    let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
1062
1063    for contribution in contributions {
1064        for call in &contribution.internal_calls {
1065            // Keep EVERY resolved edge, regardless of whether the target is an
1066            // exported symbol. Liveness must traverse through private
1067            // intermediaries (a private router/helper that forwards a root to a
1068            // public handler). Restricting targets to exports severed the chain
1069            // at the first private hop and made every handler reachable only via
1070            // a private function look dead. Node identity is (file, symbol);
1071            // private and exported symbols share the same node space.
1072            if call.caller_symbol.is_empty() {
1073                continue;
1074            }
1075            let target = (call.file.clone(), call.symbol.clone());
1076            let source = (contribution.file.clone(), call.caller_symbol.clone());
1077            edges.entry(source).or_default().insert(target);
1078        }
1079    }
1080
1081    edges
1082}
1083
1084fn collect_dispatched_method_names_by_language(
1085    contributions: &[DeadCodeContribution],
1086) -> MethodNamesByLanguage {
1087    let mut by_language: MethodNamesByLanguage = BTreeMap::new();
1088    for contribution in contributions {
1089        let language = language_for_file(&contribution.file).to_string();
1090        by_language
1091            .entry(language)
1092            .or_default()
1093            .extend(contribution.dispatched_method_names.iter().cloned());
1094    }
1095    by_language
1096}
1097
1098fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
1099    // A type-like export is live if it is referenced in type position ANYWHERE
1100    // in the project — not only from call-reachable files. Filtering by
1101    // call-reachability under-approximates
1102    // liveness: the cross-file call graph is incomplete (constructor/method
1103    // edges, workspace-package boundaries), so genuinely-used types referenced
1104    // from files the call graph fails to mark reachable were flagged dead.
1105    // This mirrors `collect_dispatched_method_names`, which is also unfiltered,
1106    // and keeps dead_code biased toward under-reporting (it is a hint, not
1107    // authority): a type with zero type-references anywhere is still precise
1108    // dead.
1109    contributions
1110        .iter()
1111        .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
1112        .collect()
1113}
1114
1115fn reachable_exports(
1116    contributions: &[DeadCodeContribution],
1117    edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
1118    dispatched_method_names: &MethodNamesByLanguage,
1119) -> BTreeSet<ExportNode> {
1120    let imported_exports_by_file = imported_exports_by_file(contributions);
1121    let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
1122    let dispatch_live_source_names_by_file =
1123        dispatch_live_source_names_by_file(contributions, dispatched_method_names);
1124    let mut expanded_file_imports = BTreeSet::new();
1125    let mut reachable = BTreeSet::new();
1126    let mut queue = VecDeque::new();
1127
1128    for contribution in contributions {
1129        for root in &contribution.liveness_roots {
1130            queue.push_back((contribution.file.clone(), root.clone()));
1131        }
1132        for export in &contribution.exports {
1133            if export.is_entry_point {
1134                queue.push_back((contribution.file.clone(), export.symbol.clone()));
1135            }
1136        }
1137    }
1138
1139    // Methods reached only via receiver or interface dispatch often have no
1140    // precise call edge because the concrete receiver type is unknown. They are
1141    // rescued from the dead list by method name, but that alone would not let
1142    // liveness flow through the method body. Go uses the method-only gate below;
1143    // other languages keep their existing name-based behavior.
1144    for source in edges_by_source.keys() {
1145        if dispatch_live_source_names_by_file
1146            .get(&source.0)
1147            .is_some_and(|method_names| method_names.contains(symbol_liveness_name(&source.1)))
1148        {
1149            queue.push_back(source.clone());
1150        }
1151    }
1152
1153    while let Some(node) = queue.pop_front() {
1154        if !reachable.insert(node.clone()) {
1155            continue;
1156        }
1157        if expanded_file_imports.insert(node.0.clone()) {
1158            // Static imports are file-level liveness edges: an imported export
1159            // should keep the target live only when the importer file itself is
1160            // reachable. This prevents dead consumers from making their imports
1161            // look live while still covering references the call graph cannot
1162            // see (type-only imports, JSX/value usage, barrel consumers, etc.).
1163            if let Some(targets) = imported_exports_by_file.get(&node.0) {
1164                for target in targets {
1165                    if !reachable.contains(target) {
1166                        queue.push_back(target.clone());
1167                    }
1168                }
1169            }
1170
1171            // Namespace imports remain conservative file-level edges: once the
1172            // importer file is reached, every export of the imported module is
1173            // considered live because member access is not tracked here.
1174            if let Some(targets) = namespace_imports_by_file.get(&node.0) {
1175                for target in targets {
1176                    if !reachable.contains(target) {
1177                        queue.push_back(target.clone());
1178                    }
1179                }
1180            }
1181        }
1182        if let Some(targets) = edges_by_source.get(&node) {
1183            for target in targets {
1184                if !reachable.contains(target) {
1185                    queue.push_back(target.clone());
1186                }
1187            }
1188        }
1189    }
1190
1191    reachable
1192}
1193
1194fn dispatch_live_source_names_by_file(
1195    contributions: &[DeadCodeContribution],
1196    dispatched_method_names: &MethodNamesByLanguage,
1197) -> BTreeMap<String, BTreeSet<String>> {
1198    let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1199    for contribution in contributions {
1200        let language = language_for_file(&contribution.file);
1201        let Some(language_method_names) = dispatched_method_names.get(language) else {
1202            continue;
1203        };
1204        if language != "go" {
1205            by_file
1206                .entry(contribution.file.clone())
1207                .or_default()
1208                .extend(language_method_names.iter().cloned());
1209            continue;
1210        }
1211
1212        for export in &contribution.exports {
1213            if export_is_method(export)
1214                && language_method_names.contains(symbol_liveness_name(&export.symbol))
1215            {
1216                by_file
1217                    .entry(contribution.file.clone())
1218                    .or_default()
1219                    .insert(symbol_liveness_name(&export.symbol).to_string());
1220            }
1221        }
1222    }
1223    by_file
1224}
1225
1226fn dispatch_liveness_keeps_export_live(
1227    contribution: &DeadCodeContribution,
1228    export: &ExportContribution,
1229    dispatched_method_names: &MethodNamesByLanguage,
1230) -> bool {
1231    let language = language_for_file(&contribution.file);
1232    let Some(method_names) = dispatched_method_names.get(language) else {
1233        return false;
1234    };
1235    let name_is_dispatched = method_names.contains(symbol_liveness_name(&export.symbol));
1236    if language == "go" {
1237        export_is_method(export) && name_is_dispatched
1238    } else {
1239        name_is_dispatched
1240    }
1241}
1242
1243fn export_is_method(export: &ExportContribution) -> bool {
1244    export.kind == "method"
1245}
1246
1247fn imported_exports_by_file(
1248    contributions: &[DeadCodeContribution],
1249) -> BTreeMap<String, BTreeSet<ExportNode>> {
1250    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1251
1252    for contribution in contributions {
1253        if contribution.imported_exports.is_empty() {
1254            continue;
1255        }
1256        by_file
1257            .entry(contribution.file.clone())
1258            .or_default()
1259            .extend(
1260                contribution
1261                    .imported_exports
1262                    .iter()
1263                    .map(|root| (root.file.clone(), root.symbol.clone())),
1264            );
1265    }
1266
1267    by_file
1268}
1269
1270fn namespace_imported_exports_by_file(
1271    contributions: &[DeadCodeContribution],
1272) -> BTreeMap<String, BTreeSet<ExportNode>> {
1273    let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1274
1275    for contribution in contributions {
1276        if contribution.namespace_imported_exports.is_empty() {
1277            continue;
1278        }
1279        by_file
1280            .entry(contribution.file.clone())
1281            .or_default()
1282            .extend(
1283                contribution
1284                    .namespace_imported_exports
1285                    .iter()
1286                    .map(|root| (root.file.clone(), root.symbol.clone())),
1287            );
1288    }
1289
1290    by_file
1291}
1292
1293fn project_internal_call(
1294    project_root: &Path,
1295    call: &CallgraphOutboundCall,
1296    caller_file: &str,
1297    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1298    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1299) -> Option<InternalCall> {
1300    let target = parse_target(project_root, &call.target);
1301    let symbol = target.symbol?;
1302    let file = match target.file {
1303        // Qualified target (file::symbol). The snapshot builder already
1304        // resolved and validated this edge — cross-file targets are confirmed
1305        // exports of the target file, and same-file targets are confirmed
1306        // definitions (private functions included, e.g. `main.rs::dispatch`).
1307        // Keep the edge regardless of the target's export visibility: liveness
1308        // must flow THROUGH private intermediaries, otherwise a public handler
1309        // reached only via a private router/helper looks unreachable.
1310        Some(file) => file,
1311        None => resolve_unqualified_target(
1312            caller_file,
1313            &symbol,
1314            exported_symbols_by_file,
1315            files_by_exported_symbol,
1316        )?,
1317    };
1318
1319    Some(InternalCall {
1320        caller_symbol: call.caller_symbol.clone(),
1321        file,
1322        symbol,
1323        line: call.line,
1324        provenance: call.provenance.clone(),
1325    })
1326}
1327
1328fn resolve_macro_token_liveness_edges(
1329    _project_root: &Path,
1330    caller_file: &str,
1331    refs: &[MacroTokenRefContribution],
1332    rust_imports: &[RawImportContribution],
1333    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1334) -> Vec<InternalCall> {
1335    let mut calls = Vec::new();
1336    for reference in refs {
1337        let Some((file, symbol)) = resolve_macro_token_ref_target(
1338            caller_file,
1339            reference,
1340            rust_imports,
1341            exported_symbols_by_file,
1342        ) else {
1343            continue;
1344        };
1345        calls.push(InternalCall {
1346            caller_symbol: reference.caller_symbol.clone(),
1347            file,
1348            symbol,
1349            line: reference.line,
1350            provenance: MACRO_TOKEN_LIVENESS_PROVENANCE.to_string(),
1351        });
1352    }
1353    sort_dedup_internal_calls(&mut calls);
1354    calls
1355}
1356
1357fn resolve_macro_token_ref_target(
1358    caller_file: &str,
1359    reference: &MacroTokenRefContribution,
1360    rust_imports: &[RawImportContribution],
1361    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1362) -> Option<ExportNode> {
1363    let path = reference.path.as_deref().unwrap_or(&[]);
1364    match reference.shape.as_str() {
1365        RUST_MACRO_REF_SHAPE_CALL => resolve_macro_call_or_struct_ref(
1366            caller_file,
1367            path,
1368            &reference.name,
1369            rust_imports,
1370            exported_symbols_by_file,
1371        ),
1372        RUST_MACRO_REF_SHAPE_STRUCT => resolve_macro_call_or_struct_ref(
1373            caller_file,
1374            path,
1375            &reference.name,
1376            rust_imports,
1377            exported_symbols_by_file,
1378        ),
1379        RUST_MACRO_REF_SHAPE_METHOD => resolve_macro_method_ref(
1380            caller_file,
1381            path,
1382            &reference.name,
1383            rust_imports,
1384            exported_symbols_by_file,
1385        ),
1386        _ => None,
1387    }
1388}
1389
1390fn resolve_macro_call_or_struct_ref(
1391    caller_file: &str,
1392    path: &[String],
1393    name: &str,
1394    rust_imports: &[RawImportContribution],
1395    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1396) -> Option<ExportNode> {
1397    if path.is_empty() {
1398        if let Some(target) = exported_symbol_target(caller_file, name, exported_symbols_by_file) {
1399            return Some(target);
1400        }
1401        return unique_macro_target(imported_macro_targets_for_local(
1402            caller_file,
1403            name,
1404            rust_imports,
1405            exported_symbols_by_file,
1406        ));
1407    }
1408
1409    let scoped_symbol = macro_scoped_symbol(path, name);
1410    if let Some(target) =
1411        exported_symbol_target(caller_file, &scoped_symbol, exported_symbols_by_file)
1412    {
1413        return Some(target);
1414    }
1415
1416    unique_macro_target(resolve_macro_module_targets(
1417        caller_file,
1418        path,
1419        name,
1420        rust_imports,
1421        exported_symbols_by_file,
1422    ))
1423}
1424
1425fn resolve_macro_method_ref(
1426    caller_file: &str,
1427    path: &[String],
1428    name: &str,
1429    rust_imports: &[RawImportContribution],
1430    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1431) -> Option<ExportNode> {
1432    let (type_name, module_path) = path.split_last()?;
1433    let scoped_symbol = macro_scoped_symbol(path, name);
1434    if let Some(target) =
1435        exported_symbol_target(caller_file, &scoped_symbol, exported_symbols_by_file)
1436    {
1437        return Some(target);
1438    }
1439
1440    let target_symbol = format!("{type_name}::{name}");
1441    let mut targets = BTreeSet::new();
1442    if module_path.is_empty() {
1443        for (file, imported_type) in imported_macro_targets_for_local(
1444            caller_file,
1445            type_name,
1446            rust_imports,
1447            exported_symbols_by_file,
1448        ) {
1449            let imported_method = format!("{imported_type}::{name}");
1450            if let Some(target) =
1451                exported_symbol_target(&file, &imported_method, exported_symbols_by_file)
1452            {
1453                targets.insert(target);
1454            }
1455        }
1456    } else {
1457        targets.extend(resolve_macro_module_targets(
1458            caller_file,
1459            module_path,
1460            &target_symbol,
1461            rust_imports,
1462            exported_symbols_by_file,
1463        ));
1464    }
1465    unique_macro_target(targets)
1466}
1467
1468fn resolve_macro_module_targets(
1469    caller_file: &str,
1470    module_path: &[String],
1471    target_symbol: &str,
1472    rust_imports: &[RawImportContribution],
1473    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1474) -> BTreeSet<ExportNode> {
1475    let mut targets = BTreeSet::new();
1476    for candidate in rust_macro_module_path_candidates(module_path, rust_imports) {
1477        let segment_refs = candidate.iter().map(String::as_str).collect::<Vec<_>>();
1478        let Some(resolved_segments) = rust_resolve_segments_for_macro(caller_file, &segment_refs)
1479        else {
1480            continue;
1481        };
1482        let Some(file) = rust_file_for_segments_from_contributions(
1483            caller_file,
1484            &resolved_segments,
1485            exported_symbols_by_file,
1486        ) else {
1487            continue;
1488        };
1489        if let Some(target) = exported_symbol_target(&file, target_symbol, exported_symbols_by_file)
1490        {
1491            targets.insert(target);
1492        }
1493    }
1494    targets
1495}
1496
1497fn imported_macro_targets_for_local(
1498    caller_file: &str,
1499    local_name: &str,
1500    rust_imports: &[RawImportContribution],
1501    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1502) -> BTreeSet<ExportNode> {
1503    let mut targets = BTreeSet::new();
1504    for import in rust_imports {
1505        for imported in rust_imported_symbol_specs(import) {
1506            if imported.local_name != local_name {
1507                continue;
1508            }
1509            let segment_refs = imported
1510                .module_segments
1511                .iter()
1512                .map(String::as_str)
1513                .collect::<Vec<_>>();
1514            let Some(resolved_segments) =
1515                rust_resolve_segments_for_macro(caller_file, &segment_refs)
1516            else {
1517                continue;
1518            };
1519            let Some(file) = rust_file_for_segments_from_contributions(
1520                caller_file,
1521                &resolved_segments,
1522                exported_symbols_by_file,
1523            ) else {
1524                continue;
1525            };
1526            if let Some(target) =
1527                exported_symbol_target(&file, &imported.imported_name, exported_symbols_by_file)
1528            {
1529                targets.insert(target);
1530            }
1531        }
1532    }
1533    targets
1534}
1535
1536fn rust_macro_module_path_candidates(
1537    path: &[String],
1538    rust_imports: &[RawImportContribution],
1539) -> Vec<Vec<String>> {
1540    let mut candidates = Vec::new();
1541    if let Some(first) = path.first() {
1542        for import in rust_imports {
1543            let Some((local_name, mut import_segments)) = rust_import_module_alias_segments(import)
1544            else {
1545                continue;
1546            };
1547            if &local_name == first {
1548                import_segments.extend(path[1..].iter().cloned());
1549                push_unique_macro_path_candidate(&mut candidates, import_segments);
1550            }
1551        }
1552    }
1553    push_unique_macro_path_candidate(&mut candidates, path.to_vec());
1554    candidates
1555}
1556
1557fn rust_import_module_alias_segments(
1558    import: &RawImportContribution,
1559) -> Option<(String, Vec<String>)> {
1560    let path = import.source.trim().trim_end_matches(';').trim();
1561    if path.contains("::{") || path.contains('{') || path.contains('*') {
1562        return None;
1563    }
1564    let (path_without_alias, alias) = path
1565        .split_once(" as ")
1566        .map(|(left, right)| (left.trim(), Some(right.trim())))
1567        .unwrap_or((path, None));
1568    let segments = rust_path_segments(path_without_alias);
1569    let local_name = alias.or_else(|| segments.last().map(String::as_str))?;
1570    if rust_macro_name_is_upper_camel(local_name) {
1571        return None;
1572    }
1573    Some((local_name.to_string(), segments))
1574}
1575
1576fn rust_imported_symbol_specs(import: &RawImportContribution) -> Vec<RustImportedSymbolSpec> {
1577    let path = import.source.trim().trim_end_matches(';').trim();
1578    if let Some((prefix, rest)) = path.split_once("::{") {
1579        let list = rest.trim_end_matches('}');
1580        return list
1581            .split(',')
1582            .filter_map(|specifier| rust_imported_symbol_spec(prefix, specifier))
1583            .collect();
1584    }
1585
1586    rust_imported_symbol_spec("", path).into_iter().collect()
1587}
1588
1589fn rust_imported_symbol_spec(prefix: &str, specifier: &str) -> Option<RustImportedSymbolSpec> {
1590    let specifier = specifier.trim();
1591    if specifier.is_empty() || specifier == "*" || specifier.contains('{') {
1592        return None;
1593    }
1594    let (path_without_alias, alias) = specifier
1595        .split_once(" as ")
1596        .map(|(left, right)| (left.trim(), Some(right.trim())))
1597        .unwrap_or((specifier, None));
1598    let mut segments = rust_path_segments(path_without_alias);
1599    let imported_name = segments.pop()?;
1600    let local_name = alias.unwrap_or(imported_name.as_str()).trim();
1601    if local_name.is_empty() || local_name == "_" {
1602        return None;
1603    }
1604
1605    let mut module_segments = rust_path_segments(prefix);
1606    module_segments.extend(segments);
1607    Some(RustImportedSymbolSpec {
1608        local_name: local_name.to_string(),
1609        module_segments,
1610        imported_name,
1611    })
1612}
1613
1614fn rust_path_segments(path: &str) -> Vec<String> {
1615    path.split("::")
1616        .map(str::trim)
1617        .filter(|segment| !segment.is_empty())
1618        .map(str::to_string)
1619        .collect()
1620}
1621
1622fn push_unique_macro_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
1623    if !candidates.iter().any(|existing| existing == &candidate) {
1624        candidates.push(candidate);
1625    }
1626}
1627
1628fn rust_resolve_segments_for_macro(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
1629    if segments.is_empty() {
1630        return Some(Vec::new());
1631    }
1632    let caller_segments = rust_module_segments_for_rel(caller_file);
1633    match segments[0] {
1634        "crate" => Some(
1635            segments[1..]
1636                .iter()
1637                .map(|item| (*item).to_string())
1638                .collect(),
1639        ),
1640        "self" => {
1641            let mut resolved = caller_segments;
1642            resolved.extend(segments[1..].iter().map(|item| (*item).to_string()));
1643            Some(resolved)
1644        }
1645        "super" => {
1646            let mut resolved = caller_segments;
1647            resolved.pop();
1648            resolved.extend(segments[1..].iter().map(|item| (*item).to_string()));
1649            Some(resolved)
1650        }
1651        _ => {
1652            let mut resolved = caller_segments;
1653            resolved.pop();
1654            resolved.extend(segments.iter().map(|item| (*item).to_string()));
1655            Some(resolved)
1656        }
1657    }
1658}
1659
1660fn rust_file_for_segments_from_contributions(
1661    caller_file: &str,
1662    segments: &[String],
1663    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1664) -> Option<String> {
1665    let src_prefix = rust_src_prefix_for_rel(caller_file);
1666    if segments.is_empty() {
1667        let lib = format!("{src_prefix}/lib.rs");
1668        if exported_symbols_by_file.contains_key(&lib) {
1669            return Some(lib);
1670        }
1671        let main = format!("{src_prefix}/main.rs");
1672        if exported_symbols_by_file.contains_key(&main) {
1673            return Some(main);
1674        }
1675    }
1676
1677    let candidate = if segments.is_empty() {
1678        format!("{src_prefix}/lib.rs")
1679    } else {
1680        format!("{}/{}.rs", src_prefix, segments.join("/"))
1681    };
1682    if exported_symbols_by_file.contains_key(&candidate) {
1683        return Some(candidate);
1684    }
1685    if !segments.is_empty() {
1686        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
1687        if exported_symbols_by_file.contains_key(&mod_candidate) {
1688            return Some(mod_candidate);
1689        }
1690    }
1691    None
1692}
1693
1694fn rust_src_prefix_for_rel(rel_path: &str) -> String {
1695    rel_path
1696        .split_once("/src/")
1697        .map(|(prefix, _)| format!("{prefix}/src"))
1698        .unwrap_or_else(|| "src".to_string())
1699}
1700
1701fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
1702    let after_src = rel_path
1703        .split_once("/src/")
1704        .map(|(_, rest)| rest)
1705        .or_else(|| rel_path.strip_prefix("src/"))
1706        .unwrap_or(rel_path);
1707    if matches!(after_src, "lib.rs" | "main.rs") {
1708        return Vec::new();
1709    }
1710    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
1711        return prefix.split('/').map(|item| item.to_string()).collect();
1712    }
1713    after_src
1714        .strip_suffix(".rs")
1715        .unwrap_or(after_src)
1716        .split('/')
1717        .map(|item| item.to_string())
1718        .collect()
1719}
1720
1721fn macro_scoped_symbol(path: &[String], name: &str) -> String {
1722    if path.is_empty() {
1723        name.to_string()
1724    } else {
1725        format!("{}::{name}", path.join("::"))
1726    }
1727}
1728
1729fn exported_symbol_target(
1730    file: &str,
1731    symbol: &str,
1732    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1733) -> Option<ExportNode> {
1734    exported_symbols_by_file
1735        .get(file)
1736        .is_some_and(|symbols| symbols.contains(symbol))
1737        .then(|| (file.to_string(), symbol.to_string()))
1738}
1739
1740fn unique_macro_target(targets: BTreeSet<ExportNode>) -> Option<ExportNode> {
1741    if targets.len() == 1 {
1742        targets.into_iter().next()
1743    } else {
1744        None
1745    }
1746}
1747
1748fn raw_imports_from_tree(
1749    source: &str,
1750    tree: &tree_sitter::Tree,
1751    lang: LangId,
1752) -> Vec<RawImportContribution> {
1753    parse_imports(source, tree, lang)
1754        .imports
1755        .into_iter()
1756        .map(|import| RawImportContribution {
1757            source: import.module_path,
1758            names: import.names,
1759            default_import: import.default_import,
1760            namespace_import: import.namespace_import,
1761        })
1762        .collect()
1763}
1764
1765fn rust_raw_import_contributions(
1766    source: &str,
1767    tree: &tree_sitter::Tree,
1768) -> Vec<RawImportContribution> {
1769    parse_imports(source, tree, LangId::Rust)
1770        .imports
1771        .into_iter()
1772        .map(|import| RawImportContribution {
1773            source: import.module_path,
1774            names: import.names,
1775            default_import: None,
1776            namespace_import: None,
1777        })
1778        .collect()
1779}
1780
1781fn rust_macro_token_refs(source: &str, root: tree_sitter::Node) -> Vec<MacroTokenRefContribution> {
1782    let mut refs = BTreeSet::new();
1783    let mut scope_stack = Vec::new();
1784    collect_rust_macro_token_refs(source, root, &mut scope_stack, &mut refs);
1785    refs.into_iter().collect()
1786}
1787
1788fn collect_rust_macro_token_refs(
1789    source: &str,
1790    node: tree_sitter::Node,
1791    scope_stack: &mut Vec<String>,
1792    refs: &mut BTreeSet<MacroTokenRefContribution>,
1793) {
1794    let scope_len = scope_stack.len();
1795    if node.kind() == "function_item" {
1796        if let Some(symbol) = rust_function_symbol_name(source, &node) {
1797            scope_stack.push(symbol);
1798        }
1799    }
1800
1801    if node.kind() == "macro_invocation" {
1802        if let Some(token_tree) = find_child_by_kind(node, "token_tree") {
1803            let caller_symbol = scope_stack
1804                .last()
1805                .cloned()
1806                .unwrap_or_else(|| TOP_LEVEL_SYMBOL.to_string());
1807            let mut tokens = Vec::new();
1808            collect_rust_macro_tokens(source, token_tree, &mut tokens);
1809            extract_rust_macro_token_refs(&tokens, &caller_symbol, refs);
1810        }
1811    }
1812
1813    let mut cursor = node.walk();
1814    if cursor.goto_first_child() {
1815        loop {
1816            collect_rust_macro_token_refs(source, cursor.node(), scope_stack, refs);
1817            if !cursor.goto_next_sibling() {
1818                break;
1819            }
1820        }
1821    }
1822    scope_stack.truncate(scope_len);
1823}
1824
1825fn collect_rust_macro_tokens<'a>(
1826    source: &'a str,
1827    node: tree_sitter::Node,
1828    tokens: &mut Vec<RustMacroToken<'a>>,
1829) {
1830    if rust_macro_token_node_is_opaque(node.kind()) {
1831        return;
1832    }
1833
1834    if node.child_count() == 0 {
1835        let text = node_text(source, node).trim();
1836        if !text.is_empty() {
1837            tokens.push(RustMacroToken {
1838                text,
1839                kind: node.kind(),
1840                line: node.start_position().row as u32 + 1,
1841            });
1842        }
1843        return;
1844    }
1845
1846    let mut cursor = node.walk();
1847    if cursor.goto_first_child() {
1848        loop {
1849            collect_rust_macro_tokens(source, cursor.node(), tokens);
1850            if !cursor.goto_next_sibling() {
1851                break;
1852            }
1853        }
1854    }
1855}
1856
1857fn rust_macro_token_node_is_opaque(kind: &str) -> bool {
1858    matches!(
1859        kind,
1860        "string_literal" | "raw_string_literal" | "char_literal" | "line_comment" | "block_comment"
1861    )
1862}
1863
1864fn extract_rust_macro_token_refs(
1865    tokens: &[RustMacroToken<'_>],
1866    caller_symbol: &str,
1867    refs: &mut BTreeSet<MacroTokenRefContribution>,
1868) {
1869    for index in 0..tokens.len() {
1870        let token = &tokens[index];
1871        if !rust_macro_token_is_identifier(token) || rust_macro_token_is_keyword(token.text) {
1872            continue;
1873        }
1874        if index > 0 && tokens[index - 1].text == "." {
1875            continue;
1876        }
1877        if tokens.get(index + 1).is_some_and(|next| next.text == "!") {
1878            continue;
1879        }
1880
1881        let path = rust_macro_path_before(tokens, index);
1882        let next = rust_macro_next_after_optional_turbofish(tokens, index + 1);
1883        if tokens.get(next).is_some_and(|next| next.text == "(") {
1884            let shape = if path
1885                .last()
1886                .is_some_and(|segment| rust_macro_name_is_upper_camel(segment))
1887            {
1888                RUST_MACRO_REF_SHAPE_METHOD
1889            } else {
1890                RUST_MACRO_REF_SHAPE_CALL
1891            };
1892            refs.insert(MacroTokenRefContribution {
1893                caller_symbol: caller_symbol.to_string(),
1894                line: token.line,
1895                name: token.text.to_string(),
1896                path: macro_ref_path(path),
1897                shape: shape.to_string(),
1898            });
1899            continue;
1900        }
1901
1902        if rust_macro_name_is_upper_camel(token.text)
1903            && tokens.get(index + 1).is_some_and(|next| next.text == "{")
1904        {
1905            refs.insert(MacroTokenRefContribution {
1906                caller_symbol: caller_symbol.to_string(),
1907                line: token.line,
1908                name: token.text.to_string(),
1909                path: macro_ref_path(path),
1910                shape: RUST_MACRO_REF_SHAPE_STRUCT.to_string(),
1911            });
1912        }
1913    }
1914}
1915
1916fn rust_macro_path_before(tokens: &[RustMacroToken<'_>], index: usize) -> Vec<String> {
1917    let mut segments = Vec::new();
1918    let mut cursor = index;
1919    while cursor >= 2
1920        && tokens[cursor - 1].text == "::"
1921        && rust_macro_token_is_path_segment(&tokens[cursor - 2])
1922    {
1923        segments.push(tokens[cursor - 2].text.to_string());
1924        cursor -= 2;
1925    }
1926    segments.reverse();
1927    segments
1928}
1929
1930fn rust_macro_next_after_optional_turbofish(tokens: &[RustMacroToken<'_>], index: usize) -> usize {
1931    if tokens.get(index).is_none_or(|token| token.text != "::")
1932        || tokens.get(index + 1).is_none_or(|token| token.text != "<")
1933    {
1934        return index;
1935    }
1936
1937    let mut depth = 0usize;
1938    let mut cursor = index + 1;
1939    while let Some(token) = tokens.get(cursor) {
1940        match token.text {
1941            "<" => depth += 1,
1942            ">" => {
1943                depth = depth.saturating_sub(1);
1944                if depth == 0 {
1945                    return cursor + 1;
1946                }
1947            }
1948            _ => {}
1949        }
1950        cursor += 1;
1951    }
1952    index
1953}
1954
1955fn macro_ref_path(path: Vec<String>) -> Option<Vec<String>> {
1956    (!path.is_empty()).then_some(path)
1957}
1958
1959fn rust_macro_token_is_identifier(token: &RustMacroToken<'_>) -> bool {
1960    matches!(token.kind, "identifier" | "type_identifier")
1961        || rust_macro_text_is_identifier(token.text)
1962}
1963
1964fn rust_macro_token_is_path_segment(token: &RustMacroToken<'_>) -> bool {
1965    rust_macro_token_is_identifier(token)
1966        && (!rust_macro_token_is_keyword(token.text)
1967            || matches!(token.text, "crate" | "self" | "super"))
1968}
1969
1970fn rust_macro_text_is_identifier(text: &str) -> bool {
1971    let mut chars = text.chars();
1972    let Some(first) = chars.next() else {
1973        return false;
1974    };
1975    (first == '_' || first.is_ascii_alphabetic())
1976        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1977}
1978
1979fn rust_macro_name_is_upper_camel(name: &str) -> bool {
1980    name.chars().next().is_some_and(char::is_uppercase)
1981}
1982
1983fn rust_macro_token_is_keyword(text: &str) -> bool {
1984    matches!(
1985        text,
1986        "as" | "async"
1987            | "await"
1988            | "break"
1989            | "const"
1990            | "continue"
1991            | "crate"
1992            | "dyn"
1993            | "else"
1994            | "enum"
1995            | "extern"
1996            | "false"
1997            | "fn"
1998            | "for"
1999            | "if"
2000            | "impl"
2001            | "in"
2002            | "let"
2003            | "loop"
2004            | "match"
2005            | "mod"
2006            | "move"
2007            | "mut"
2008            | "pub"
2009            | "ref"
2010            | "return"
2011            | "self"
2012            | "Self"
2013            | "static"
2014            | "struct"
2015            | "super"
2016            | "trait"
2017            | "true"
2018            | "type"
2019            | "unsafe"
2020            | "use"
2021            | "where"
2022            | "while"
2023    )
2024}
2025
2026fn rust_function_symbol_name(
2027    source: &str,
2028    function_node: &tree_sitter::Node<'_>,
2029) -> Option<String> {
2030    let name_node = function_node.child_by_field_name("name")?;
2031    let name = node_text(source, name_node).to_string();
2032    let declaration_list_owner = rust_function_declaration_list_owner(function_node);
2033
2034    match declaration_list_owner.as_ref().map(tree_sitter::Node::kind) {
2035        Some("impl_item") => {
2036            let scope_name = rust_impl_scope_name(declaration_list_owner.as_ref().unwrap(), source);
2037            if scope_name.is_empty() {
2038                Some(name)
2039            } else {
2040                Some(format!("{scope_name}::{name}"))
2041            }
2042        }
2043        Some(owner_kind) if owner_kind != "mod_item" => None,
2044        _ => {
2045            let scope_chain = rust_mod_scope_chain(function_node, source);
2046            if scope_chain.is_empty() {
2047                Some(name)
2048            } else {
2049                Some(format!("{}::{name}", scope_chain.join("::")))
2050            }
2051        }
2052    }
2053}
2054
2055fn rust_function_declaration_list_owner<'a>(
2056    function_node: &tree_sitter::Node<'a>,
2057) -> Option<tree_sitter::Node<'a>> {
2058    function_node
2059        .parent()
2060        .filter(|parent| parent.kind() == "declaration_list")
2061        .and_then(|parent| parent.parent())
2062}
2063
2064fn rust_mod_scope_chain(node: &tree_sitter::Node<'_>, source: &str) -> Vec<String> {
2065    let mut scopes = Vec::new();
2066    let mut current = node.parent();
2067    while let Some(parent) = current {
2068        if parent.kind() == "mod_item" {
2069            if let Some(name_node) = parent.child_by_field_name("name") {
2070                scopes.push(node_text(source, name_node).to_string());
2071            }
2072        }
2073        current = parent.parent();
2074    }
2075    scopes.reverse();
2076    scopes
2077}
2078
2079fn rust_impl_scope_name(impl_node: &tree_sitter::Node<'_>, source: &str) -> String {
2080    let mut type_names: Vec<String> = Vec::new();
2081    let mut child_cursor = impl_node.walk();
2082    if child_cursor.goto_first_child() {
2083        loop {
2084            let child = child_cursor.node();
2085            if child.kind() == "type_identifier" || child.kind() == "generic_type" {
2086                type_names.push(node_text(source, child).to_string());
2087            }
2088            if !child_cursor.goto_next_sibling() {
2089                break;
2090            }
2091        }
2092    }
2093
2094    if type_names.len() >= 2 {
2095        format!("{} for {}", type_names[0], type_names[1])
2096    } else if type_names.len() == 1 {
2097        type_names[0].clone()
2098    } else {
2099        String::new()
2100    }
2101}
2102
2103fn ts_raw_reexport_contributions(
2104    source: &str,
2105    root: tree_sitter::Node,
2106) -> Vec<RawReexportContribution> {
2107    let mut reexports = Vec::new();
2108    let mut cursor = root.walk();
2109    if !cursor.goto_first_child() {
2110        return reexports;
2111    }
2112
2113    loop {
2114        let node = cursor.node();
2115        if node.kind() == "export_statement" {
2116            if let Some(module_path) = export_source_module(source, node) {
2117                let line = (node.start_position().row + 1) as u32;
2118                let raw_export = node_text(source, node).trim();
2119                for specifier in ts_reexport_specifiers(raw_export) {
2120                    reexports.push(RawReexportContribution {
2121                        language: "ts".to_string(),
2122                        source: module_path.clone(),
2123                        kind: "named".to_string(),
2124                        imported: Some(specifier.imported),
2125                        exported: Some(specifier.exported),
2126                        line,
2127                    });
2128                }
2129                if raw_export.contains('*') {
2130                    if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
2131                        reexports.push(RawReexportContribution {
2132                            language: "ts".to_string(),
2133                            source: module_path.clone(),
2134                            kind: "namespace".to_string(),
2135                            imported: Some("*".to_string()),
2136                            exported: Some(namespace_export),
2137                            line,
2138                        });
2139                    } else {
2140                        reexports.push(RawReexportContribution {
2141                            language: "ts".to_string(),
2142                            source: module_path.clone(),
2143                            kind: "star".to_string(),
2144                            imported: Some("*".to_string()),
2145                            exported: None,
2146                            line,
2147                        });
2148                    }
2149                }
2150            }
2151        }
2152
2153        if !cursor.goto_next_sibling() {
2154            break;
2155        }
2156    }
2157
2158    reexports
2159}
2160
2161fn rust_raw_reexport_contributions(source: &str) -> Vec<RawReexportContribution> {
2162    rust_pub_use_statements(source)
2163        .into_iter()
2164        .flat_map(|(statement, line)| {
2165            rust_reexport_specifiers(&statement)
2166                .into_iter()
2167                .map(move |specifier| RawReexportContribution {
2168                    language: "rust".to_string(),
2169                    source: specifier.module_path.join("::"),
2170                    kind: if specifier.imported == "*" {
2171                        "star".to_string()
2172                    } else {
2173                        "named".to_string()
2174                    },
2175                    imported: Some(specifier.imported),
2176                    exported: Some(specifier.exported),
2177                    line,
2178                })
2179        })
2180        .collect()
2181}
2182
2183fn resolve_raw_reexport_liveness_edges(
2184    project_root: &Path,
2185    file_name: &str,
2186    raw_reexports: &[RawReexportContribution],
2187    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2188    default_export_symbols_by_file: &BTreeMap<String, String>,
2189) -> Vec<InternalCall> {
2190    let mut edges = Vec::new();
2191    let file = project_root.join(file_name);
2192    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2193
2194    for raw in raw_reexports {
2195        match raw.language.as_str() {
2196            "ts" => {
2197                let Some(module_entry) = resolve_import_module_path(from_dir, &raw.source) else {
2198                    continue;
2199                };
2200                edges.extend(resolve_reexport_fact_edge(
2201                    project_root,
2202                    file_name,
2203                    &module_entry,
2204                    raw.kind.as_str(),
2205                    raw.imported.as_deref(),
2206                    raw.exported.as_deref(),
2207                    raw.line,
2208                    exported_symbols_by_file,
2209                    default_export_symbols_by_file,
2210                ));
2211            }
2212            "rust" => {
2213                let module_path = raw
2214                    .source
2215                    .split("::")
2216                    .filter(|segment| !segment.is_empty())
2217                    .map(str::to_string)
2218                    .collect::<Vec<_>>();
2219                let Some(module_entry) =
2220                    rust_module_entry_from_file(project_root, file_name, &module_path)
2221                else {
2222                    continue;
2223                };
2224                edges.extend(resolve_reexport_fact_edge(
2225                    project_root,
2226                    file_name,
2227                    &module_entry,
2228                    raw.kind.as_str(),
2229                    raw.imported.as_deref(),
2230                    raw.exported.as_deref(),
2231                    raw.line,
2232                    exported_symbols_by_file,
2233                    default_export_symbols_by_file,
2234                ));
2235            }
2236            _ => {}
2237        }
2238    }
2239
2240    edges
2241}
2242
2243fn resolve_oxc_reexport_liveness_edges(
2244    project_root: &Path,
2245    file_name: &str,
2246    oxc_facts: &OxcFactsContribution,
2247    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2248    default_export_symbols_by_file: &BTreeMap<String, String>,
2249) -> Vec<InternalCall> {
2250    let file = project_root.join(file_name);
2251    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2252    let mut edges = Vec::new();
2253    for fact in &oxc_facts.re_exports {
2254        let Some(module_entry) = resolve_import_module_path(from_dir, &fact.source) else {
2255            continue;
2256        };
2257        let kind = match fact.kind {
2258            ReExportKind::Named => "named",
2259            ReExportKind::Star => "star",
2260            ReExportKind::Namespace => "namespace",
2261        };
2262        edges.extend(resolve_reexport_fact_edge(
2263            project_root,
2264            file_name,
2265            &module_entry,
2266            kind,
2267            fact.imported_name.as_deref(),
2268            fact.exported_name.as_deref(),
2269            fact.line,
2270            exported_symbols_by_file,
2271            default_export_symbols_by_file,
2272        ));
2273    }
2274    edges
2275}
2276
2277#[allow(clippy::too_many_arguments)]
2278fn resolve_reexport_fact_edge(
2279    project_root: &Path,
2280    file_name: &str,
2281    module_entry: &Path,
2282    kind: &str,
2283    imported: Option<&str>,
2284    exported: Option<&str>,
2285    line: u32,
2286    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2287    default_export_symbols_by_file: &BTreeMap<String, String>,
2288) -> Vec<InternalCall> {
2289    match kind {
2290        "star" => reexport_edges_for_all_target_symbols(
2291            project_root,
2292            file_name,
2293            "",
2294            module_entry,
2295            line,
2296            exported_symbols_by_file,
2297            default_export_symbols_by_file,
2298            true,
2299        ),
2300        "namespace" => {
2301            let namespace_export = exported.unwrap_or_default();
2302            if namespace_export.is_empty()
2303                || !file_exports_symbol(file_name, namespace_export, exported_symbols_by_file)
2304            {
2305                return Vec::new();
2306            }
2307            reexport_edges_for_all_target_symbols(
2308                project_root,
2309                file_name,
2310                namespace_export,
2311                module_entry,
2312                line,
2313                exported_symbols_by_file,
2314                default_export_symbols_by_file,
2315                false,
2316            )
2317        }
2318        _ => {
2319            let imported = imported.unwrap_or_default();
2320            let exported = exported.unwrap_or(imported);
2321            if imported.is_empty()
2322                || exported.is_empty()
2323                || !file_exports_symbol(file_name, exported, exported_symbols_by_file)
2324            {
2325                return Vec::new();
2326            }
2327            resolve_imported_export_liveness_root(
2328                project_root,
2329                module_entry,
2330                imported,
2331                exported_symbols_by_file,
2332                default_export_symbols_by_file,
2333            )
2334            .map(|(target_file, target_symbol)| {
2335                vec![InternalCall {
2336                    caller_symbol: exported.to_string(),
2337                    file: target_file,
2338                    symbol: target_symbol,
2339                    line,
2340                    provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
2341                }]
2342            })
2343            .unwrap_or_default()
2344        }
2345    }
2346}
2347
2348fn rust_module_entry_from_file(
2349    project_root: &Path,
2350    file_name: &str,
2351    module_path: &[String],
2352) -> Option<PathBuf> {
2353    let first = module_path.first()?;
2354    let file = project_root.join(file_name);
2355    let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
2356    resolve_rust_module_file(base_dir, first)
2357}
2358
2359fn resolve_raw_imported_export_liveness_roots(
2360    project_root: &Path,
2361    file_name: &str,
2362    raw_imports: &[RawImportContribution],
2363    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2364    default_export_symbols_by_file: &BTreeMap<String, String>,
2365) -> ImportedExportLiveness {
2366    let file = project_root.join(file_name);
2367    let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
2368    let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
2369    let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
2370
2371    for import in raw_imports {
2372        if import.namespace_import.is_some() {
2373            if let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) {
2374                namespace_exports.extend(resolve_namespace_import_liveness_roots(
2375                    project_root,
2376                    &module_entry,
2377                    exported_symbols_by_file,
2378                    default_export_symbols_by_file,
2379                ));
2380            }
2381        }
2382
2383        let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) else {
2384            continue;
2385        };
2386
2387        for imported_name in import
2388            .names
2389            .iter()
2390            .map(|name| specifier_imported_name(name))
2391        {
2392            if let Some(root) = resolve_imported_export_liveness_root(
2393                project_root,
2394                &module_entry,
2395                imported_name,
2396                exported_symbols_by_file,
2397                default_export_symbols_by_file,
2398            ) {
2399                root_exports.insert(root);
2400            }
2401        }
2402
2403        if import.default_import.is_some() {
2404            if let Some(root) = resolve_imported_export_liveness_root(
2405                project_root,
2406                &module_entry,
2407                "default",
2408                exported_symbols_by_file,
2409                default_export_symbols_by_file,
2410            ) {
2411                root_exports.insert(root);
2412            }
2413        }
2414    }
2415
2416    ImportedExportLiveness {
2417        root_exports: root_exports
2418            .into_iter()
2419            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
2420            .collect(),
2421        namespace_exports: namespace_exports
2422            .into_iter()
2423            .map(|(file, symbol)| ImportedExportContribution { file, symbol })
2424            .collect(),
2425    }
2426}
2427
2428fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
2429    let Some(start) = raw_export.find('{').map(|index| index + 1) else {
2430        return Vec::new();
2431    };
2432    let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
2433        return Vec::new();
2434    };
2435
2436    raw_export[start..end]
2437        .split(',')
2438        .filter_map(|specifier| {
2439            let specifier = specifier.trim();
2440            if specifier.is_empty() {
2441                return None;
2442            }
2443            let imported = specifier_imported_name(specifier).trim();
2444            let exported = specifier_local_name(specifier).trim();
2445            if imported.is_empty() || exported.is_empty() {
2446                return None;
2447            }
2448            Some(ReexportSpecifier {
2449                imported: imported.to_string(),
2450                exported: exported.to_string(),
2451            })
2452        })
2453        .collect()
2454}
2455
2456fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
2457    let after_star = raw_export.split_once('*')?.1.trim_start();
2458    let after_as = after_star.strip_prefix("as")?.trim_start();
2459    let name = after_as
2460        .split_whitespace()
2461        .next()?
2462        .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
2463    (!name.is_empty()).then(|| name.to_string())
2464}
2465
2466fn reexport_edges_for_all_target_symbols(
2467    project_root: &Path,
2468    file_name: &str,
2469    namespace_export: &str,
2470    module_entry: &Path,
2471    line: u32,
2472    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2473    default_export_symbols_by_file: &BTreeMap<String, String>,
2474    match_current_export_names: bool,
2475) -> Vec<InternalCall> {
2476    let Some((_, target_symbols)) =
2477        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
2478    else {
2479        return Vec::new();
2480    };
2481
2482    let mut edges = Vec::new();
2483    for target_symbol in target_symbols {
2484        let caller_symbol = if match_current_export_names {
2485            if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
2486                continue;
2487            }
2488            target_symbol.clone()
2489        } else {
2490            namespace_export.to_string()
2491        };
2492
2493        if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
2494            project_root,
2495            module_entry,
2496            target_symbol,
2497            exported_symbols_by_file,
2498            default_export_symbols_by_file,
2499        ) {
2500            edges.push(InternalCall {
2501                caller_symbol,
2502                file: target_file,
2503                symbol: resolved_symbol,
2504                line,
2505                provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
2506            });
2507        }
2508    }
2509
2510    edges
2511}
2512
2513fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
2514    let flat = base_dir.join(format!("{module}.rs"));
2515    if flat.is_file() {
2516        return Some(flat);
2517    }
2518    let nested = base_dir.join(module).join("mod.rs");
2519    nested.is_file().then_some(nested)
2520}
2521
2522fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
2523    let mut statements = Vec::new();
2524    let mut current = String::new();
2525    let mut start_line = 0u32;
2526
2527    for (index, line) in source.lines().enumerate() {
2528        let trimmed = line.trim();
2529        if current.is_empty() {
2530            if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
2531                continue;
2532            }
2533            start_line = (index + 1) as u32;
2534        }
2535
2536        current.push(' ');
2537        current.push_str(trimmed);
2538        if trimmed.ends_with(';') {
2539            statements.push((current.trim().to_string(), start_line));
2540            current.clear();
2541        }
2542    }
2543
2544    statements
2545}
2546
2547fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
2548    let statement = statement
2549        .trim()
2550        .trim_end_matches(';')
2551        .strip_prefix("pub(crate) use ")
2552        .or_else(|| {
2553            statement
2554                .trim()
2555                .trim_end_matches(';')
2556                .strip_prefix("pub use ")
2557        })
2558        .unwrap_or("")
2559        .trim();
2560    if statement.is_empty() {
2561        return Vec::new();
2562    }
2563
2564    if let Some((module_path, grouped)) = statement.split_once("::{") {
2565        let grouped = grouped.trim_end_matches('}');
2566        return grouped
2567            .split(',')
2568            .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
2569            .collect();
2570    }
2571
2572    let Some((module_path, imported)) = statement.rsplit_once("::") else {
2573        return Vec::new();
2574    };
2575    rust_reexport_specifier(module_path.trim(), imported.trim())
2576        .into_iter()
2577        .collect()
2578}
2579
2580fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
2581    if specifier.is_empty() {
2582        return None;
2583    }
2584    let (imported, exported) = specifier
2585        .split_once(" as ")
2586        .map(|(imported, exported)| (imported.trim(), exported.trim()))
2587        .unwrap_or((specifier.trim(), specifier.trim()));
2588    if imported.is_empty() || exported.is_empty() {
2589        return None;
2590    }
2591    Some(RustReexportSpecifier {
2592        module_path: rust_normalize_module_path(module_path),
2593        imported: imported.to_string(),
2594        exported: exported.to_string(),
2595    })
2596}
2597
2598fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
2599    module_path
2600        .split("::")
2601        .filter_map(|segment| {
2602            let segment = segment.trim();
2603            if segment.is_empty() || matches!(segment, "self" | "crate") {
2604                None
2605            } else {
2606                Some(segment.to_string())
2607            }
2608        })
2609        .collect()
2610}
2611
2612fn file_exports_symbol(
2613    file_name: &str,
2614    symbol: &str,
2615    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2616) -> bool {
2617    exported_symbols_by_file
2618        .get(file_name)
2619        .is_some_and(|symbols| symbols.contains(symbol))
2620}
2621
2622fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
2623    node.child_by_field_name("source")
2624        .or_else(|| find_child_by_kind(node, "string"))
2625        .and_then(|source_node| string_literal_content(source, source_node))
2626}
2627
2628fn find_child_by_kind<'tree>(
2629    node: tree_sitter::Node<'tree>,
2630    kind: &str,
2631) -> Option<tree_sitter::Node<'tree>> {
2632    let mut cursor = node.walk();
2633    if !cursor.goto_first_child() {
2634        return None;
2635    }
2636    loop {
2637        let child = cursor.node();
2638        if child.kind() == kind {
2639            return Some(child);
2640        }
2641        if let Some(descendant) = find_child_by_kind(child, kind) {
2642            return Some(descendant);
2643        }
2644        if !cursor.goto_next_sibling() {
2645            break;
2646        }
2647    }
2648    None
2649}
2650
2651fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2652    let raw = node_text(source, node).trim();
2653    let quote = raw.chars().next()?;
2654    if quote != '\'' && quote != '"' {
2655        return None;
2656    }
2657    raw.strip_prefix(quote)
2658        .and_then(|value| value.strip_suffix(quote))
2659        .map(ToOwned::to_owned)
2660}
2661
2662fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
2663    &source[node.byte_range()]
2664}
2665
2666fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2667    if is_relative_module_path(module_path) {
2668        return resolve_js_ts_module_path(from_dir, module_path);
2669    }
2670    resolve_workspace_package_import(from_dir, module_path)
2671}
2672
2673fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2674    resolve_module_path(from_dir, module_path)
2675        .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
2676}
2677
2678fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2679    if !is_relative_module_path(module_path) {
2680        return None;
2681    }
2682    let base = from_dir.join(module_path);
2683    let ext = base.extension().and_then(|extension| extension.to_str())?;
2684    let candidates: &[&str] = match ext {
2685        "js" => &["ts", "tsx"],
2686        "jsx" => &["tsx", "ts"],
2687        "mjs" => &["mts", "ts"],
2688        "cjs" => &["cts", "ts"],
2689        _ => return None,
2690    };
2691
2692    candidates
2693        .iter()
2694        .map(|extension| base.with_extension(extension))
2695        .find(|candidate| candidate.is_file())
2696}
2697
2698fn is_relative_module_path(module_path: &str) -> bool {
2699    module_path.starts_with("./")
2700        || module_path.starts_with("../")
2701        || module_path == "."
2702        || module_path == ".."
2703}
2704
2705#[derive(Debug)]
2706struct ReexportSpecifier {
2707    imported: String,
2708    exported: String,
2709}
2710
2711#[derive(Debug)]
2712struct RustReexportSpecifier {
2713    module_path: Vec<String>,
2714    imported: String,
2715    exported: String,
2716}
2717
2718fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2719    let package_name = package_name_from_import(module_path)?;
2720    let module_entry = resolve_module_path(from_dir, module_path)?;
2721    let resolved_package_name = package_name_for_file(&module_entry)?;
2722    (resolved_package_name == package_name).then_some(module_entry)
2723}
2724
2725fn package_name_from_import(module_path: &str) -> Option<String> {
2726    if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
2727    {
2728        return None;
2729    }
2730
2731    let mut parts = module_path.split('/');
2732    let first = parts.next()?;
2733    if first.is_empty() {
2734        return None;
2735    }
2736
2737    if first.starts_with('@') {
2738        let second = parts.next()?;
2739        (!second.is_empty()).then(|| format!("{first}/{second}"))
2740    } else {
2741        Some(first.to_string())
2742    }
2743}
2744
2745fn package_name_for_file(file: &Path) -> Option<String> {
2746    let mut current = file.parent();
2747    while let Some(dir) = current {
2748        let manifest = dir.join("package.json");
2749        if manifest.is_file() {
2750            if let Ok(source) = fs::read_to_string(&manifest) {
2751                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
2752                    if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
2753                        return Some(name.to_string());
2754                    }
2755                }
2756            }
2757        }
2758        current = dir.parent();
2759    }
2760    None
2761}
2762
2763fn resolve_namespace_import_liveness_roots(
2764    project_root: &Path,
2765    module_entry: &Path,
2766    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2767    default_export_symbols_by_file: &BTreeMap<String, String>,
2768) -> Vec<ExportNode> {
2769    let Some((_, symbols)) =
2770        exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
2771    else {
2772        return Vec::new();
2773    };
2774    let mut roots = BTreeSet::new();
2775
2776    for symbol in symbols {
2777        if let Some(root) = resolve_imported_export_liveness_root(
2778            project_root,
2779            module_entry,
2780            symbol,
2781            exported_symbols_by_file,
2782            default_export_symbols_by_file,
2783        ) {
2784            roots.insert(root);
2785        }
2786    }
2787
2788    if default_export_symbol_for_resolved_file(
2789        project_root,
2790        module_entry,
2791        default_export_symbols_by_file,
2792    )
2793    .is_some()
2794    {
2795        if let Some(root) = resolve_imported_export_liveness_root(
2796            project_root,
2797            module_entry,
2798            "default",
2799            exported_symbols_by_file,
2800            default_export_symbols_by_file,
2801        ) {
2802            roots.insert(root);
2803        }
2804    }
2805
2806    roots.into_iter().collect()
2807}
2808
2809fn resolve_imported_export_liveness_root(
2810    project_root: &Path,
2811    module_entry: &Path,
2812    imported_symbol: &str,
2813    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2814    default_export_symbols_by_file: &BTreeMap<String, String>,
2815) -> Option<ExportNode> {
2816    let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
2817        exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
2818            .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
2819    };
2820    let mut file_default_export_symbol = |path: &Path| {
2821        default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
2822            .or_else(|| {
2823                exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
2824                    .and_then(|(_, symbols)| {
2825                        symbols.contains("default").then(|| "default".to_string())
2826                    })
2827            })
2828    };
2829
2830    let (target_file, symbol) = resolve_reexported_symbol_target(
2831        module_entry,
2832        imported_symbol,
2833        &mut file_exports_symbol,
2834        &mut file_default_export_symbol,
2835    )?;
2836
2837    let (file, symbols) =
2838        exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
2839    symbols.contains(&symbol).then_some((file, symbol))
2840}
2841
2842fn exported_symbols_for_resolved_file<'a>(
2843    project_root: &Path,
2844    file: &Path,
2845    exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
2846) -> Option<(String, &'a BTreeSet<String>)> {
2847    let relative = relative_path(project_root, file);
2848    if let Some(symbols) = exported_symbols_by_file.get(&relative) {
2849        return Some((relative, symbols));
2850    }
2851
2852    // Normalized, not bare-canonical: the map keys being probed are built
2853    // from job-normalized (verbatim-stripped) paths.
2854    let canonical_root = canonicalize_normalized(project_root);
2855    let canonical_file = canonicalize_normalized(file);
2856    let relative = relative_path(&canonical_root, &canonical_file);
2857    exported_symbols_by_file
2858        .get(&relative)
2859        .map(|symbols| (relative, symbols))
2860}
2861
2862fn default_export_symbol_for_resolved_file(
2863    project_root: &Path,
2864    file: &Path,
2865    default_export_symbols_by_file: &BTreeMap<String, String>,
2866) -> Option<String> {
2867    let relative = relative_path(project_root, file);
2868    if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
2869        return Some(symbol.clone());
2870    }
2871
2872    // Normalized, not bare-canonical: the map keys being probed are built
2873    // from job-normalized (verbatim-stripped) paths.
2874    let canonical_root = canonicalize_normalized(project_root);
2875    let canonical_file = canonicalize_normalized(file);
2876    let relative = relative_path(&canonical_root, &canonical_file);
2877    default_export_symbols_by_file.get(&relative).cloned()
2878}
2879
2880fn resolve_unqualified_target(
2881    caller_file: &str,
2882    symbol: &str,
2883    exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
2884    files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
2885) -> Option<String> {
2886    if exported_symbols_by_file
2887        .get(caller_file)
2888        .is_some_and(|symbols| symbols.contains(symbol))
2889    {
2890        return Some(caller_file.to_string());
2891    }
2892
2893    let files = files_by_exported_symbol.get(symbol)?;
2894    if files.len() == 1 {
2895        files.iter().next().cloned()
2896    } else {
2897        None
2898    }
2899}
2900
2901fn dispatched_method_names_from_call(
2902    call: &CallgraphOutboundCall,
2903    caller_file: &str,
2904) -> Vec<String> {
2905    let mut names = BTreeSet::new();
2906    let is_go = language_for_file(caller_file) == "go";
2907    if is_go {
2908        if let Some(interface_methods) = go_well_known_interface_methods_from_call(call) {
2909            names.extend(interface_methods.iter().map(|name| (*name).to_string()));
2910            return names.into_iter().collect();
2911        }
2912    }
2913
2914    if let Some(name) = dispatched_method_name_from_call(call) {
2915        names.insert(name);
2916    }
2917    names.into_iter().collect()
2918}
2919
2920fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
2921    let (target, full_callee) = split_call_target_metadata(&call.target);
2922    if let Some(full_callee) = full_callee {
2923        return dispatched_method_name_from_callee(full_callee);
2924    }
2925    if target.contains("::") || target.contains('#') {
2926        return None;
2927    }
2928    dispatched_method_name_from_callee(target)
2929}
2930
2931fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
2932    let callee = callee.trim();
2933    if !callee.contains('.') {
2934        return None;
2935    }
2936
2937    clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
2938}
2939
2940fn go_well_known_interface_methods_from_call(
2941    call: &CallgraphOutboundCall,
2942) -> Option<&'static [&'static str]> {
2943    let (target, full_callee) = split_call_target_metadata(&call.target);
2944    let callee = full_callee.unwrap_or(target).trim();
2945    // Go interface methods are invoked by library code outside the project
2946    // graph. These entry calls add method names only; the final liveness check
2947    // is still gated to Go method exports, not functions.
2948    match callee {
2949        "sort.Sort" | "sort.Stable" | "sort.IsSorted" => Some(&["Len", "Less", "Swap"]),
2950        "list.New" => Some(&["FilterValue"]),
2951        _ => None,
2952    }
2953}
2954
2955fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
2956    target
2957        .split_once(DISPATCHED_CALLEE_SEPARATOR)
2958        .map_or((target, None), |(target, full_callee)| {
2959            (target, Some(full_callee))
2960        })
2961}
2962
2963fn symbol_liveness_name(symbol: &str) -> &str {
2964    symbol
2965        .rsplit(['.', ':', '#'])
2966        .find(|segment| !segment.is_empty())
2967        .unwrap_or(symbol)
2968}
2969
2970fn is_type_like_kind(kind: &str) -> bool {
2971    matches!(
2972        kind,
2973        "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
2974    )
2975}
2976
2977fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
2978    let (target, _) = split_call_target_metadata(target);
2979    let trimmed = target.trim();
2980    if trimmed.is_empty() {
2981        return ParsedTarget {
2982            file: None,
2983            symbol: None,
2984        };
2985    }
2986
2987    if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
2988        return ParsedTarget {
2989            file: Some(relative_path(project_root, Path::new(file))),
2990            symbol: clean_symbol(symbol),
2991        };
2992    }
2993
2994    if let Some((file, symbol)) = trimmed.rsplit_once('#') {
2995        return ParsedTarget {
2996            file: Some(relative_path(project_root, Path::new(file))),
2997            symbol: clean_symbol(symbol),
2998        };
2999    }
3000
3001    ParsedTarget {
3002        file: None,
3003        symbol: clean_symbol(trimmed),
3004    }
3005}
3006
3007fn split_file_symbol_target<'a>(
3008    project_root: &Path,
3009    target: &'a str,
3010    separator: &str,
3011) -> Option<(&'a str, &'a str)> {
3012    let mut search_start = 0;
3013    while let Some(offset) = target[search_start..].find(separator) {
3014        let split_at = search_start + offset;
3015        let file = &target[..split_at];
3016        let symbol = &target[split_at + separator.len()..];
3017        if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
3018            return Some((file, symbol));
3019        }
3020        search_start = split_at + separator.len();
3021    }
3022    None
3023}
3024
3025fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
3026    let path = Path::new(file);
3027    language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
3028}
3029
3030fn clean_symbol(symbol: &str) -> Option<String> {
3031    let trimmed = symbol.trim();
3032    if trimmed.is_empty() {
3033        None
3034    } else {
3035        Some(trimmed.to_string())
3036    }
3037}
3038
3039fn liveness_roots_for_file(
3040    file_name: &str,
3041    exports: &[ExportContribution],
3042    internal_calls: &[InternalCall],
3043    attribute_entry_points: &BTreeSet<String>,
3044    executable_root_exports: Option<&BTreeSet<String>>,
3045    is_liveness_root_file: bool,
3046    is_public_api_file: bool,
3047) -> Vec<String> {
3048    let mut roots = attribute_entry_points
3049        .iter()
3050        .filter_map(|symbol| clean_symbol(symbol))
3051        .collect::<BTreeSet<_>>();
3052
3053    if !is_liveness_root_file && !is_public_api_file {
3054        return roots.into_iter().collect();
3055    }
3056
3057    roots.insert("<top-level>".to_string());
3058    if is_public_api_file {
3059        roots.extend(exports.iter().map(|export| export.symbol.clone()));
3060    } else if let Some(executable_root_exports) = executable_root_exports {
3061        roots.extend(executable_root_exports.iter().cloned());
3062    } else {
3063        roots.extend(
3064            exports
3065                .iter()
3066                .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
3067                .map(|export| export.symbol.clone()),
3068        );
3069        roots.extend(
3070            internal_calls
3071                .iter()
3072                .map(|call| call.caller_symbol.as_str())
3073                .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
3074                .map(str::to_string),
3075        );
3076    }
3077
3078    roots.into_iter().collect()
3079}
3080
3081fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
3082    let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
3083    if symbol == "<top-level>" {
3084        return true;
3085    }
3086
3087    let lower = symbol.to_ascii_lowercase();
3088    if matches!(
3089        lower.as_str(),
3090        "main" | "init" | "setup" | "bootstrap" | "run"
3091    ) {
3092        return true;
3093    }
3094
3095    Path::new(file_name)
3096        .file_stem()
3097        .and_then(|stem| stem.to_str())
3098        .is_some_and(|stem| stem == symbol)
3099}
3100
3101pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
3102    crate::inspect::entry_points::resolve_entry_points(project_root)
3103        .public_api_files_relative(project_root)
3104}
3105
3106fn language_for_file(file: &str) -> &'static str {
3107    detect_language(Path::new(file))
3108        .map(language_name)
3109        .unwrap_or("unknown")
3110}
3111
3112fn supports_type_refs(lang: LangId) -> bool {
3113    matches!(
3114        lang,
3115        LangId::TypeScript
3116            | LangId::Tsx
3117            | LangId::JavaScript
3118            | LangId::Python
3119            | LangId::Rust
3120            | LangId::Go
3121    )
3122}
3123
3124fn collect_freshness(file: &Path) -> FileFreshness {
3125    cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
3126        mtime: UNIX_EPOCH,
3127        size: 0,
3128        content_hash: cache_freshness::zero_hash(),
3129    })
3130}
3131
3132fn relative_path(project_root: &Path, path: &Path) -> String {
3133    let absolute = if path.is_absolute() {
3134        path.to_path_buf()
3135    } else {
3136        project_root.join(path)
3137    };
3138    let normalized_root = canonicalize_normalized(project_root);
3139    let normalized = canonicalize_normalized(&absolute);
3140    normalized
3141        .strip_prefix(&normalized_root)
3142        .unwrap_or(normalized.as_path())
3143        .to_string_lossy()
3144        .replace('\\', "/")
3145}
3146
3147fn canonical_or_normalized(project_root: &Path, path: &Path) -> PathBuf {
3148    // Delegates to the oxc engine's input normalizer so FileFacts paths built
3149    // here compare equal to the engine's entry-point/executable-root sets.
3150    // Calling fs::canonicalize directly is wrong on Windows: it returns
3151    // verbatim (\\?\C:\) paths while those sets are de-verbatimed, and the
3152    // membership miss silently drops entry-point liveness.
3153    crate::inspect::oxc_engine::normalize_input_path(project_root, path)
3154}
3155
3156fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
3157    let absolute = if path.is_absolute() {
3158        path.to_path_buf()
3159    } else {
3160        project_root.join(path)
3161    };
3162    normalize_path(&absolute)
3163}
3164
3165fn normalize_path(path: &Path) -> PathBuf {
3166    // Delegates to the subsystem-wide normalizer: a components-only local
3167    // version kept Windows verbatim prefixes, so map keys built here failed
3168    // to join lookups built from verbatim-stripped roots.
3169    crate::inspect::job::normalize_path(path)
3170}
3171
3172#[derive(Debug, Clone, Deserialize)]
3173struct DeadCodeContribution {
3174    file: String,
3175    #[serde(default)]
3176    generated: Option<bool>,
3177    exports: Vec<ExportContribution>,
3178    #[serde(default)]
3179    facts_format_version: Option<u32>,
3180    #[serde(default)]
3181    raw_imports: Vec<RawImportContribution>,
3182    #[serde(default)]
3183    raw_reexports: Vec<RawReexportContribution>,
3184    #[serde(default)]
3185    rust_imports: Vec<RawImportContribution>,
3186    #[serde(default)]
3187    macro_token_refs: Vec<MacroTokenRefContribution>,
3188    #[serde(default)]
3189    attribute_entry_points: Vec<String>,
3190    #[serde(default)]
3191    oxc_facts: Option<OxcFactsContribution>,
3192    #[serde(default)]
3193    internal_calls: Vec<InternalCallContribution>,
3194    #[serde(default)]
3195    liveness_roots: Vec<String>,
3196    #[serde(default)]
3197    imported_exports: Vec<ImportedExportContribution>,
3198    #[serde(default)]
3199    namespace_imported_exports: Vec<ImportedExportContribution>,
3200    #[serde(default)]
3201    dispatched_method_names: Vec<String>,
3202    #[serde(default)]
3203    type_ref_names: Vec<String>,
3204    #[serde(default)]
3205    parse_errors: Vec<Value>,
3206    #[serde(default)]
3207    skipped_files: Vec<Value>,
3208    #[serde(default)]
3209    skipped_languages: Vec<String>,
3210}
3211
3212#[derive(Debug, Clone, Serialize, Deserialize)]
3213struct RawImportContribution {
3214    source: String,
3215    #[serde(default)]
3216    names: Vec<String>,
3217    #[serde(default)]
3218    default_import: Option<String>,
3219    #[serde(default)]
3220    namespace_import: Option<String>,
3221}
3222
3223#[derive(Debug, Clone, Serialize, Deserialize)]
3224struct RawReexportContribution {
3225    language: String,
3226    source: String,
3227    kind: String,
3228    #[serde(default)]
3229    imported: Option<String>,
3230    #[serde(default)]
3231    exported: Option<String>,
3232    line: u32,
3233}
3234
3235#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
3236struct MacroTokenRefContribution {
3237    caller_symbol: String,
3238    line: u32,
3239    name: String,
3240    #[serde(default, skip_serializing_if = "Option::is_none")]
3241    path: Option<Vec<String>>,
3242    shape: String,
3243}
3244
3245#[derive(Debug, Clone, Deserialize)]
3246struct OxcFactsContribution {
3247    format_version: u32,
3248    content_hash: String,
3249    exports: Vec<ExportFact>,
3250    imports: Vec<ImportFact>,
3251    re_exports: Vec<ReExportFact>,
3252    dynamic_imports: Vec<DynamicImportFact>,
3253    same_file_value_references: BTreeSet<String>,
3254    used_import_bindings: BTreeSet<String>,
3255    type_referenced_import_bindings: BTreeSet<String>,
3256    value_referenced_import_bindings: BTreeSet<String>,
3257    #[serde(default)]
3258    parse_error: Option<String>,
3259}
3260
3261#[derive(Debug, Clone, Deserialize)]
3262struct ImportedExportContribution {
3263    file: String,
3264    symbol: String,
3265}
3266
3267#[derive(Debug, Clone, Deserialize)]
3268struct ExportContribution {
3269    symbol: String,
3270    kind: String,
3271    line: u32,
3272    #[serde(default)]
3273    is_type_like: bool,
3274    #[serde(default)]
3275    is_entry_point: bool,
3276    #[serde(default)]
3277    has_references: bool,
3278    #[serde(default)]
3279    test_only_reference_files: Vec<String>,
3280    #[serde(default)]
3281    verdict: Option<LivenessVerdict>,
3282    #[serde(default)]
3283    reason: Option<String>,
3284    #[serde(default)]
3285    provenance: Option<String>,
3286    #[serde(default)]
3287    also_reexported: Vec<OxcReExportContext>,
3288}
3289
3290#[derive(Debug, Clone, Deserialize)]
3291struct InternalCallContribution {
3292    #[serde(default)]
3293    caller_symbol: String,
3294    file: String,
3295    symbol: String,
3296}
3297
3298impl From<InternalCall> for InternalCallContribution {
3299    fn from(call: InternalCall) -> Self {
3300        Self {
3301            caller_symbol: call.caller_symbol,
3302            file: call.file,
3303            symbol: call.symbol,
3304        }
3305    }
3306}
3307
3308#[derive(Debug, Clone)]
3309struct InternalCall {
3310    caller_symbol: String,
3311    file: String,
3312    symbol: String,
3313    line: u32,
3314    provenance: String,
3315}
3316
3317#[derive(Debug, Clone)]
3318struct ParsedTarget {
3319    file: Option<String>,
3320    symbol: Option<String>,
3321}
3322
3323#[cfg(test)]
3324mod tests {
3325    use super::*;
3326    use std::fs;
3327    use std::path::{Path, PathBuf};
3328    use std::sync::{Arc, RwLock};
3329
3330    use crate::config::Config;
3331    use crate::inspect::job::{CALLGRAPH_PROVENANCE_TREESITTER, DISPATCHED_CALLEE_SEPARATOR};
3332    use crate::inspect::{CallgraphExport, JobKey};
3333    use crate::parser::SymbolCache;
3334
3335    fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
3336        let temp_dir = tempfile::tempdir().expect("tempdir");
3337        let root = temp_dir.path().join("project");
3338        fs::create_dir_all(&root).expect("create project root");
3339
3340        let paths = files
3341            .iter()
3342            .map(|(relative, contents)| {
3343                let path = root.join(relative);
3344                if let Some(parent) = path.parent() {
3345                    fs::create_dir_all(parent).expect("create parent");
3346                }
3347                fs::write(&path, contents).expect("write fixture file");
3348                path
3349            })
3350            .collect::<Vec<_>>();
3351
3352        (temp_dir, root, paths)
3353    }
3354
3355    fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
3356        InspectJob {
3357            job_id: 1,
3358            key: JobKey::for_project_category(InspectCategory::DeadCode),
3359            category: InspectCategory::DeadCode,
3360            scope_files,
3361            project_root: root.to_path_buf(),
3362            inspect_dir: root.join(".aft-cache").join("inspect"),
3363            config: Arc::new(Config {
3364                project_root: Some(root.to_path_buf()),
3365                ..Config::default()
3366            }),
3367            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3368            inspect_writer: true,
3369            callgraph_writer: true,
3370            callgraph_snapshot: Some(Arc::new(snapshot)),
3371        }
3372    }
3373
3374    fn snapshot(
3375        files: Vec<PathBuf>,
3376        exported_symbols: Vec<CallgraphExport>,
3377        outbound_calls: Vec<CallgraphOutboundCall>,
3378    ) -> CallgraphSnapshot {
3379        snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
3380    }
3381
3382    fn snapshot_with_entry_points(
3383        files: Vec<PathBuf>,
3384        exported_symbols: Vec<CallgraphExport>,
3385        outbound_calls: Vec<CallgraphOutboundCall>,
3386        entry_points: BTreeSet<PathBuf>,
3387    ) -> CallgraphSnapshot {
3388        CallgraphSnapshot {
3389            generated_at: None,
3390            files,
3391            exported_symbols,
3392            outbound_calls,
3393            entry_points,
3394            entry_point_symbols: BTreeMap::new(),
3395        }
3396    }
3397
3398    fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
3399        CallgraphExport {
3400            file: root.join(file),
3401            symbol: symbol.to_string(),
3402            kind: kind.to_string(),
3403            line: 1,
3404        }
3405    }
3406
3407    fn outbound(
3408        root: &Path,
3409        caller_file: &str,
3410        caller_symbol: &str,
3411        target: &str,
3412    ) -> CallgraphOutboundCall {
3413        CallgraphOutboundCall {
3414            caller_file: root.join(caller_file),
3415            caller_symbol: caller_symbol.to_string(),
3416            target: target.to_string(),
3417            line: 1,
3418            provenance: CALLGRAPH_PROVENANCE_TREESITTER.to_string(),
3419        }
3420    }
3421
3422    fn dispatched_target(target: &str, full_callee: &str) -> String {
3423        format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
3424    }
3425
3426    fn scan(job: InspectJob) -> serde_json::Value {
3427        run_dead_code_scan(&job)
3428            .outcome
3429            .expect("scan succeeds")
3430            .aggregate
3431    }
3432
3433    fn aggregate_has_item(aggregate: &serde_json::Value, file: &str, symbol: &str) -> bool {
3434        aggregate
3435            .get("items")
3436            .and_then(serde_json::Value::as_array)
3437            .into_iter()
3438            .flatten()
3439            .any(|item| {
3440                item.get("file").and_then(serde_json::Value::as_str) == Some(file)
3441                    && item.get("symbol").and_then(serde_json::Value::as_str) == Some(symbol)
3442            })
3443    }
3444
3445    #[test]
3446    fn contributions_persist_non_generated_classification() {
3447        let (_temp_dir, root, paths) = fixture_project(&[
3448            ("src/hand.ts", "export const hand = 1;\n"),
3449            ("build.gradle", "task smokeTest {}\n"),
3450        ]);
3451        let success = run_dead_code_scan(&job(
3452            &root,
3453            paths.clone(),
3454            snapshot(paths.clone(), Vec::new(), Vec::new()),
3455        ))
3456        .outcome
3457        .expect("scan succeeds");
3458
3459        assert_eq!(success.contributions.len(), 2);
3460        assert!(success.contributions.iter().all(|contribution| {
3461            contribution
3462                .contribution
3463                .get("generated")
3464                .and_then(Value::as_bool)
3465                == Some(false)
3466        }));
3467    }
3468
3469    #[test]
3470    fn groovy_dead_code_scan_reports_language_skipped_without_fabricated_counts() {
3471        let (_temp_dir, root, paths) = fixture_project(&[(
3472            "build.gradle",
3473            "task smokeTest {\n    doLast {\n        println 'smoke'\n    }\n}\n",
3474        )]);
3475        let aggregate = scan(job(
3476            &root,
3477            paths.clone(),
3478            snapshot(paths.clone(), Vec::new(), Vec::new()),
3479        ));
3480
3481        assert_eq!(aggregate["count"], 0);
3482        assert_eq!(aggregate["total_count"], 0);
3483        assert_eq!(
3484            aggregate["languages_skipped"],
3485            serde_json::json!(["groovy"])
3486        );
3487        assert_eq!(aggregate["by_language"], serde_json::json!({}));
3488        assert!(aggregate["items"]
3489            .as_array()
3490            .is_some_and(|items| items.is_empty()));
3491        assert_eq!(aggregate["complete"], true);
3492    }
3493
3494    fn rust_entry_scan(
3495        files: &[(&str, &str)],
3496        exports: &[(&str, &str, &str)],
3497    ) -> serde_json::Value {
3498        let (_temp_dir, root, paths) = fixture_project(files);
3499        let entry_points = [root.join("src/main.rs")]
3500            .into_iter()
3501            .collect::<BTreeSet<_>>();
3502        let exports = exports
3503            .iter()
3504            .map(|(file, symbol, kind)| export(&root, file, symbol, kind))
3505            .collect::<Vec<_>>();
3506        scan(job(
3507            &root,
3508            paths.clone(),
3509            snapshot_with_entry_points(paths, exports, Vec::new(), entry_points),
3510        ))
3511    }
3512
3513    fn scan_success_with_oxc(job: InspectJob) -> InspectScanSuccess {
3514        let entry_points = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
3515        let options = AnalyzeOptions {
3516            entry_points: job
3517                .callgraph_snapshot
3518                .as_ref()
3519                .map(|snapshot| snapshot.entry_points.iter().cloned().collect())
3520                .unwrap_or_default(),
3521            public_api_files: Vec::new(),
3522            executable_root_exports: entry_points.executable_root_exports(),
3523            force_reparse_files: Vec::new(),
3524            entry_reachability: true,
3525        };
3526        let oxc_result =
3527            crate::inspect::oxc_engine::analyze_files(&job.project_root, &job.scope_files, options)
3528                .expect("oxc analyze succeeds");
3529        run_dead_code_scan_with_oxc(&job, Some(&oxc_result))
3530            .outcome
3531            .expect("scan succeeds")
3532    }
3533
3534    fn scan_with_oxc(job: InspectJob) -> serde_json::Value {
3535        scan_success_with_oxc(job).aggregate
3536    }
3537
3538    fn aggregate_item<'a>(
3539        aggregate: &'a serde_json::Value,
3540        file: &str,
3541        symbol: &str,
3542    ) -> Option<&'a serde_json::Value> {
3543        aggregate["items"].as_array()?.iter().find(|item| {
3544            item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3545        })
3546    }
3547
3548    fn aggregate_generated_item<'a>(
3549        aggregate: &'a serde_json::Value,
3550        file: &str,
3551        symbol: &str,
3552    ) -> Option<&'a serde_json::Value> {
3553        aggregate["generated_items"]
3554            .as_array()?
3555            .iter()
3556            .find(|item| {
3557                item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3558            })
3559    }
3560
3561    fn aggregate_test_only_item<'a>(
3562        aggregate: &'a serde_json::Value,
3563        file: &str,
3564        symbol: &str,
3565    ) -> Option<&'a serde_json::Value> {
3566        aggregate["test_only_items"]
3567            .as_array()?
3568            .iter()
3569            .find(|item| {
3570                item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
3571            })
3572    }
3573
3574    #[test]
3575    fn oxc_dead_code_splits_test_only_references_from_headline() {
3576        let (_temp_dir, root, paths) = fixture_project(&[
3577            ("package.json", r#"{"main":"src/main.ts"}"#),
3578            (
3579                "src/main.ts",
3580                "import { productUsed } from './api';
3581export function main() { productUsed(); }
3582",
3583            ),
3584            (
3585                "src/api.ts",
3586                "export function testOnly() {}
3587export function productUsed() {}
3588",
3589            ),
3590            (
3591                "src/dead.ts",
3592                "export function plantedDead() {}
3593",
3594            ),
3595            (
3596                "src/api.test.ts",
3597                "import { testOnly } from './api';
3598testOnly();
3599",
3600            ),
3601            (
3602                "src/barrel-target.ts",
3603                "export function throughBarrel() {}
3604export function barrelDead() {}
3605",
3606            ),
3607            (
3608                "src/barrel.ts",
3609                "export { throughBarrel } from './barrel-target';
3610",
3611            ),
3612            (
3613                "src/barrel.test.ts",
3614                "import { throughBarrel } from './barrel';
3615throughBarrel();
3616",
3617            ),
3618        ]);
3619        let root = fs::canonicalize(root).expect("canonical project root");
3620        let paths = paths
3621            .into_iter()
3622            .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3623            .collect::<Vec<_>>();
3624        let entry_points = BTreeSet::from([root.join("src/main.ts")]);
3625        let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
3626
3627        let aggregate = scan_with_oxc(job(&root, paths, graph));
3628
3629        assert_eq!(aggregate["count"], 2, "{aggregate:#}");
3630        assert!(aggregate_item(&aggregate, "src/dead.ts", "plantedDead").is_some());
3631        assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "barrelDead").is_some());
3632        assert!(aggregate_item(&aggregate, "src/api.ts", "testOnly").is_none());
3633        assert!(aggregate_item(&aggregate, "src/api.ts", "productUsed").is_none());
3634        assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "throughBarrel").is_none());
3635
3636        assert_eq!(aggregate["test_only_count"], 2, "{aggregate:#}");
3637        assert_eq!(
3638            aggregate_test_only_item(&aggregate, "src/api.ts", "testOnly")
3639                .and_then(|item| item["used_by"].as_array())
3640                .and_then(|items| items.first())
3641                .and_then(serde_json::Value::as_str),
3642            Some("api.test.ts")
3643        );
3644        assert_eq!(
3645            aggregate_test_only_item(&aggregate, "src/barrel-target.ts", "throughBarrel")
3646                .and_then(|item| item["used_by"].as_array())
3647                .and_then(|items| items.first())
3648                .and_then(serde_json::Value::as_str),
3649            Some("barrel.test.ts")
3650        );
3651    }
3652
3653    #[test]
3654    fn oxc_dead_code_buckets_generated_exports_below_headline() {
3655        let (_temp_dir, root, paths) = fixture_project(&[
3656            ("package.json", r#"{"main":"src/main.ts"}"#),
3657            (
3658                "src/main.ts",
3659                "console.log('main');
3660",
3661            ),
3662            (
3663                "src/hand.ts",
3664                "export function handDead() {}
3665",
3666            ),
3667            (
3668                "gen/schema_pb.ts",
3669                "export function generatedPathDead() {}
3670",
3671            ),
3672            (
3673                "src/banner.ts",
3674                "// Code generated by fixture. DO NOT EDIT.
3675export function bannerDead() {}
3676",
3677            ),
3678        ]);
3679        let root = fs::canonicalize(root).expect("canonical project root");
3680        let paths = paths
3681            .into_iter()
3682            .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3683            .collect::<Vec<_>>();
3684        let entry_points = BTreeSet::from([root.join("src/main.ts")]);
3685        let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
3686
3687        let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3688        let second = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3689        assert_eq!(
3690            first.aggregate, second.aggregate,
3691            "twice-cold scan must be deterministic"
3692        );
3693
3694        assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
3695        assert_eq!(
3696            first.aggregate["generated_count"], 2,
3697            "{:#}",
3698            first.aggregate
3699        );
3700        assert_eq!(first.aggregate["total_count"], 3, "{:#}", first.aggregate);
3701        assert!(aggregate_item(&first.aggregate, "src/hand.ts", "handDead").is_some());
3702        assert!(aggregate_generated_item(
3703            &first.aggregate,
3704            "gen/schema_pb.ts",
3705            "generatedPathDead"
3706        )
3707        .is_some());
3708        assert!(
3709            aggregate_generated_item(&first.aggregate, "src/banner.ts", "bannerDead").is_some()
3710        );
3711
3712        let item_files = first.aggregate["items"]
3713            .as_array()
3714            .expect("items")
3715            .iter()
3716            .filter_map(|item| item["file"].as_str())
3717            .collect::<Vec<_>>();
3718        assert_eq!(item_files.first(), Some(&"src/hand.ts"), "{item_files:?}");
3719
3720        let roles = crate::inspect::entry_points::resolve_project_roles(&root);
3721        let rolled_up = aggregate_dead_code_contributions_with_snapshot(
3722            &root,
3723            &graph,
3724            &first.contributions,
3725            &collect_public_api_files(&root),
3726            &roles,
3727            Some(MAX_DRILL_DOWN_ITEMS),
3728        );
3729        assert_eq!(
3730            rolled_up, first.aggregate,
3731            "cached rollup must match cold aggregate"
3732        );
3733    }
3734
3735    #[test]
3736    fn oxc_dead_code_test_file_edit_cached_rollup_matches_cold() {
3737        let (_temp_dir, root, paths) = fixture_project(&[
3738            (
3739                "src/api.ts",
3740                "export function testOnly() {}
3741export function plantedDead() {}
3742",
3743            ),
3744            (
3745                "src/api.test.ts",
3746                "import { testOnly } from './api';
3747testOnly();
3748",
3749            ),
3750        ]);
3751        let root = fs::canonicalize(root).expect("canonical project root");
3752        let paths = paths
3753            .into_iter()
3754            .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
3755            .collect::<Vec<_>>();
3756        let graph =
3757            snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), BTreeSet::new());
3758        let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3759        assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
3760        assert_eq!(
3761            first.aggregate["test_only_count"], 1,
3762            "{:#}",
3763            first.aggregate
3764        );
3765
3766        fs::write(
3767            root.join("src/api.test.ts"),
3768            "console.log('import removed');
3769",
3770        )
3771        .expect("edit test file");
3772
3773        let cold = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
3774        let changed_test = scan_success_with_oxc(job(
3775            &root,
3776            vec![root.join("src/api.test.ts")],
3777            graph.clone(),
3778        ));
3779        let mut cached_contributions = first.contributions.clone();
3780        for changed in changed_test.contributions {
3781            let slot = cached_contributions
3782                .iter_mut()
3783                .find(|contribution| contribution.file_path == changed.file_path)
3784                .expect("cached test contribution exists");
3785            *slot = changed;
3786        }
3787        let roles = crate::inspect::entry_points::resolve_project_roles(&root);
3788        let rolled_up = aggregate_dead_code_contributions_with_snapshot(
3789            &root,
3790            &graph,
3791            &cached_contributions,
3792            &collect_public_api_files(&root),
3793            &roles,
3794            Some(MAX_DRILL_DOWN_ITEMS),
3795        );
3796
3797        assert_eq!(rolled_up, cold.aggregate);
3798        assert_eq!(rolled_up["count"], 2, "{rolled_up:#}");
3799        assert_eq!(rolled_up["test_only_count"], 0, "{rolled_up:#}");
3800    }
3801
3802    #[test]
3803    fn method_dispatched_by_receiver_call_is_live() {
3804        let (_temp_dir, root, paths) = fixture_project(&[
3805            ("src/service.ts", "export class Service { render() {} }\n"),
3806            (
3807                "src/consumer.ts",
3808                "function run(service: Service) { service.render(); }\n",
3809            ),
3810        ]);
3811        let aggregate = scan(job(
3812            &root,
3813            paths.clone(),
3814            snapshot(
3815                paths,
3816                vec![export(&root, "src/service.ts", "render", "method")],
3817                vec![outbound(
3818                    &root,
3819                    "src/consumer.ts",
3820                    "run",
3821                    &dispatched_target("render", "service.render"),
3822                )],
3823            ),
3824        ));
3825
3826        assert_eq!(aggregate["count"], 0);
3827        assert_eq!(aggregate["uncertain_count"], 0);
3828        assert!(aggregate["items"].as_array().unwrap().is_empty());
3829    }
3830
3831    #[test]
3832    fn method_without_any_dispatch_is_still_dead() {
3833        let (_temp_dir, root, paths) =
3834            fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
3835        let aggregate = scan(job(
3836            &root,
3837            paths.clone(),
3838            snapshot(
3839                paths,
3840                vec![export(&root, "src/service.ts", "render", "method")],
3841                Vec::new(),
3842            ),
3843        ));
3844
3845        assert_eq!(aggregate["count"], 1);
3846        assert_eq!(aggregate["items"][0]["symbol"], "render");
3847        assert_eq!(aggregate["uncertain_count"], 0);
3848    }
3849
3850    #[test]
3851    fn free_function_called_from_dispatch_live_method_body_is_live() {
3852        // Regression for the dead_code reachability bug: a free function reached
3853        // only through a method whose only caller is a receiver dispatch
3854        // (`obj.method()`) must NOT be reported dead. The method ("render") is
3855        // rescued from the dead list by dispatch-name, but liveness must also
3856        // flow THROUGH its body to the free function it calls ("helper").
3857        // Mirrors the real `BgTaskRegistry::spawn` -> `task_paths` case, where
3858        // `task_paths` had 33 callers yet was flagged dead because the BFS never
3859        // entered the dispatch-only method body. Method bodies are keyed by
3860        // scoped identity (`Service::render`) while exports are bare (`render`),
3861        // so the body edge is unreachable without seeding the scoped method node.
3862        let (_temp_dir, root, paths) = fixture_project(&[
3863            (
3864                "src/service.ts",
3865                "export class Service { render() { helper(); } }\n",
3866            ),
3867            ("src/helper.ts", "export function helper() {}\n"),
3868            (
3869                "src/consumer.ts",
3870                "function run(service: Service) { service.render(); }\n",
3871            ),
3872        ]);
3873        let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
3874        let aggregate = scan(job(
3875            &root,
3876            paths.clone(),
3877            snapshot(
3878                paths,
3879                vec![
3880                    export(&root, "src/service.ts", "render", "method"),
3881                    export(&root, "src/helper.ts", "helper", "function"),
3882                ],
3883                vec![
3884                    // The method's ONLY caller is a receiver dispatch — no
3885                    // resolvable edge into `Service::render`.
3886                    outbound(
3887                        &root,
3888                        "src/consumer.ts",
3889                        "run",
3890                        &dispatched_target("render", "service.render"),
3891                    ),
3892                    // The dispatch-only method body calls a free function. The
3893                    // caller identity is scoped (`Service::render`), the form the
3894                    // edge map uses for sources.
3895                    outbound(&root, "src/service.ts", "Service::render", &helper_target),
3896                ],
3897            ),
3898        ));
3899
3900        assert_eq!(
3901            aggregate["count"], 0,
3902            "free function reached via dispatch-live method body must be live: {aggregate:#}"
3903        );
3904        assert!(aggregate["items"].as_array().unwrap().is_empty());
3905    }
3906
3907    #[test]
3908    fn rust_struct_referenced_only_in_types_is_live() {
3909        let (_temp_dir, root, paths) = fixture_project(&[
3910            ("src/types.rs", "pub struct Widget { id: u64 }\n"),
3911            (
3912                "src/main.rs",
3913                "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
3914            ),
3915        ]);
3916        let aggregate = scan(job(
3917            &root,
3918            paths.clone(),
3919            snapshot_with_entry_points(
3920                paths,
3921                vec![
3922                    export(&root, "src/types.rs", "Widget", "struct"),
3923                    export(&root, "src/main.rs", "main", "function"),
3924                ],
3925                Vec::new(),
3926                BTreeSet::from([root.join("src/main.rs")]),
3927            ),
3928        ));
3929
3930        assert_eq!(aggregate["count"], 0);
3931        assert_eq!(aggregate["uncertain_count"], 0);
3932        assert!(aggregate["items"].as_array().unwrap().is_empty());
3933    }
3934
3935    #[test]
3936    fn ts_interface_referenced_only_in_type_annotation_is_live() {
3937        let (_temp_dir, root, paths) = fixture_project(&[
3938            ("src/types.ts", "export interface Widget { id: string }\n"),
3939            (
3940                "src/main.ts",
3941                "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
3942            ),
3943        ]);
3944        let aggregate = scan(job(
3945            &root,
3946            paths.clone(),
3947            snapshot_with_entry_points(
3948                paths,
3949                vec![
3950                    export(&root, "src/types.ts", "Widget", "interface"),
3951                    export(&root, "src/main.ts", "run", "function"),
3952                ],
3953                Vec::new(),
3954                BTreeSet::from([root.join("src/main.ts")]),
3955            ),
3956        ));
3957
3958        assert_eq!(aggregate["count"], 0);
3959        assert_eq!(aggregate["uncertain_count"], 0);
3960        assert!(aggregate["items"].as_array().unwrap().is_empty());
3961    }
3962
3963    #[test]
3964    fn type_like_export_without_call_or_type_ref_is_precise_dead() {
3965        let (_temp_dir, root, paths) =
3966            fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
3967        let aggregate = scan(job(
3968            &root,
3969            paths.clone(),
3970            snapshot(
3971                paths,
3972                vec![export(&root, "src/types.ts", "Widget", "interface")],
3973                Vec::new(),
3974            ),
3975        ));
3976
3977        assert_eq!(aggregate["count"], 1);
3978        assert_eq!(aggregate["items"][0]["symbol"], "Widget");
3979        assert_eq!(aggregate["uncertain_count"], 0);
3980        assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
3981    }
3982
3983    #[test]
3984    fn rust_attribute_entry_points_seed_command_liveness() {
3985        let (_temp_dir, root, paths) = fixture_project(&[
3986            (
3987                "src/commands.rs",
3988                r#"use crate::db;
3989
3990#[tauri::command]
3991pub fn get_primers() -> String {
3992    db::helper()
3993}
3994
3995pub fn planted_dead() -> String {
3996    "dead".to_string()
3997}
3998
3999#[tauri::command]
4000fn private_command() -> String {
4001    db::private_helper()
4002}
4003"#,
4004            ),
4005            (
4006                "src/imported.rs",
4007                r#"use crate::db;
4008use tauri::command;
4009
4010#[command]
4011pub fn imported_command() -> String {
4012    db::imported_helper()
4013}
4014"#,
4015            ),
4016            (
4017                "src/unimported.rs",
4018                r#"use crate::db;
4019
4020#[command]
4021pub fn false_command() -> String {
4022    db::false_helper()
4023}
4024"#,
4025            ),
4026            (
4027                "src/db.rs",
4028                r#"pub fn helper() -> String { "live".to_string() }
4029pub fn imported_helper() -> String { "live".to_string() }
4030pub fn private_helper() -> String { "live".to_string() }
4031pub fn false_helper() -> String { "dead".to_string() }
4032"#,
4033            ),
4034        ]);
4035        let helper_target = format!("{}::helper", root.join("src/db.rs").display());
4036        let imported_helper_target =
4037            format!("{}::imported_helper", root.join("src/db.rs").display());
4038        let private_helper_target = format!("{}::private_helper", root.join("src/db.rs").display());
4039        let false_helper_target = format!("{}::false_helper", root.join("src/db.rs").display());
4040        let aggregate = scan(job(
4041            &root,
4042            paths.clone(),
4043            snapshot(
4044                paths,
4045                vec![
4046                    export(&root, "src/commands.rs", "get_primers", "function"),
4047                    export(&root, "src/commands.rs", "planted_dead", "function"),
4048                    export(&root, "src/imported.rs", "imported_command", "function"),
4049                    export(&root, "src/unimported.rs", "false_command", "function"),
4050                    export(&root, "src/db.rs", "helper", "function"),
4051                    export(&root, "src/db.rs", "imported_helper", "function"),
4052                    export(&root, "src/db.rs", "private_helper", "function"),
4053                    export(&root, "src/db.rs", "false_helper", "function"),
4054                ],
4055                vec![
4056                    outbound(&root, "src/commands.rs", "get_primers", &helper_target),
4057                    outbound(
4058                        &root,
4059                        "src/imported.rs",
4060                        "imported_command",
4061                        &imported_helper_target,
4062                    ),
4063                    outbound(
4064                        &root,
4065                        "src/commands.rs",
4066                        "private_command",
4067                        &private_helper_target,
4068                    ),
4069                    outbound(
4070                        &root,
4071                        "src/unimported.rs",
4072                        "false_command",
4073                        &false_helper_target,
4074                    ),
4075                ],
4076            ),
4077        ));
4078
4079        assert!(!aggregate_has_item(
4080            &aggregate,
4081            "src/commands.rs",
4082            "get_primers"
4083        ));
4084        assert!(!aggregate_has_item(&aggregate, "src/db.rs", "helper"));
4085        assert!(!aggregate_has_item(
4086            &aggregate,
4087            "src/imported.rs",
4088            "imported_command"
4089        ));
4090        assert!(!aggregate_has_item(
4091            &aggregate,
4092            "src/db.rs",
4093            "imported_helper"
4094        ));
4095        assert!(!aggregate_has_item(
4096            &aggregate,
4097            "src/db.rs",
4098            "private_helper"
4099        ));
4100        assert!(aggregate_has_item(
4101            &aggregate,
4102            "src/commands.rs",
4103            "planted_dead"
4104        ));
4105        assert!(aggregate_has_item(
4106            &aggregate,
4107            "src/unimported.rs",
4108            "false_command"
4109        ));
4110        assert!(aggregate_has_item(&aggregate, "src/db.rs", "false_helper"));
4111    }
4112
4113    #[test]
4114    fn rust_macro_token_liveness_rescues_bare_join_calls() {
4115        let aggregate = rust_entry_scan(
4116            &[(
4117                "src/main.rs",
4118                "fn main() { tokio::join!(fetch_a(), fetch_b()); }\nfn fetch_a() {}\nfn fetch_b() {}\nfn dead() {}\n",
4119            )],
4120            &[
4121                ("src/main.rs", "main", "function"),
4122                ("src/main.rs", "fetch_a", "function"),
4123                ("src/main.rs", "fetch_b", "function"),
4124                ("src/main.rs", "dead", "function"),
4125            ],
4126        );
4127
4128        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "fetch_a"));
4129        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "fetch_b"));
4130        assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4131    }
4132
4133    #[test]
4134    fn rust_macro_token_liveness_rescues_upper_camel_component_and_nested_call() {
4135        let aggregate = rust_entry_scan(
4136            &[(
4137                "src/main.rs",
4138                "fn main() { element! { Header { title() } } }\nstruct Header;\nfn title() {}\nfn dead() {}\n",
4139            )],
4140            &[
4141                ("src/main.rs", "main", "function"),
4142                ("src/main.rs", "Header", "struct"),
4143                ("src/main.rs", "title", "function"),
4144                ("src/main.rs", "dead", "function"),
4145            ],
4146        );
4147
4148        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "Header"));
4149        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "title"));
4150        assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4151    }
4152
4153    #[test]
4154    fn rust_macro_token_liveness_ignores_json_string_keys_but_keeps_values() {
4155        let aggregate = rust_entry_scan(
4156            &[(
4157                "src/main.rs",
4158                "fn main() { json!({\"dead_key\": compute_x()}); }\nfn compute_x() {}\nfn dead_key() {}\n",
4159            )],
4160            &[
4161                ("src/main.rs", "main", "function"),
4162                ("src/main.rs", "compute_x", "function"),
4163                ("src/main.rs", "dead_key", "function"),
4164            ],
4165        );
4166
4167        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "compute_x"));
4168        assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead_key"));
4169    }
4170
4171    #[test]
4172    fn rust_macro_token_liveness_resolves_path_qualified_calls() {
4173        let aggregate = rust_entry_scan(
4174            &[
4175                (
4176                    "src/main.rs",
4177                    "mod m;\nfn main() { wrapper!(m::helper()); }\n",
4178                ),
4179                ("src/m.rs", "pub fn helper() {}\npub fn dead() {}\n"),
4180            ],
4181            &[
4182                ("src/main.rs", "main", "function"),
4183                ("src/m.rs", "helper", "function"),
4184                ("src/m.rs", "dead", "function"),
4185            ],
4186        );
4187
4188        assert!(!aggregate_has_item(&aggregate, "src/m.rs", "helper"));
4189        assert!(aggregate_has_item(&aggregate, "src/m.rs", "dead"));
4190    }
4191
4192    #[test]
4193    fn rust_macro_token_liveness_rescues_turbofish_calls() {
4194        let aggregate = rust_entry_scan(
4195            &[(
4196                "src/main.rs",
4197                "fn main() { wrapper!(parse::<T>()); }\nstruct T;\nfn parse<T>() {}\nfn dead() {}\n",
4198            )],
4199            &[
4200                ("src/main.rs", "main", "function"),
4201                ("src/main.rs", "T", "struct"),
4202                ("src/main.rs", "parse", "function"),
4203                ("src/main.rs", "dead", "function"),
4204            ],
4205        );
4206
4207        assert!(!aggregate_has_item(&aggregate, "src/main.rs", "parse"));
4208        assert!(aggregate_has_item(&aggregate, "src/main.rs", "dead"));
4209    }
4210
4211    #[test]
4212    fn rust_macro_token_liveness_does_not_rescue_receiver_methods_or_bare_idents() {
4213        let aggregate = rust_entry_scan(
4214            &[
4215                (
4216                    "src/main.rs",
4217                    "mod other;\nfn main() { wrapper!(socket.recv(), recv); }\n",
4218                ),
4219                ("src/other.rs", "pub fn recv() {}\n"),
4220            ],
4221            &[
4222                ("src/main.rs", "main", "function"),
4223                ("src/other.rs", "recv", "function"),
4224            ],
4225        );
4226
4227        assert!(aggregate_has_item(&aggregate, "src/other.rs", "recv"));
4228    }
4229
4230    #[test]
4231    fn rust_macro_token_liveness_inside_dead_caller_does_not_rescue_target() {
4232        let aggregate = rust_entry_scan(
4233            &[(
4234                "src/main.rs",
4235                "fn main() {}\nfn unreachable() { wrapper!(target()); }\nfn target() {}\n",
4236            )],
4237            &[
4238                ("src/main.rs", "main", "function"),
4239                ("src/main.rs", "unreachable", "function"),
4240                ("src/main.rs", "target", "function"),
4241            ],
4242        );
4243
4244        assert!(aggregate_has_item(&aggregate, "src/main.rs", "unreachable"));
4245        assert!(aggregate_has_item(&aggregate, "src/main.rs", "target"));
4246    }
4247
4248    #[test]
4249    fn genuinely_unreachable_function_is_still_dead() {
4250        let (_temp_dir, root, paths) =
4251            fixture_project(&[("src/build.ts", "export function build() {}\n")]);
4252        let aggregate = scan(job(
4253            &root,
4254            paths.clone(),
4255            snapshot(
4256                paths,
4257                vec![export(&root, "src/build.ts", "build", "function")],
4258                Vec::new(),
4259            ),
4260        ));
4261
4262        assert_eq!(aggregate["count"], 1);
4263        assert_eq!(aggregate["items"][0]["symbol"], "build");
4264        assert_eq!(aggregate["uncertain_count"], 0);
4265    }
4266}