Skip to main content

aft/inspect/scanners/
dead_code.rs

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