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 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
605pub(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 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 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 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 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 ¤t,
1846 BTreeSet::new(),
1847 current.roots.iter().chain(¤t.dispatch_roots).cloned(),
1848 );
1849 return (current, false);
1850 };
1851
1852 current.reachable = incremental_reachable(previous, ¤t, 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(¤t.roots).cloned());
1926 frontier.extend(
1927 previous
1928 .dispatch_roots
1929 .symmetric_difference(¤t.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 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(¤t.dispatch_roots)
1993 .filter(|node| frontier.contains(*node))
1994 .cloned()
1995 .collect::<Vec<_>>();
1996
1997 for (source, targets) in ¤t.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 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 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 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 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 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 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 ¤t,
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 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 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 outbound(
5284 &root,
5285 "src/consumer.ts",
5286 "run",
5287 &dispatched_target("render", "service.render"),
5288 ),
5289 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}