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