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