Skip to main content

aft/inspect/scanners/
dead_code.rs

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