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