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