1use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, VecDeque};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::time::{Instant, UNIX_EPOCH};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10use crate::cache_freshness::{self, FileFreshness};
11use crate::callgraph::{resolve_module_path, resolve_reexported_symbol_target};
12use crate::calls::extract_type_references;
13use crate::imports::{parse_imports, specifier_imported_name, specifier_local_name};
14use crate::inspect::job::{
15 canonicalize_normalized, is_test_file, is_test_support_file, CALLGRAPH_PROVENANCE_REEXPORT,
16 DISPATCHED_CALLEE_SEPARATOR,
17};
18use crate::inspect::oxc_engine::{
19 analyze_file_facts, AnalyzeOptions, DynamicImportFact, ExportFact, FileFacts, FileId,
20 ImportFact, LivenessVerdict, OxcEngineResult, OxcFileVerdicts, OxcReExportContext,
21 ReExportFact, ReExportKind, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
22};
23use crate::inspect::{
24 CallgraphOutboundCall, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
25 InspectResult, InspectScanSuccess,
26};
27use crate::parser::{detect_language, grammar_for, LangId};
28
29use super::DEFAULT_EXPORT_MARKER_KIND;
30
31const MAX_DRILL_DOWN_ITEMS: usize = 100;
32pub(crate) const DEAD_CODE_FACTS_FORMAT_VERSION: u32 = 2;
33
34type ExportNode = (String, String);
35type OutboundCallsByCallerFile<'a> = BTreeMap<PathBuf, Vec<&'a CallgraphOutboundCall>>;
36
37#[derive(Debug, Default)]
38struct ImportedExportLiveness {
39 root_exports: Vec<ImportedExportContribution>,
40 namespace_exports: Vec<ImportedExportContribution>,
41}
42
43#[derive(Debug, Default)]
44struct FileAnalysis {
45 raw_imports: Vec<RawImportContribution>,
46 raw_reexports: Vec<RawReexportContribution>,
47 attribute_entry_points: Vec<String>,
48 type_ref_names: BTreeSet<String>,
49}
50
51#[derive(Default)]
52struct DeadCodeFileAnalyzer {
53 parsers: HashMap<LangId, tree_sitter::Parser>,
54}
55
56#[derive(Debug, Serialize)]
57struct OxcDeadCodeFactsPayload<'a> {
58 format_version: u32,
59 content_hash: &'a str,
60 exports: &'a [ExportFact],
61 imports: &'a [ImportFact],
62 re_exports: &'a [ReExportFact],
63 dynamic_imports: &'a [DynamicImportFact],
64 same_file_value_references: &'a BTreeSet<String>,
65 used_import_bindings: &'a BTreeSet<String>,
66 type_referenced_import_bindings: &'a BTreeSet<String>,
67 value_referenced_import_bindings: &'a BTreeSet<String>,
68 parse_error: &'a Option<String>,
69}
70
71impl DeadCodeFileAnalyzer {
72 fn analyze_file(&mut self, file: &Path, has_oxc_file: bool) -> FileAnalysis {
73 let Some(lang) = detect_language(file) else {
74 return FileAnalysis::default();
75 };
76 let needs_type_refs = supports_type_refs(lang);
77 let is_ts_js = matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript);
78 let needs_ts_raw_facts = is_ts_js && !has_oxc_file;
81 let needs_rust_reexports = matches!(lang, LangId::Rust);
82 let needs_rust_attribute_entry_points = matches!(lang, LangId::Rust);
83
84 if !needs_type_refs
85 && !needs_ts_raw_facts
86 && !needs_rust_reexports
87 && !needs_rust_attribute_entry_points
88 {
89 return FileAnalysis::default();
90 }
91
92 let Ok(source) = fs::read_to_string(file) else {
93 return FileAnalysis::default();
94 };
95 let needs_tree = needs_type_refs || needs_ts_raw_facts || needs_rust_attribute_entry_points;
96 let tree = needs_tree
97 .then(|| self.parse_source(lang, &source))
98 .flatten();
99
100 let type_ref_names = if needs_type_refs {
101 tree.as_ref()
102 .map(|tree| extract_type_references(&source, tree.root_node(), lang))
103 .unwrap_or_default()
104 } else {
105 BTreeSet::new()
106 };
107
108 let raw_imports = if needs_ts_raw_facts {
109 tree.as_ref()
110 .map(|tree| raw_imports_from_tree(&source, tree, lang))
111 .unwrap_or_default()
112 } else {
113 Vec::new()
114 };
115
116 let raw_reexports = if needs_ts_raw_facts {
117 tree.as_ref()
118 .map(|tree| ts_raw_reexport_contributions(&source, tree.root_node()))
119 .unwrap_or_default()
120 } else if needs_rust_reexports {
121 rust_raw_reexport_contributions(&source)
122 } else {
123 Vec::new()
124 };
125
126 let attribute_entry_points = if needs_rust_attribute_entry_points {
127 tree.as_ref()
128 .map(|tree| {
129 let mut roots = BTreeSet::new();
130 for entry in
131 crate::parser::rust_attribute_entry_points(&source, tree.root_node())
132 {
133 roots.insert(entry.name);
134 roots.insert(entry.scoped_name);
135 }
136 roots.into_iter().collect()
137 })
138 .unwrap_or_default()
139 } else {
140 Vec::new()
141 };
142
143 FileAnalysis {
144 raw_imports,
145 raw_reexports,
146 attribute_entry_points,
147 type_ref_names,
148 }
149 }
150
151 fn parse_source(&mut self, lang: LangId, source: &str) -> Option<tree_sitter::Tree> {
152 let parser = match self.parsers.entry(lang) {
153 Entry::Occupied(entry) => entry.into_mut(),
154 Entry::Vacant(entry) => {
155 let grammar = grammar_for(lang);
156 let mut parser = tree_sitter::Parser::new();
157 if parser.set_language(&grammar).is_err() {
158 return None;
159 }
160 entry.insert(parser)
161 }
162 };
163
164 parser.parse(source, None)
165 }
166}
167
168pub fn run_dead_code_scan(job: &InspectJob) -> InspectResult {
169 run_dead_code_scan_with_oxc_started(job, None, Instant::now())
170}
171
172pub(crate) fn run_dead_code_scan_with_oxc(
173 job: &InspectJob,
174 oxc_result: Option<&OxcEngineResult>,
175) -> InspectResult {
176 run_dead_code_scan_with_oxc_started(job, oxc_result, Instant::now())
177}
178
179fn run_dead_code_scan_with_oxc_started(
180 job: &InspectJob,
181 oxc_result: Option<&OxcEngineResult>,
182 started: Instant,
183) -> InspectResult {
184 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
185 let success = InspectScanSuccess {
186 scanned_files: job.scope_files.clone(),
187 contributions: Vec::new(),
188 aggregate: callgraph_unavailable_aggregate(job.scope_files.len()),
189 };
190 return InspectResult::success(job, success, started.elapsed());
191 };
192
193 let fallback_exports_by_file = fallback_export_contributions_by_file(job, snapshot);
194 let oxc_facts_by_file = oxc_result
195 .map(|result| {
196 result
197 .facts
198 .iter()
199 .cloned()
200 .map(|facts| (relative_path(&job.project_root, &facts.path), facts))
201 .collect::<BTreeMap<_, _>>()
202 })
203 .unwrap_or_default();
204 let oxc_parse_errors_by_file = oxc_result
205 .map(|result| {
206 result.errors.iter().fold(
207 BTreeMap::<String, Vec<String>>::new(),
208 |mut errors, error| {
209 errors
210 .entry(relative_path(&job.project_root, &error.file))
211 .or_default()
212 .push(error.message.clone());
213 errors
214 },
215 )
216 })
217 .unwrap_or_default();
218 let oxc_skipped_files = oxc_result
219 .map(|result| oxc_skipped_files_payload(&job.project_root, result))
220 .unwrap_or_default();
221
222 let contributions = job
223 .scope_files
224 .par_iter()
225 .map_init(DeadCodeFileAnalyzer::default, |file_analyzer, file| {
226 gather_file_contribution(
227 job,
228 file,
229 &fallback_exports_by_file,
230 &oxc_facts_by_file,
231 &oxc_parse_errors_by_file,
232 &oxc_skipped_files,
233 file_analyzer,
234 )
235 })
236 .collect::<Vec<_>>();
237
238 let public_api_files = collect_public_api_files(&job.project_root);
239 let roles = crate::inspect::entry_points::resolve_project_roles(&job.project_root);
240 let aggregate = aggregate_dead_code_contributions_with_snapshot(
241 &job.project_root,
242 snapshot,
243 &contributions,
244 &public_api_files,
245 &roles,
246 Some(MAX_DRILL_DOWN_ITEMS),
247 );
248 let success = InspectScanSuccess {
249 scanned_files: job.scope_files.clone(),
250 contributions,
251 aggregate,
252 };
253
254 InspectResult::success(job, success, started.elapsed())
255}
256
257fn fallback_export_contributions_by_file(
258 job: &InspectJob,
259 snapshot: &CallgraphSnapshot,
260) -> BTreeMap<String, Vec<ExportContribution>> {
261 let mut by_file: BTreeMap<String, Vec<ExportContribution>> = BTreeMap::new();
262 for export in &snapshot.exported_symbols {
263 if export.kind == DEFAULT_EXPORT_MARKER_KIND {
264 continue;
265 }
266 by_file
267 .entry(relative_path(&job.project_root, &export.file))
268 .or_default()
269 .push(ExportContribution {
270 symbol: export.symbol.clone(),
271 kind: export.kind.clone(),
272 line: export.line,
273 is_type_like: is_type_like_kind(&export.kind),
274 is_entry_point: false,
275 has_references: false,
276 test_only_reference_files: Vec::new(),
277 verdict: None,
278 reason: None,
279 provenance: None,
280 also_reexported: Vec::new(),
281 });
282 }
283 by_file
284}
285
286fn group_outbound_calls_by_caller_file<'a>(
287 project_root: &Path,
288 outbound_calls: &'a [CallgraphOutboundCall],
289) -> OutboundCallsByCallerFile<'a> {
290 let mut by_file: OutboundCallsByCallerFile<'a> = BTreeMap::new();
291 for call in outbound_calls {
292 by_file
293 .entry(normalize_absolute(project_root, &call.caller_file))
294 .or_default()
295 .push(call);
296 }
297 by_file
298}
299
300fn gather_file_contribution(
301 job: &InspectJob,
302 file: &Path,
303 fallback_exports_by_file: &BTreeMap<String, Vec<ExportContribution>>,
304 oxc_facts_by_file: &BTreeMap<String, FileFacts>,
305 oxc_parse_errors_by_file: &BTreeMap<String, Vec<String>>,
306 oxc_skipped_files: &[Value],
307 file_analyzer: &mut DeadCodeFileAnalyzer,
308) -> FileContribution {
309 let file_name = relative_path(&job.project_root, file);
310 let oxc_facts = oxc_facts_by_file.get(&file_name);
311 let exports = oxc_facts
312 .map(oxc_fact_export_contributions)
313 .unwrap_or_else(|| {
314 fallback_exports_by_file
315 .get(&file_name)
316 .cloned()
317 .unwrap_or_default()
318 });
319 let FileAnalysis {
320 raw_imports,
321 raw_reexports,
322 attribute_entry_points,
323 type_ref_names,
324 } = file_analyzer.analyze_file(file, oxc_facts.is_some());
325
326 let mut payload = json!({
327 "file": file_name,
328 "facts_format_version": DEAD_CODE_FACTS_FORMAT_VERSION,
329 "exports": exports
330 .iter()
331 .map(|export| {
332 let mut value = json!({
333 "symbol": export.symbol,
334 "kind": export.kind,
335 "line": export.line,
336 });
337 if export.is_type_like {
338 value["is_type_like"] = json!(true);
339 }
340 value
341 })
342 .collect::<Vec<_>>(),
343 });
344
345 if !raw_imports.is_empty() {
346 payload["raw_imports"] = json!(raw_imports);
347 }
348 if !raw_reexports.is_empty() {
349 payload["raw_reexports"] = json!(raw_reexports);
350 }
351 if !attribute_entry_points.is_empty() {
352 payload["attribute_entry_points"] = json!(attribute_entry_points);
353 }
354 if let Some(facts) = oxc_facts {
355 payload["provenance"] = json!(OXC_PROVENANCE);
356 payload["oxc_facts"] = json!(OxcDeadCodeFactsPayload {
357 format_version: FACTS_FORMAT_VERSION,
358 content_hash: &facts.content_hash,
359 exports: &facts.exports,
360 imports: &facts.imports,
361 re_exports: &facts.re_exports,
362 dynamic_imports: &facts.dynamic_imports,
363 same_file_value_references: &facts.same_file_value_references,
364 used_import_bindings: &facts.used_import_bindings,
365 type_referenced_import_bindings: &facts.type_referenced_import_bindings,
366 value_referenced_import_bindings: &facts.value_referenced_import_bindings,
367 parse_error: &facts.parse_error,
368 });
369 }
370 if let Some(parse_errors) = oxc_parse_errors_by_file.get(&file_name) {
371 payload["parse_errors"] = json!(parse_errors
372 .iter()
373 .map(|message| json!({
374 "file": file_name,
375 "message": message,
376 }))
377 .collect::<Vec<_>>());
378 }
379 if oxc_facts.is_some() && !oxc_skipped_files.is_empty() {
380 payload["skipped_files"] = Value::Array(oxc_skipped_files.to_vec());
381 }
382
383 FileContribution::new(
384 InspectCategory::DeadCode,
385 file.to_path_buf(),
386 collect_freshness(file),
387 payload,
388 )
389 .with_type_ref_names(type_ref_names)
390}
391
392fn oxc_fact_export_contributions(facts: &FileFacts) -> Vec<ExportContribution> {
393 facts
394 .exports
395 .iter()
396 .map(|export| ExportContribution {
397 symbol: export.name.as_symbol(),
398 kind: export.kind.clone(),
399 line: export.line,
400 is_type_like: export.is_type_only || is_type_like_kind(&export.kind),
401 is_entry_point: false,
402 has_references: false,
403 test_only_reference_files: Vec::new(),
404 verdict: None,
405 reason: None,
406 provenance: None,
407 also_reexported: Vec::new(),
408 })
409 .collect()
410}
411
412fn oxc_export_contributions(file: &OxcFileVerdicts) -> Vec<ExportContribution> {
413 file.exports
414 .iter()
415 .map(|export| ExportContribution {
416 symbol: export.symbol.clone(),
417 kind: export.kind.clone(),
418 line: export.line,
419 is_type_like: is_type_like_kind(&export.kind),
420 is_entry_point: matches!(export.verdict, LivenessVerdict::Used),
421 has_references: export.has_references,
422 test_only_reference_files: export.test_only_reference_files.clone(),
423 verdict: Some(export.verdict),
424 reason: Some(export.reason.clone()),
425 provenance: Some(export.provenance.clone()),
426 also_reexported: export.also_reexported.clone(),
427 })
428 .collect()
429}
430
431fn oxc_skipped_files_payload(project_root: &Path, oxc_result: &OxcEngineResult) -> Vec<Value> {
432 oxc_result
433 .skipped_outside_root
434 .iter()
435 .map(|path| {
436 json!({
437 "file": relative_path(project_root, path),
438 "reason": "outside_project_root",
439 })
440 })
441 .collect()
442}
443
444pub(crate) fn callgraph_unavailable_aggregate(scanned_files: usize) -> serde_json::Value {
445 json!({
446 "count": 0,
447 "items": [],
448 "by_language": {},
449 "drill_down_capped": false,
450 "uncertain_count": 0,
451 "uncertain_items": [],
452 "callgraph_available": false,
453 "scanned_files": scanned_files,
454 "notes": ["callgraph_unavailable"],
455 })
456}
457
458pub(crate) fn aggregate_dead_code_contributions_with_snapshot(
459 project_root: &Path,
460 snapshot: &CallgraphSnapshot,
461 contributions: &[FileContribution],
462 public_api_files: &BTreeSet<String>,
463 roles: &crate::inspect::entry_points::ProjectRoles,
464 drill_down_limit: Option<usize>,
465) -> serde_json::Value {
466 let parsed = parse_dead_code_contributions(contributions);
467 let materialized =
468 materialize_dead_code_contributions(project_root, snapshot, parsed, public_api_files);
469 aggregate_materialized_dead_code_contributions(
470 &materialized,
471 public_api_files,
472 roles,
473 drill_down_limit,
474 contributions.len(),
475 )
476}
477
478fn parse_dead_code_contributions(contributions: &[FileContribution]) -> Vec<DeadCodeContribution> {
479 contributions
480 .iter()
481 .filter_map(|contribution| {
482 serde_json::from_value::<DeadCodeContribution>(contribution.contribution.clone()).ok()
483 })
484 .collect::<Vec<_>>()
485}
486
487fn materialize_dead_code_contributions(
488 project_root: &Path,
489 snapshot: &CallgraphSnapshot,
490 parsed: Vec<DeadCodeContribution>,
491 public_api_files: &BTreeSet<String>,
492) -> Vec<DeadCodeContribution> {
493 let liveness_root_files = snapshot
494 .entry_points
495 .iter()
496 .map(|file| relative_path(project_root, file))
497 .collect::<BTreeSet<_>>();
498 let executable_root_exports_by_file =
499 crate::inspect::entry_points::resolve_entry_points(project_root)
500 .executable_root_exports()
501 .into_iter()
502 .map(|(file, exports)| (relative_path(project_root, &file), exports))
503 .collect::<BTreeMap<_, _>>();
504 let attribute_roots_from_snapshot = snapshot
505 .entry_point_symbols
506 .iter()
507 .map(|(file, symbols)| (relative_path(project_root, file), symbols.clone()))
508 .collect::<BTreeMap<_, _>>();
509 let (exported_symbols_by_file, files_by_exported_symbol, default_export_symbols_by_file) =
510 exported_symbol_indexes_from_contributions(project_root, snapshot, &parsed);
511 let outbound_calls_by_caller_file =
512 group_outbound_calls_by_caller_file(project_root, &snapshot.outbound_calls);
513 let oxc_by_file = oxc_verdicts_by_file(project_root, snapshot, &parsed, public_api_files);
514
515 parsed
516 .into_iter()
517 .map(|mut contribution| {
518 let _facts_format_version = contribution.facts_format_version;
519 let absolute_file = project_root.join(&contribution.file);
520 let normalized_file = normalize_absolute(project_root, &absolute_file);
521 let outbound_calls_for_file = outbound_calls_by_caller_file
522 .get(&normalized_file)
523 .map(Vec::as_slice)
524 .unwrap_or(&[]);
525 let mut exports = oxc_by_file
526 .get(&contribution.file)
527 .map(oxc_export_contributions)
528 .unwrap_or_else(|| contribution.exports.clone());
529
530 let mut internal_calls = outbound_calls_for_file
531 .iter()
532 .copied()
533 .filter_map(|call| {
534 project_internal_call(
535 project_root,
536 call,
537 &contribution.file,
538 &exported_symbols_by_file,
539 &files_by_exported_symbol,
540 )
541 })
542 .collect::<Vec<_>>();
543 internal_calls.extend(resolve_raw_reexport_liveness_edges(
544 project_root,
545 &contribution.file,
546 &contribution.raw_reexports,
547 &exported_symbols_by_file,
548 &default_export_symbols_by_file,
549 ));
550 if let Some(oxc_facts) = &contribution.oxc_facts {
551 internal_calls.extend(resolve_oxc_reexport_liveness_edges(
552 project_root,
553 &contribution.file,
554 oxc_facts,
555 &exported_symbols_by_file,
556 &default_export_symbols_by_file,
557 ));
558 }
559 sort_dedup_internal_calls(&mut internal_calls);
560
561 let dispatched_method_names = outbound_calls_for_file
562 .iter()
563 .copied()
564 .filter_map(dispatched_method_name_from_call)
565 .collect::<BTreeSet<_>>()
566 .into_iter()
567 .collect::<Vec<_>>();
568 let imported_export_liveness = resolve_raw_imported_export_liveness_roots(
569 project_root,
570 &contribution.file,
571 &contribution.raw_imports,
572 &exported_symbols_by_file,
573 &default_export_symbols_by_file,
574 );
575 let mut attribute_entry_points = contribution
576 .attribute_entry_points
577 .iter()
578 .cloned()
579 .collect::<BTreeSet<_>>();
580 if let Some(snapshot_roots) = attribute_roots_from_snapshot.get(&contribution.file) {
581 attribute_entry_points.extend(snapshot_roots.iter().cloned());
582 }
583 let liveness_roots = liveness_roots_for_file(
584 &contribution.file,
585 &exports,
586 &internal_calls,
587 &attribute_entry_points,
588 executable_root_exports_by_file.get(&contribution.file),
589 liveness_root_files.contains(&contribution.file),
590 public_api_files.contains(&contribution.file),
591 );
592 for export in &mut exports {
593 export.is_entry_point = liveness_roots.contains(&export.symbol);
594 }
595
596 contribution.exports = exports;
597 contribution.internal_calls = internal_calls
598 .into_iter()
599 .map(InternalCallContribution::from)
600 .collect();
601 contribution.liveness_roots = liveness_roots;
602 contribution.imported_exports = imported_export_liveness.root_exports;
603 contribution.namespace_imported_exports = imported_export_liveness.namespace_exports;
604 contribution.dispatched_method_names = dispatched_method_names;
605 contribution
606 })
607 .collect()
608}
609
610fn exported_symbol_indexes_from_contributions(
611 project_root: &Path,
612 snapshot: &CallgraphSnapshot,
613 contributions: &[DeadCodeContribution],
614) -> (
615 BTreeMap<String, BTreeSet<String>>,
616 BTreeMap<String, BTreeSet<String>>,
617 BTreeMap<String, String>,
618) {
619 let mut exported_symbols_by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
620 let mut files_by_exported_symbol: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
621 let mut default_export_symbols_by_file: BTreeMap<String, String> = BTreeMap::new();
622
623 for contribution in contributions {
624 for export in &contribution.exports {
625 exported_symbols_by_file
626 .entry(contribution.file.clone())
627 .or_default()
628 .insert(export.symbol.clone());
629 files_by_exported_symbol
630 .entry(export.symbol.clone())
631 .or_default()
632 .insert(contribution.file.clone());
633 }
634 }
635
636 for export in &snapshot.exported_symbols {
637 let file = relative_path(project_root, &export.file);
638 if export.kind == DEFAULT_EXPORT_MARKER_KIND {
639 default_export_symbols_by_file.insert(file, export.symbol.clone());
640 }
641 }
642
643 (
644 exported_symbols_by_file,
645 files_by_exported_symbol,
646 default_export_symbols_by_file,
647 )
648}
649
650fn oxc_verdicts_by_file(
651 project_root: &Path,
652 snapshot: &CallgraphSnapshot,
653 contributions: &[DeadCodeContribution],
654 public_api_files: &BTreeSet<String>,
655) -> BTreeMap<String, OxcFileVerdicts> {
656 let facts = contributions
657 .iter()
658 .filter_map(|contribution| {
659 let oxc_facts = contribution.oxc_facts.as_ref()?;
660 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
661 return None;
662 }
663 Some(FileFacts {
664 file_id: FileId(0),
665 path: canonical_or_normalized(project_root, &project_root.join(&contribution.file)),
666 content_hash: oxc_facts.content_hash.clone(),
667 exports: oxc_facts.exports.clone(),
668 imports: oxc_facts.imports.clone(),
669 re_exports: oxc_facts.re_exports.clone(),
670 dynamic_imports: oxc_facts.dynamic_imports.clone(),
671 same_file_value_references: oxc_facts.same_file_value_references.clone(),
672 used_import_bindings: oxc_facts.used_import_bindings.clone(),
673 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
674 value_referenced_import_bindings: oxc_facts
675 .value_referenced_import_bindings
676 .clone(),
677 parse_error: oxc_facts.parse_error.clone(),
678 })
679 })
680 .collect::<Vec<_>>();
681 if facts.is_empty() {
682 return BTreeMap::new();
683 }
684
685 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
686 analyze_file_facts(
687 project_root,
688 facts,
689 AnalyzeOptions {
690 entry_points: snapshot.entry_points.iter().cloned().collect(),
691 public_api_files: public_api_files
692 .iter()
693 .map(|file| project_root.join(file))
694 .collect(),
695 executable_root_exports: entry_points.executable_root_exports(),
696 force_reparse_files: Vec::new(),
697 entry_reachability: true,
698 },
699 Vec::new(),
700 )
701 .files
702 .into_iter()
703 .map(|file| (file.relative_file.clone(), file))
704 .collect()
705}
706
707fn sort_dedup_internal_calls(internal_calls: &mut Vec<InternalCall>) {
708 internal_calls.sort_by(|left, right| {
709 left.caller_symbol
710 .cmp(&right.caller_symbol)
711 .then_with(|| left.file.cmp(&right.file))
712 .then_with(|| left.symbol.cmp(&right.symbol))
713 .then_with(|| left.line.cmp(&right.line))
714 .then_with(|| left.provenance.cmp(&right.provenance))
715 });
716 internal_calls.dedup_by(|left, right| {
717 left.caller_symbol == right.caller_symbol
718 && left.file == right.file
719 && left.symbol == right.symbol
720 && left.line == right.line
721 && left.provenance == right.provenance
722 });
723}
724
725fn aggregate_materialized_dead_code_contributions(
726 parsed: &[DeadCodeContribution],
727 public_api_files: &BTreeSet<String>,
728 roles: &crate::inspect::entry_points::ProjectRoles,
729 drill_down_limit: Option<usize>,
730 scanned_files: usize,
731) -> serde_json::Value {
732 let edges_by_source = edges_by_source(parsed);
733 let dispatched_method_names = collect_dispatched_method_names(parsed);
734 let reachable = reachable_exports(parsed, &edges_by_source, &dispatched_method_names);
735 let referenced_type_names = collect_referenced_type_names(parsed);
736
737 let mut by_language: BTreeMap<String, usize> = BTreeMap::new();
738 let mut count = 0usize;
739 let mut dead_items = Vec::new();
740 let mut test_only_count = 0usize;
741 let mut test_only_items = Vec::new();
742 let mut uncertain_count = 0usize;
743 let mut uncertain_items: Vec<serde_json::Value> = Vec::new();
744 for contribution in parsed {
745 if is_test_support_file(&contribution.file) {
749 continue;
750 }
751 let is_public_api_file = public_api_files.contains(&contribution.file);
752 for export in &contribution.exports {
753 if export_uses_oxc(export) {
754 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
755 LivenessVerdict::Used => {
756 if !is_test_file(&contribution.file)
757 && !export.test_only_reference_files.is_empty()
758 {
759 test_only_count += 1;
760 let mut item = json!({
761 "file": contribution.file,
762 "symbol": export.symbol,
763 "kind": export.kind,
764 "line": export.line,
765 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
766 "used_by": export.test_only_reference_files,
767 });
768 add_reexport_contexts(&mut item, &export.also_reexported);
769 test_only_items.push(item);
770 }
771 continue;
772 }
773 LivenessVerdict::Uncertain => {
774 uncertain_count += 1;
775 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
776 let mut item = json!({
777 "file": contribution.file,
778 "symbol": export.symbol,
779 "kind": export.kind,
780 "line": export.line,
781 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
782 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
783 });
784 add_reexport_contexts(&mut item, &export.also_reexported);
785 uncertain_items.push(item);
786 }
787 continue;
788 }
789 LivenessVerdict::Unused => {
790 if !is_test_file(&contribution.file)
791 && !export.test_only_reference_files.is_empty()
792 {
793 test_only_count += 1;
794 let mut item = json!({
795 "file": contribution.file,
796 "symbol": export.symbol,
797 "kind": export.kind,
798 "line": export.line,
799 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
800 "used_by": export.test_only_reference_files,
801 });
802 add_reexport_contexts(&mut item, &export.also_reexported);
803 test_only_items.push(item);
804 continue;
805 }
806 if export.has_references {
807 continue;
808 }
809 }
810 }
811 } else {
812 let node = (contribution.file.clone(), export.symbol.clone());
813 if reachable.contains(&node)
814 || is_public_api_file
815 || dispatched_method_names.contains(symbol_liveness_name(&export.symbol))
816 {
817 continue;
818 }
819
820 if (export.is_type_like || is_type_like_kind(&export.kind))
821 && referenced_type_names.contains(symbol_liveness_name(&export.symbol))
822 {
823 continue;
824 }
825 }
826
827 count += 1;
828 *by_language
829 .entry(language_for_file(&contribution.file).to_string())
830 .or_default() += 1;
831 let mut item = json!({
835 "file": contribution.file,
836 "symbol": export.symbol,
837 "kind": export.kind,
838 "line": export.line,
839 });
840 if let Some(provenance) = &export.provenance {
841 item["provenance"] = json!(provenance);
842 }
843 add_reexport_contexts(&mut item, &export.also_reexported);
844 dead_items.push(item);
845 }
846 }
847
848 let dead_items =
849 crate::inspect::entry_points::rank_and_truncate_items(dead_items, roles, drill_down_limit);
850 let top = crate::inspect::entry_points::top_preview_symbols(&dead_items);
851 let test_only_items = crate::inspect::entry_points::rank_and_truncate_items(
852 test_only_items,
853 roles,
854 drill_down_limit,
855 );
856 let test_only_top = test_only_items
857 .iter()
858 .take(crate::inspect::entry_points::TOP_PREVIEW_ITEMS)
859 .cloned()
860 .collect::<Vec<_>>();
861
862 let (parse_errors, skipped_files) = dead_code_honesty_fields(parsed);
863 let mut aggregate = json!({
864 "count": count,
865 "items": dead_items,
866 "top": top,
867 "test_only_count": test_only_count,
868 "test_only_items": test_only_items,
869 "test_only_top": test_only_top,
870 "by_language": by_language,
871 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
872 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
873 "uncertain_count": uncertain_count,
874 "uncertain_items": uncertain_items,
875 "callgraph_available": true,
876 "scanned_files": scanned_files,
877 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
878 });
879 if !parse_errors.is_empty() {
880 aggregate["parse_errors"] = Value::Array(parse_errors);
881 }
882 if !skipped_files.is_empty() {
883 aggregate["skipped_files"] = Value::Array(skipped_files);
884 }
885 aggregate
886}
887
888fn add_reexport_contexts(item: &mut Value, contexts: &[OxcReExportContext]) {
889 if !contexts.is_empty() {
890 item["also_reexported"] = json!(contexts);
891 }
892}
893
894fn export_uses_oxc(export: &ExportContribution) -> bool {
895 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
896}
897
898fn dead_code_honesty_fields(parsed: &[DeadCodeContribution]) -> (Vec<Value>, Vec<Value>) {
899 let mut parse_error_keys = BTreeSet::new();
900 let mut parse_errors = Vec::new();
901 let mut skipped_file_keys = BTreeSet::new();
902 let mut skipped_files = Vec::new();
903 for contribution in parsed {
904 for value in &contribution.parse_errors {
905 let key = value.to_string();
906 if parse_error_keys.insert(key) {
907 parse_errors.push(value.clone());
908 }
909 }
910 for value in &contribution.skipped_files {
911 let key = value.to_string();
912 if skipped_file_keys.insert(key) {
913 skipped_files.push(value.clone());
914 }
915 }
916 }
917 (parse_errors, skipped_files)
918}
919
920fn edges_by_source(
921 contributions: &[DeadCodeContribution],
922) -> BTreeMap<ExportNode, BTreeSet<ExportNode>> {
923 let mut edges: BTreeMap<ExportNode, BTreeSet<ExportNode>> = BTreeMap::new();
924
925 for contribution in contributions {
926 for call in &contribution.internal_calls {
927 if call.caller_symbol.is_empty() {
935 continue;
936 }
937 let target = (call.file.clone(), call.symbol.clone());
938 let source = (contribution.file.clone(), call.caller_symbol.clone());
939 edges.entry(source).or_default().insert(target);
940 }
941 }
942
943 edges
944}
945
946fn collect_dispatched_method_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
947 contributions
948 .iter()
949 .flat_map(|contribution| contribution.dispatched_method_names.iter().cloned())
950 .collect()
951}
952
953fn collect_referenced_type_names(contributions: &[DeadCodeContribution]) -> BTreeSet<String> {
954 contributions
965 .iter()
966 .flat_map(|contribution| contribution.type_ref_names.iter().cloned())
967 .collect()
968}
969
970fn reachable_exports(
971 contributions: &[DeadCodeContribution],
972 edges_by_source: &BTreeMap<ExportNode, BTreeSet<ExportNode>>,
973 dispatched_method_names: &BTreeSet<String>,
974) -> BTreeSet<ExportNode> {
975 let imported_exports_by_file = imported_exports_by_file(contributions);
976 let namespace_imports_by_file = namespace_imported_exports_by_file(contributions);
977 let mut expanded_file_imports = BTreeSet::new();
978 let mut reachable = BTreeSet::new();
979 let mut queue = VecDeque::new();
980
981 for contribution in contributions {
982 for root in &contribution.liveness_roots {
983 queue.push_back((contribution.file.clone(), root.clone()));
984 }
985 for export in &contribution.exports {
986 if export.is_entry_point {
987 queue.push_back((contribution.file.clone(), export.symbol.clone()));
988 }
989 }
990 }
991
992 for source in edges_by_source.keys() {
1005 if dispatched_method_names.contains(symbol_liveness_name(&source.1)) {
1006 queue.push_back(source.clone());
1007 }
1008 }
1009
1010 while let Some(node) = queue.pop_front() {
1011 if !reachable.insert(node.clone()) {
1012 continue;
1013 }
1014 if expanded_file_imports.insert(node.0.clone()) {
1015 if let Some(targets) = imported_exports_by_file.get(&node.0) {
1021 for target in targets {
1022 if !reachable.contains(target) {
1023 queue.push_back(target.clone());
1024 }
1025 }
1026 }
1027
1028 if let Some(targets) = namespace_imports_by_file.get(&node.0) {
1032 for target in targets {
1033 if !reachable.contains(target) {
1034 queue.push_back(target.clone());
1035 }
1036 }
1037 }
1038 }
1039 if let Some(targets) = edges_by_source.get(&node) {
1040 for target in targets {
1041 if !reachable.contains(target) {
1042 queue.push_back(target.clone());
1043 }
1044 }
1045 }
1046 }
1047
1048 reachable
1049}
1050
1051fn imported_exports_by_file(
1052 contributions: &[DeadCodeContribution],
1053) -> BTreeMap<String, BTreeSet<ExportNode>> {
1054 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1055
1056 for contribution in contributions {
1057 if contribution.imported_exports.is_empty() {
1058 continue;
1059 }
1060 by_file
1061 .entry(contribution.file.clone())
1062 .or_default()
1063 .extend(
1064 contribution
1065 .imported_exports
1066 .iter()
1067 .map(|root| (root.file.clone(), root.symbol.clone())),
1068 );
1069 }
1070
1071 by_file
1072}
1073
1074fn namespace_imported_exports_by_file(
1075 contributions: &[DeadCodeContribution],
1076) -> BTreeMap<String, BTreeSet<ExportNode>> {
1077 let mut by_file: BTreeMap<String, BTreeSet<ExportNode>> = BTreeMap::new();
1078
1079 for contribution in contributions {
1080 if contribution.namespace_imported_exports.is_empty() {
1081 continue;
1082 }
1083 by_file
1084 .entry(contribution.file.clone())
1085 .or_default()
1086 .extend(
1087 contribution
1088 .namespace_imported_exports
1089 .iter()
1090 .map(|root| (root.file.clone(), root.symbol.clone())),
1091 );
1092 }
1093
1094 by_file
1095}
1096
1097fn project_internal_call(
1098 project_root: &Path,
1099 call: &CallgraphOutboundCall,
1100 caller_file: &str,
1101 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1102 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1103) -> Option<InternalCall> {
1104 let target = parse_target(project_root, &call.target);
1105 let symbol = target.symbol?;
1106 let file = match target.file {
1107 Some(file) => file,
1115 None => resolve_unqualified_target(
1116 caller_file,
1117 &symbol,
1118 exported_symbols_by_file,
1119 files_by_exported_symbol,
1120 )?,
1121 };
1122
1123 Some(InternalCall {
1124 caller_symbol: call.caller_symbol.clone(),
1125 file,
1126 symbol,
1127 line: call.line,
1128 provenance: call.provenance.clone(),
1129 })
1130}
1131
1132fn raw_imports_from_tree(
1133 source: &str,
1134 tree: &tree_sitter::Tree,
1135 lang: LangId,
1136) -> Vec<RawImportContribution> {
1137 parse_imports(source, tree, lang)
1138 .imports
1139 .into_iter()
1140 .map(|import| RawImportContribution {
1141 source: import.module_path,
1142 names: import.names,
1143 default_import: import.default_import,
1144 namespace_import: import.namespace_import,
1145 })
1146 .collect()
1147}
1148
1149fn ts_raw_reexport_contributions(
1150 source: &str,
1151 root: tree_sitter::Node,
1152) -> Vec<RawReexportContribution> {
1153 let mut reexports = Vec::new();
1154 let mut cursor = root.walk();
1155 if !cursor.goto_first_child() {
1156 return reexports;
1157 }
1158
1159 loop {
1160 let node = cursor.node();
1161 if node.kind() == "export_statement" {
1162 if let Some(module_path) = export_source_module(source, node) {
1163 let line = (node.start_position().row + 1) as u32;
1164 let raw_export = node_text(source, node).trim();
1165 for specifier in ts_reexport_specifiers(raw_export) {
1166 reexports.push(RawReexportContribution {
1167 language: "ts".to_string(),
1168 source: module_path.clone(),
1169 kind: "named".to_string(),
1170 imported: Some(specifier.imported),
1171 exported: Some(specifier.exported),
1172 line,
1173 });
1174 }
1175 if raw_export.contains('*') {
1176 if let Some(namespace_export) = ts_namespace_reexport_name(raw_export) {
1177 reexports.push(RawReexportContribution {
1178 language: "ts".to_string(),
1179 source: module_path.clone(),
1180 kind: "namespace".to_string(),
1181 imported: Some("*".to_string()),
1182 exported: Some(namespace_export),
1183 line,
1184 });
1185 } else {
1186 reexports.push(RawReexportContribution {
1187 language: "ts".to_string(),
1188 source: module_path.clone(),
1189 kind: "star".to_string(),
1190 imported: Some("*".to_string()),
1191 exported: None,
1192 line,
1193 });
1194 }
1195 }
1196 }
1197 }
1198
1199 if !cursor.goto_next_sibling() {
1200 break;
1201 }
1202 }
1203
1204 reexports
1205}
1206
1207fn rust_raw_reexport_contributions(source: &str) -> Vec<RawReexportContribution> {
1208 rust_pub_use_statements(source)
1209 .into_iter()
1210 .flat_map(|(statement, line)| {
1211 rust_reexport_specifiers(&statement)
1212 .into_iter()
1213 .map(move |specifier| RawReexportContribution {
1214 language: "rust".to_string(),
1215 source: specifier.module_path.join("::"),
1216 kind: if specifier.imported == "*" {
1217 "star".to_string()
1218 } else {
1219 "named".to_string()
1220 },
1221 imported: Some(specifier.imported),
1222 exported: Some(specifier.exported),
1223 line,
1224 })
1225 })
1226 .collect()
1227}
1228
1229fn resolve_raw_reexport_liveness_edges(
1230 project_root: &Path,
1231 file_name: &str,
1232 raw_reexports: &[RawReexportContribution],
1233 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1234 default_export_symbols_by_file: &BTreeMap<String, String>,
1235) -> Vec<InternalCall> {
1236 let mut edges = Vec::new();
1237 let file = project_root.join(file_name);
1238 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1239
1240 for raw in raw_reexports {
1241 match raw.language.as_str() {
1242 "ts" => {
1243 let Some(module_entry) = resolve_import_module_path(from_dir, &raw.source) else {
1244 continue;
1245 };
1246 edges.extend(resolve_reexport_fact_edge(
1247 project_root,
1248 file_name,
1249 &module_entry,
1250 raw.kind.as_str(),
1251 raw.imported.as_deref(),
1252 raw.exported.as_deref(),
1253 raw.line,
1254 exported_symbols_by_file,
1255 default_export_symbols_by_file,
1256 ));
1257 }
1258 "rust" => {
1259 let module_path = raw
1260 .source
1261 .split("::")
1262 .filter(|segment| !segment.is_empty())
1263 .map(str::to_string)
1264 .collect::<Vec<_>>();
1265 let Some(module_entry) =
1266 rust_module_entry_from_file(project_root, file_name, &module_path)
1267 else {
1268 continue;
1269 };
1270 edges.extend(resolve_reexport_fact_edge(
1271 project_root,
1272 file_name,
1273 &module_entry,
1274 raw.kind.as_str(),
1275 raw.imported.as_deref(),
1276 raw.exported.as_deref(),
1277 raw.line,
1278 exported_symbols_by_file,
1279 default_export_symbols_by_file,
1280 ));
1281 }
1282 _ => {}
1283 }
1284 }
1285
1286 edges
1287}
1288
1289fn resolve_oxc_reexport_liveness_edges(
1290 project_root: &Path,
1291 file_name: &str,
1292 oxc_facts: &OxcFactsContribution,
1293 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1294 default_export_symbols_by_file: &BTreeMap<String, String>,
1295) -> Vec<InternalCall> {
1296 let file = project_root.join(file_name);
1297 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1298 let mut edges = Vec::new();
1299 for fact in &oxc_facts.re_exports {
1300 let Some(module_entry) = resolve_import_module_path(from_dir, &fact.source) else {
1301 continue;
1302 };
1303 let kind = match fact.kind {
1304 ReExportKind::Named => "named",
1305 ReExportKind::Star => "star",
1306 ReExportKind::Namespace => "namespace",
1307 };
1308 edges.extend(resolve_reexport_fact_edge(
1309 project_root,
1310 file_name,
1311 &module_entry,
1312 kind,
1313 fact.imported_name.as_deref(),
1314 fact.exported_name.as_deref(),
1315 fact.line,
1316 exported_symbols_by_file,
1317 default_export_symbols_by_file,
1318 ));
1319 }
1320 edges
1321}
1322
1323#[allow(clippy::too_many_arguments)]
1324fn resolve_reexport_fact_edge(
1325 project_root: &Path,
1326 file_name: &str,
1327 module_entry: &Path,
1328 kind: &str,
1329 imported: Option<&str>,
1330 exported: Option<&str>,
1331 line: u32,
1332 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1333 default_export_symbols_by_file: &BTreeMap<String, String>,
1334) -> Vec<InternalCall> {
1335 match kind {
1336 "star" => reexport_edges_for_all_target_symbols(
1337 project_root,
1338 file_name,
1339 "",
1340 module_entry,
1341 line,
1342 exported_symbols_by_file,
1343 default_export_symbols_by_file,
1344 true,
1345 ),
1346 "namespace" => {
1347 let namespace_export = exported.unwrap_or_default();
1348 if namespace_export.is_empty()
1349 || !file_exports_symbol(file_name, namespace_export, exported_symbols_by_file)
1350 {
1351 return Vec::new();
1352 }
1353 reexport_edges_for_all_target_symbols(
1354 project_root,
1355 file_name,
1356 namespace_export,
1357 module_entry,
1358 line,
1359 exported_symbols_by_file,
1360 default_export_symbols_by_file,
1361 false,
1362 )
1363 }
1364 _ => {
1365 let imported = imported.unwrap_or_default();
1366 let exported = exported.unwrap_or(imported);
1367 if imported.is_empty()
1368 || exported.is_empty()
1369 || !file_exports_symbol(file_name, exported, exported_symbols_by_file)
1370 {
1371 return Vec::new();
1372 }
1373 resolve_imported_export_liveness_root(
1374 project_root,
1375 module_entry,
1376 imported,
1377 exported_symbols_by_file,
1378 default_export_symbols_by_file,
1379 )
1380 .map(|(target_file, target_symbol)| {
1381 vec![InternalCall {
1382 caller_symbol: exported.to_string(),
1383 file: target_file,
1384 symbol: target_symbol,
1385 line,
1386 provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
1387 }]
1388 })
1389 .unwrap_or_default()
1390 }
1391 }
1392}
1393
1394fn rust_module_entry_from_file(
1395 project_root: &Path,
1396 file_name: &str,
1397 module_path: &[String],
1398) -> Option<PathBuf> {
1399 let first = module_path.first()?;
1400 let file = project_root.join(file_name);
1401 let base_dir = file.parent().unwrap_or_else(|| Path::new("."));
1402 resolve_rust_module_file(base_dir, first)
1403}
1404
1405fn resolve_raw_imported_export_liveness_roots(
1406 project_root: &Path,
1407 file_name: &str,
1408 raw_imports: &[RawImportContribution],
1409 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1410 default_export_symbols_by_file: &BTreeMap<String, String>,
1411) -> ImportedExportLiveness {
1412 let file = project_root.join(file_name);
1413 let from_dir = file.parent().unwrap_or_else(|| Path::new("."));
1414 let mut root_exports: BTreeSet<ExportNode> = BTreeSet::new();
1415 let mut namespace_exports: BTreeSet<ExportNode> = BTreeSet::new();
1416
1417 for import in raw_imports {
1418 if import.namespace_import.is_some() {
1419 if let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) {
1420 namespace_exports.extend(resolve_namespace_import_liveness_roots(
1421 project_root,
1422 &module_entry,
1423 exported_symbols_by_file,
1424 default_export_symbols_by_file,
1425 ));
1426 }
1427 }
1428
1429 let Some(module_entry) = resolve_import_module_path(from_dir, &import.source) else {
1430 continue;
1431 };
1432
1433 for imported_name in import
1434 .names
1435 .iter()
1436 .map(|name| specifier_imported_name(name))
1437 {
1438 if let Some(root) = resolve_imported_export_liveness_root(
1439 project_root,
1440 &module_entry,
1441 imported_name,
1442 exported_symbols_by_file,
1443 default_export_symbols_by_file,
1444 ) {
1445 root_exports.insert(root);
1446 }
1447 }
1448
1449 if import.default_import.is_some() {
1450 if let Some(root) = resolve_imported_export_liveness_root(
1451 project_root,
1452 &module_entry,
1453 "default",
1454 exported_symbols_by_file,
1455 default_export_symbols_by_file,
1456 ) {
1457 root_exports.insert(root);
1458 }
1459 }
1460 }
1461
1462 ImportedExportLiveness {
1463 root_exports: root_exports
1464 .into_iter()
1465 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1466 .collect(),
1467 namespace_exports: namespace_exports
1468 .into_iter()
1469 .map(|(file, symbol)| ImportedExportContribution { file, symbol })
1470 .collect(),
1471 }
1472}
1473
1474fn ts_reexport_specifiers(raw_export: &str) -> Vec<ReexportSpecifier> {
1475 let Some(start) = raw_export.find('{').map(|index| index + 1) else {
1476 return Vec::new();
1477 };
1478 let Some(end) = raw_export[start..].find('}').map(|index| start + index) else {
1479 return Vec::new();
1480 };
1481
1482 raw_export[start..end]
1483 .split(',')
1484 .filter_map(|specifier| {
1485 let specifier = specifier.trim();
1486 if specifier.is_empty() {
1487 return None;
1488 }
1489 let imported = specifier_imported_name(specifier).trim();
1490 let exported = specifier_local_name(specifier).trim();
1491 if imported.is_empty() || exported.is_empty() {
1492 return None;
1493 }
1494 Some(ReexportSpecifier {
1495 imported: imported.to_string(),
1496 exported: exported.to_string(),
1497 })
1498 })
1499 .collect()
1500}
1501
1502fn ts_namespace_reexport_name(raw_export: &str) -> Option<String> {
1503 let after_star = raw_export.split_once('*')?.1.trim_start();
1504 let after_as = after_star.strip_prefix("as")?.trim_start();
1505 let name = after_as
1506 .split_whitespace()
1507 .next()?
1508 .trim_matches(|ch: char| ch == '{' || ch == '}' || ch == ';' || ch == ',');
1509 (!name.is_empty()).then(|| name.to_string())
1510}
1511
1512fn reexport_edges_for_all_target_symbols(
1513 project_root: &Path,
1514 file_name: &str,
1515 namespace_export: &str,
1516 module_entry: &Path,
1517 line: u32,
1518 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1519 default_export_symbols_by_file: &BTreeMap<String, String>,
1520 match_current_export_names: bool,
1521) -> Vec<InternalCall> {
1522 let Some((_, target_symbols)) =
1523 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1524 else {
1525 return Vec::new();
1526 };
1527
1528 let mut edges = Vec::new();
1529 for target_symbol in target_symbols {
1530 let caller_symbol = if match_current_export_names {
1531 if !file_exports_symbol(file_name, target_symbol, exported_symbols_by_file) {
1532 continue;
1533 }
1534 target_symbol.clone()
1535 } else {
1536 namespace_export.to_string()
1537 };
1538
1539 if let Some((target_file, resolved_symbol)) = resolve_imported_export_liveness_root(
1540 project_root,
1541 module_entry,
1542 target_symbol,
1543 exported_symbols_by_file,
1544 default_export_symbols_by_file,
1545 ) {
1546 edges.push(InternalCall {
1547 caller_symbol,
1548 file: target_file,
1549 symbol: resolved_symbol,
1550 line,
1551 provenance: CALLGRAPH_PROVENANCE_REEXPORT.to_string(),
1552 });
1553 }
1554 }
1555
1556 edges
1557}
1558
1559fn resolve_rust_module_file(base_dir: &Path, module: &str) -> Option<PathBuf> {
1560 let flat = base_dir.join(format!("{module}.rs"));
1561 if flat.is_file() {
1562 return Some(flat);
1563 }
1564 let nested = base_dir.join(module).join("mod.rs");
1565 nested.is_file().then_some(nested)
1566}
1567
1568fn rust_pub_use_statements(source: &str) -> Vec<(String, u32)> {
1569 let mut statements = Vec::new();
1570 let mut current = String::new();
1571 let mut start_line = 0u32;
1572
1573 for (index, line) in source.lines().enumerate() {
1574 let trimmed = line.trim();
1575 if current.is_empty() {
1576 if !(trimmed.starts_with("pub use ") || trimmed.starts_with("pub(crate) use ")) {
1577 continue;
1578 }
1579 start_line = (index + 1) as u32;
1580 }
1581
1582 current.push(' ');
1583 current.push_str(trimmed);
1584 if trimmed.ends_with(';') {
1585 statements.push((current.trim().to_string(), start_line));
1586 current.clear();
1587 }
1588 }
1589
1590 statements
1591}
1592
1593fn rust_reexport_specifiers(statement: &str) -> Vec<RustReexportSpecifier> {
1594 let statement = statement
1595 .trim()
1596 .trim_end_matches(';')
1597 .strip_prefix("pub(crate) use ")
1598 .or_else(|| {
1599 statement
1600 .trim()
1601 .trim_end_matches(';')
1602 .strip_prefix("pub use ")
1603 })
1604 .unwrap_or("")
1605 .trim();
1606 if statement.is_empty() {
1607 return Vec::new();
1608 }
1609
1610 if let Some((module_path, grouped)) = statement.split_once("::{") {
1611 let grouped = grouped.trim_end_matches('}');
1612 return grouped
1613 .split(',')
1614 .filter_map(|specifier| rust_reexport_specifier(module_path.trim(), specifier.trim()))
1615 .collect();
1616 }
1617
1618 let Some((module_path, imported)) = statement.rsplit_once("::") else {
1619 return Vec::new();
1620 };
1621 rust_reexport_specifier(module_path.trim(), imported.trim())
1622 .into_iter()
1623 .collect()
1624}
1625
1626fn rust_reexport_specifier(module_path: &str, specifier: &str) -> Option<RustReexportSpecifier> {
1627 if specifier.is_empty() {
1628 return None;
1629 }
1630 let (imported, exported) = specifier
1631 .split_once(" as ")
1632 .map(|(imported, exported)| (imported.trim(), exported.trim()))
1633 .unwrap_or((specifier.trim(), specifier.trim()));
1634 if imported.is_empty() || exported.is_empty() {
1635 return None;
1636 }
1637 Some(RustReexportSpecifier {
1638 module_path: rust_normalize_module_path(module_path),
1639 imported: imported.to_string(),
1640 exported: exported.to_string(),
1641 })
1642}
1643
1644fn rust_normalize_module_path(module_path: &str) -> Vec<String> {
1645 module_path
1646 .split("::")
1647 .filter_map(|segment| {
1648 let segment = segment.trim();
1649 if segment.is_empty() || matches!(segment, "self" | "crate") {
1650 None
1651 } else {
1652 Some(segment.to_string())
1653 }
1654 })
1655 .collect()
1656}
1657
1658fn file_exports_symbol(
1659 file_name: &str,
1660 symbol: &str,
1661 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1662) -> bool {
1663 exported_symbols_by_file
1664 .get(file_name)
1665 .is_some_and(|symbols| symbols.contains(symbol))
1666}
1667
1668fn export_source_module(source: &str, node: tree_sitter::Node) -> Option<String> {
1669 node.child_by_field_name("source")
1670 .or_else(|| find_child_by_kind(node, "string"))
1671 .and_then(|source_node| string_literal_content(source, source_node))
1672}
1673
1674fn find_child_by_kind<'tree>(
1675 node: tree_sitter::Node<'tree>,
1676 kind: &str,
1677) -> Option<tree_sitter::Node<'tree>> {
1678 let mut cursor = node.walk();
1679 if !cursor.goto_first_child() {
1680 return None;
1681 }
1682 loop {
1683 let child = cursor.node();
1684 if child.kind() == kind {
1685 return Some(child);
1686 }
1687 if let Some(descendant) = find_child_by_kind(child, kind) {
1688 return Some(descendant);
1689 }
1690 if !cursor.goto_next_sibling() {
1691 break;
1692 }
1693 }
1694 None
1695}
1696
1697fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
1698 let raw = node_text(source, node).trim();
1699 let quote = raw.chars().next()?;
1700 if quote != '\'' && quote != '"' {
1701 return None;
1702 }
1703 raw.strip_prefix(quote)
1704 .and_then(|value| value.strip_suffix(quote))
1705 .map(ToOwned::to_owned)
1706}
1707
1708fn node_text<'a>(source: &'a str, node: tree_sitter::Node) -> &'a str {
1709 &source[node.byte_range()]
1710}
1711
1712fn resolve_import_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1713 if is_relative_module_path(module_path) {
1714 return resolve_js_ts_module_path(from_dir, module_path);
1715 }
1716 resolve_workspace_package_import(from_dir, module_path)
1717}
1718
1719fn resolve_js_ts_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1720 resolve_module_path(from_dir, module_path)
1721 .or_else(|| resolve_esm_source_module_path(from_dir, module_path))
1722}
1723
1724fn resolve_esm_source_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1725 if !is_relative_module_path(module_path) {
1726 return None;
1727 }
1728 let base = from_dir.join(module_path);
1729 let ext = base.extension().and_then(|extension| extension.to_str())?;
1730 let candidates: &[&str] = match ext {
1731 "js" => &["ts", "tsx"],
1732 "jsx" => &["tsx", "ts"],
1733 "mjs" => &["mts", "ts"],
1734 "cjs" => &["cts", "ts"],
1735 _ => return None,
1736 };
1737
1738 candidates
1739 .iter()
1740 .map(|extension| base.with_extension(extension))
1741 .find(|candidate| candidate.is_file())
1742}
1743
1744fn is_relative_module_path(module_path: &str) -> bool {
1745 module_path.starts_with("./")
1746 || module_path.starts_with("../")
1747 || module_path == "."
1748 || module_path == ".."
1749}
1750
1751#[derive(Debug)]
1752struct ReexportSpecifier {
1753 imported: String,
1754 exported: String,
1755}
1756
1757#[derive(Debug)]
1758struct RustReexportSpecifier {
1759 module_path: Vec<String>,
1760 imported: String,
1761 exported: String,
1762}
1763
1764fn resolve_workspace_package_import(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1765 let package_name = package_name_from_import(module_path)?;
1766 let module_entry = resolve_module_path(from_dir, module_path)?;
1767 let resolved_package_name = package_name_for_file(&module_entry)?;
1768 (resolved_package_name == package_name).then_some(module_entry)
1769}
1770
1771fn package_name_from_import(module_path: &str) -> Option<String> {
1772 if module_path.starts_with('.') || module_path.starts_with('/') || module_path.starts_with('#')
1773 {
1774 return None;
1775 }
1776
1777 let mut parts = module_path.split('/');
1778 let first = parts.next()?;
1779 if first.is_empty() {
1780 return None;
1781 }
1782
1783 if first.starts_with('@') {
1784 let second = parts.next()?;
1785 (!second.is_empty()).then(|| format!("{first}/{second}"))
1786 } else {
1787 Some(first.to_string())
1788 }
1789}
1790
1791fn package_name_for_file(file: &Path) -> Option<String> {
1792 let mut current = file.parent();
1793 while let Some(dir) = current {
1794 let manifest = dir.join("package.json");
1795 if manifest.is_file() {
1796 if let Ok(source) = fs::read_to_string(&manifest) {
1797 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) {
1798 if let Some(name) = value.get("name").and_then(serde_json::Value::as_str) {
1799 return Some(name.to_string());
1800 }
1801 }
1802 }
1803 }
1804 current = dir.parent();
1805 }
1806 None
1807}
1808
1809fn resolve_namespace_import_liveness_roots(
1810 project_root: &Path,
1811 module_entry: &Path,
1812 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1813 default_export_symbols_by_file: &BTreeMap<String, String>,
1814) -> Vec<ExportNode> {
1815 let Some((_, symbols)) =
1816 exported_symbols_for_resolved_file(project_root, module_entry, exported_symbols_by_file)
1817 else {
1818 return Vec::new();
1819 };
1820 let mut roots = BTreeSet::new();
1821
1822 for symbol in symbols {
1823 if let Some(root) = resolve_imported_export_liveness_root(
1824 project_root,
1825 module_entry,
1826 symbol,
1827 exported_symbols_by_file,
1828 default_export_symbols_by_file,
1829 ) {
1830 roots.insert(root);
1831 }
1832 }
1833
1834 if default_export_symbol_for_resolved_file(
1835 project_root,
1836 module_entry,
1837 default_export_symbols_by_file,
1838 )
1839 .is_some()
1840 {
1841 if let Some(root) = resolve_imported_export_liveness_root(
1842 project_root,
1843 module_entry,
1844 "default",
1845 exported_symbols_by_file,
1846 default_export_symbols_by_file,
1847 ) {
1848 roots.insert(root);
1849 }
1850 }
1851
1852 roots.into_iter().collect()
1853}
1854
1855fn resolve_imported_export_liveness_root(
1856 project_root: &Path,
1857 module_entry: &Path,
1858 imported_symbol: &str,
1859 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1860 default_export_symbols_by_file: &BTreeMap<String, String>,
1861) -> Option<ExportNode> {
1862 let mut file_exports_symbol = |path: &Path, symbol_name: &str| {
1863 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1864 .is_some_and(|(_, symbols)| symbols.contains(symbol_name))
1865 };
1866 let mut file_default_export_symbol = |path: &Path| {
1867 default_export_symbol_for_resolved_file(project_root, path, default_export_symbols_by_file)
1868 .or_else(|| {
1869 exported_symbols_for_resolved_file(project_root, path, exported_symbols_by_file)
1870 .and_then(|(_, symbols)| {
1871 symbols.contains("default").then(|| "default".to_string())
1872 })
1873 })
1874 };
1875
1876 let (target_file, symbol) = resolve_reexported_symbol_target(
1877 module_entry,
1878 imported_symbol,
1879 &mut file_exports_symbol,
1880 &mut file_default_export_symbol,
1881 )?;
1882
1883 let (file, symbols) =
1884 exported_symbols_for_resolved_file(project_root, &target_file, exported_symbols_by_file)?;
1885 symbols.contains(&symbol).then_some((file, symbol))
1886}
1887
1888fn exported_symbols_for_resolved_file<'a>(
1889 project_root: &Path,
1890 file: &Path,
1891 exported_symbols_by_file: &'a BTreeMap<String, BTreeSet<String>>,
1892) -> Option<(String, &'a BTreeSet<String>)> {
1893 let relative = relative_path(project_root, file);
1894 if let Some(symbols) = exported_symbols_by_file.get(&relative) {
1895 return Some((relative, symbols));
1896 }
1897
1898 let canonical_root = fs::canonicalize(project_root).ok()?;
1899 let canonical_file = fs::canonicalize(file).ok()?;
1900 let relative = relative_path(&canonical_root, &canonical_file);
1901 exported_symbols_by_file
1902 .get(&relative)
1903 .map(|symbols| (relative, symbols))
1904}
1905
1906fn default_export_symbol_for_resolved_file(
1907 project_root: &Path,
1908 file: &Path,
1909 default_export_symbols_by_file: &BTreeMap<String, String>,
1910) -> Option<String> {
1911 let relative = relative_path(project_root, file);
1912 if let Some(symbol) = default_export_symbols_by_file.get(&relative) {
1913 return Some(symbol.clone());
1914 }
1915
1916 let canonical_root = fs::canonicalize(project_root).ok()?;
1917 let canonical_file = fs::canonicalize(file).ok()?;
1918 let relative = relative_path(&canonical_root, &canonical_file);
1919 default_export_symbols_by_file.get(&relative).cloned()
1920}
1921
1922fn resolve_unqualified_target(
1923 caller_file: &str,
1924 symbol: &str,
1925 exported_symbols_by_file: &BTreeMap<String, BTreeSet<String>>,
1926 files_by_exported_symbol: &BTreeMap<String, BTreeSet<String>>,
1927) -> Option<String> {
1928 if exported_symbols_by_file
1929 .get(caller_file)
1930 .is_some_and(|symbols| symbols.contains(symbol))
1931 {
1932 return Some(caller_file.to_string());
1933 }
1934
1935 let files = files_by_exported_symbol.get(symbol)?;
1936 if files.len() == 1 {
1937 files.iter().next().cloned()
1938 } else {
1939 None
1940 }
1941}
1942
1943fn dispatched_method_name_from_call(call: &CallgraphOutboundCall) -> Option<String> {
1944 let (target, full_callee) = split_call_target_metadata(&call.target);
1945 if let Some(full_callee) = full_callee {
1946 return dispatched_method_name_from_callee(full_callee);
1947 }
1948 if target.contains("::") || target.contains('#') {
1949 return None;
1950 }
1951 dispatched_method_name_from_callee(target)
1952}
1953
1954fn dispatched_method_name_from_callee(callee: &str) -> Option<String> {
1955 let callee = callee.trim();
1956 if !callee.contains('.') {
1957 return None;
1958 }
1959
1960 clean_symbol(callee.rsplit('.').next()?.trim().trim_start_matches('?'))
1961}
1962
1963fn split_call_target_metadata(target: &str) -> (&str, Option<&str>) {
1964 target
1965 .split_once(DISPATCHED_CALLEE_SEPARATOR)
1966 .map_or((target, None), |(target, full_callee)| {
1967 (target, Some(full_callee))
1968 })
1969}
1970
1971fn symbol_liveness_name(symbol: &str) -> &str {
1972 symbol
1973 .rsplit(['.', ':', '#'])
1974 .find(|segment| !segment.is_empty())
1975 .unwrap_or(symbol)
1976}
1977
1978fn is_type_like_kind(kind: &str) -> bool {
1979 matches!(
1980 kind,
1981 "struct" | "enum" | "trait" | "type" | "type_alias" | "interface"
1982 )
1983}
1984
1985fn parse_target(project_root: &Path, target: &str) -> ParsedTarget {
1986 let (target, _) = split_call_target_metadata(target);
1987 let trimmed = target.trim();
1988 if trimmed.is_empty() {
1989 return ParsedTarget {
1990 file: None,
1991 symbol: None,
1992 };
1993 }
1994
1995 if let Some((file, symbol)) = split_file_symbol_target(project_root, trimmed, "::") {
1996 return ParsedTarget {
1997 file: Some(relative_path(project_root, Path::new(file))),
1998 symbol: clean_symbol(symbol),
1999 };
2000 }
2001
2002 if let Some((file, symbol)) = trimmed.rsplit_once('#') {
2003 return ParsedTarget {
2004 file: Some(relative_path(project_root, Path::new(file))),
2005 symbol: clean_symbol(symbol),
2006 };
2007 }
2008
2009 ParsedTarget {
2010 file: None,
2011 symbol: clean_symbol(trimmed),
2012 }
2013}
2014
2015fn split_file_symbol_target<'a>(
2016 project_root: &Path,
2017 target: &'a str,
2018 separator: &str,
2019) -> Option<(&'a str, &'a str)> {
2020 let mut search_start = 0;
2021 while let Some(offset) = target[search_start..].find(separator) {
2022 let split_at = search_start + offset;
2023 let file = &target[..split_at];
2024 let symbol = &target[split_at + separator.len()..];
2025 if !symbol.trim().is_empty() && looks_like_source_file_target(project_root, file) {
2026 return Some((file, symbol));
2027 }
2028 search_start = split_at + separator.len();
2029 }
2030 None
2031}
2032
2033fn looks_like_source_file_target(project_root: &Path, file: &str) -> bool {
2034 let path = Path::new(file);
2035 language_for_file(file) != "unknown" || path.is_file() || project_root.join(path).is_file()
2036}
2037
2038fn clean_symbol(symbol: &str) -> Option<String> {
2039 let trimmed = symbol.trim();
2040 if trimmed.is_empty() {
2041 None
2042 } else {
2043 Some(trimmed.to_string())
2044 }
2045}
2046
2047fn liveness_roots_for_file(
2048 file_name: &str,
2049 exports: &[ExportContribution],
2050 internal_calls: &[InternalCall],
2051 attribute_entry_points: &BTreeSet<String>,
2052 executable_root_exports: Option<&BTreeSet<String>>,
2053 is_liveness_root_file: bool,
2054 is_public_api_file: bool,
2055) -> Vec<String> {
2056 let mut roots = attribute_entry_points
2057 .iter()
2058 .filter_map(|symbol| clean_symbol(symbol))
2059 .collect::<BTreeSet<_>>();
2060
2061 if !is_liveness_root_file && !is_public_api_file {
2062 return roots.into_iter().collect();
2063 }
2064
2065 roots.insert("<top-level>".to_string());
2066 if is_public_api_file {
2067 roots.extend(exports.iter().map(|export| export.symbol.clone()));
2068 } else if let Some(executable_root_exports) = executable_root_exports {
2069 roots.extend(executable_root_exports.iter().cloned());
2070 } else {
2071 roots.extend(
2072 exports
2073 .iter()
2074 .filter(|export| is_explicit_liveness_symbol(file_name, &export.symbol))
2075 .map(|export| export.symbol.clone()),
2076 );
2077 roots.extend(
2078 internal_calls
2079 .iter()
2080 .map(|call| call.caller_symbol.as_str())
2081 .filter(|symbol| is_explicit_liveness_symbol(file_name, symbol))
2082 .map(str::to_string),
2083 );
2084 }
2085
2086 roots.into_iter().collect()
2087}
2088
2089fn is_explicit_liveness_symbol(file_name: &str, symbol: &str) -> bool {
2090 let symbol = symbol.rsplit("::").next().unwrap_or(symbol);
2091 if symbol == "<top-level>" {
2092 return true;
2093 }
2094
2095 let lower = symbol.to_ascii_lowercase();
2096 if matches!(
2097 lower.as_str(),
2098 "main" | "init" | "setup" | "bootstrap" | "run"
2099 ) {
2100 return true;
2101 }
2102
2103 Path::new(file_name)
2104 .file_stem()
2105 .and_then(|stem| stem.to_str())
2106 .is_some_and(|stem| stem == symbol)
2107}
2108
2109pub(crate) fn collect_public_api_files(project_root: &Path) -> BTreeSet<String> {
2110 crate::inspect::entry_points::resolve_entry_points(project_root)
2111 .public_api_files_relative(project_root)
2112}
2113
2114fn language_for_file(file: &str) -> &'static str {
2115 let extension = Path::new(file)
2116 .extension()
2117 .and_then(|extension| extension.to_str())
2118 .map(|extension| extension.to_ascii_lowercase())
2119 .unwrap_or_default();
2120
2121 match extension.as_str() {
2122 "rs" => "rust",
2123 "ts" | "tsx" | "mts" | "cts" => "typescript",
2124 "js" | "jsx" | "mjs" | "cjs" => "javascript",
2125 "py" => "python",
2126 "go" => "go",
2127 "c" | "h" => "c",
2128 "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp",
2129 "zig" => "zig",
2130 "cs" => "csharp",
2131 "sh" | "bash" | "zsh" | "fish" => "bash",
2132 "html" | "htm" => "html",
2133 "md" | "markdown" => "markdown",
2134 "sol" => "solidity",
2135 "vue" => "vue",
2136 "json" => "json",
2137 "scala" => "scala",
2138 "java" => "java",
2139 "rb" => "ruby",
2140 "kt" | "kts" => "kotlin",
2141 "swift" => "swift",
2142 "php" => "php",
2143 "lua" => "lua",
2144 "pl" | "pm" => "perl",
2145 _ => "unknown",
2146 }
2147}
2148
2149fn supports_type_refs(lang: LangId) -> bool {
2150 matches!(
2151 lang,
2152 LangId::TypeScript
2153 | LangId::Tsx
2154 | LangId::JavaScript
2155 | LangId::Python
2156 | LangId::Rust
2157 | LangId::Go
2158 )
2159}
2160
2161fn collect_freshness(file: &Path) -> FileFreshness {
2162 cache_freshness::collect(file).unwrap_or_else(|_| FileFreshness {
2163 mtime: UNIX_EPOCH,
2164 size: 0,
2165 content_hash: cache_freshness::zero_hash(),
2166 })
2167}
2168
2169fn relative_path(project_root: &Path, path: &Path) -> String {
2170 let absolute = if path.is_absolute() {
2171 path.to_path_buf()
2172 } else {
2173 project_root.join(path)
2174 };
2175 let normalized_root = canonicalize_normalized(project_root);
2176 let normalized = canonicalize_normalized(&absolute);
2177 normalized
2178 .strip_prefix(&normalized_root)
2179 .unwrap_or(normalized.as_path())
2180 .to_string_lossy()
2181 .replace('\\', "/")
2182}
2183
2184fn canonical_or_normalized(project_root: &Path, path: &Path) -> PathBuf {
2185 crate::inspect::oxc_engine::normalize_input_path(project_root, path)
2191}
2192
2193fn normalize_absolute(project_root: &Path, path: &Path) -> PathBuf {
2194 let absolute = if path.is_absolute() {
2195 path.to_path_buf()
2196 } else {
2197 project_root.join(path)
2198 };
2199 normalize_path(&absolute)
2200}
2201
2202fn normalize_path(path: &Path) -> PathBuf {
2203 let mut normalized = PathBuf::new();
2204 for component in path.components() {
2205 match component {
2206 Component::CurDir => {}
2207 Component::ParentDir => {
2208 if !normalized.pop() {
2209 normalized.push(component.as_os_str());
2210 }
2211 }
2212 _ => normalized.push(component.as_os_str()),
2213 }
2214 }
2215 normalized
2216}
2217
2218#[derive(Debug, Clone, Deserialize)]
2219struct DeadCodeContribution {
2220 file: String,
2221 exports: Vec<ExportContribution>,
2222 #[serde(default)]
2223 facts_format_version: Option<u32>,
2224 #[serde(default)]
2225 raw_imports: Vec<RawImportContribution>,
2226 #[serde(default)]
2227 raw_reexports: Vec<RawReexportContribution>,
2228 #[serde(default)]
2229 attribute_entry_points: Vec<String>,
2230 #[serde(default)]
2231 oxc_facts: Option<OxcFactsContribution>,
2232 #[serde(default)]
2233 internal_calls: Vec<InternalCallContribution>,
2234 #[serde(default)]
2235 liveness_roots: Vec<String>,
2236 #[serde(default)]
2237 imported_exports: Vec<ImportedExportContribution>,
2238 #[serde(default)]
2239 namespace_imported_exports: Vec<ImportedExportContribution>,
2240 #[serde(default)]
2241 dispatched_method_names: Vec<String>,
2242 #[serde(default)]
2243 type_ref_names: Vec<String>,
2244 #[serde(default)]
2245 parse_errors: Vec<Value>,
2246 #[serde(default)]
2247 skipped_files: Vec<Value>,
2248}
2249
2250#[derive(Debug, Clone, Serialize, Deserialize)]
2251struct RawImportContribution {
2252 source: String,
2253 #[serde(default)]
2254 names: Vec<String>,
2255 #[serde(default)]
2256 default_import: Option<String>,
2257 #[serde(default)]
2258 namespace_import: Option<String>,
2259}
2260
2261#[derive(Debug, Clone, Serialize, Deserialize)]
2262struct RawReexportContribution {
2263 language: String,
2264 source: String,
2265 kind: String,
2266 #[serde(default)]
2267 imported: Option<String>,
2268 #[serde(default)]
2269 exported: Option<String>,
2270 line: u32,
2271}
2272
2273#[derive(Debug, Clone, Deserialize)]
2274struct OxcFactsContribution {
2275 format_version: u32,
2276 content_hash: String,
2277 exports: Vec<ExportFact>,
2278 imports: Vec<ImportFact>,
2279 re_exports: Vec<ReExportFact>,
2280 dynamic_imports: Vec<DynamicImportFact>,
2281 same_file_value_references: BTreeSet<String>,
2282 used_import_bindings: BTreeSet<String>,
2283 type_referenced_import_bindings: BTreeSet<String>,
2284 value_referenced_import_bindings: BTreeSet<String>,
2285 #[serde(default)]
2286 parse_error: Option<String>,
2287}
2288
2289#[derive(Debug, Clone, Deserialize)]
2290struct ImportedExportContribution {
2291 file: String,
2292 symbol: String,
2293}
2294
2295#[derive(Debug, Clone, Deserialize)]
2296struct ExportContribution {
2297 symbol: String,
2298 kind: String,
2299 line: u32,
2300 #[serde(default)]
2301 is_type_like: bool,
2302 #[serde(default)]
2303 is_entry_point: bool,
2304 #[serde(default)]
2305 has_references: bool,
2306 #[serde(default)]
2307 test_only_reference_files: Vec<String>,
2308 #[serde(default)]
2309 verdict: Option<LivenessVerdict>,
2310 #[serde(default)]
2311 reason: Option<String>,
2312 #[serde(default)]
2313 provenance: Option<String>,
2314 #[serde(default)]
2315 also_reexported: Vec<OxcReExportContext>,
2316}
2317
2318#[derive(Debug, Clone, Deserialize)]
2319struct InternalCallContribution {
2320 #[serde(default)]
2321 caller_symbol: String,
2322 file: String,
2323 symbol: String,
2324}
2325
2326impl From<InternalCall> for InternalCallContribution {
2327 fn from(call: InternalCall) -> Self {
2328 Self {
2329 caller_symbol: call.caller_symbol,
2330 file: call.file,
2331 symbol: call.symbol,
2332 }
2333 }
2334}
2335
2336#[derive(Debug, Clone)]
2337struct InternalCall {
2338 caller_symbol: String,
2339 file: String,
2340 symbol: String,
2341 line: u32,
2342 provenance: String,
2343}
2344
2345#[derive(Debug, Clone)]
2346struct ParsedTarget {
2347 file: Option<String>,
2348 symbol: Option<String>,
2349}
2350
2351#[cfg(test)]
2352mod tests {
2353 use super::*;
2354 use std::fs;
2355 use std::path::{Path, PathBuf};
2356 use std::sync::{Arc, RwLock};
2357
2358 use crate::config::Config;
2359 use crate::inspect::job::{CALLGRAPH_PROVENANCE_TREESITTER, DISPATCHED_CALLEE_SEPARATOR};
2360 use crate::inspect::{CallgraphExport, JobKey};
2361 use crate::parser::SymbolCache;
2362
2363 fn fixture_project(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
2364 let temp_dir = tempfile::tempdir().expect("tempdir");
2365 let root = temp_dir.path().join("project");
2366 fs::create_dir_all(&root).expect("create project root");
2367
2368 let paths = files
2369 .iter()
2370 .map(|(relative, contents)| {
2371 let path = root.join(relative);
2372 if let Some(parent) = path.parent() {
2373 fs::create_dir_all(parent).expect("create parent");
2374 }
2375 fs::write(&path, contents).expect("write fixture file");
2376 path
2377 })
2378 .collect::<Vec<_>>();
2379
2380 (temp_dir, root, paths)
2381 }
2382
2383 fn job(root: &Path, scope_files: Vec<PathBuf>, snapshot: CallgraphSnapshot) -> InspectJob {
2384 InspectJob {
2385 job_id: 1,
2386 key: JobKey::for_project_category(InspectCategory::DeadCode),
2387 category: InspectCategory::DeadCode,
2388 scope_files,
2389 project_root: root.to_path_buf(),
2390 inspect_dir: root.join(".aft-cache").join("inspect"),
2391 config: Arc::new(Config {
2392 project_root: Some(root.to_path_buf()),
2393 ..Config::default()
2394 }),
2395 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2396 callgraph_snapshot: Some(Arc::new(snapshot)),
2397 }
2398 }
2399
2400 fn snapshot(
2401 files: Vec<PathBuf>,
2402 exported_symbols: Vec<CallgraphExport>,
2403 outbound_calls: Vec<CallgraphOutboundCall>,
2404 ) -> CallgraphSnapshot {
2405 snapshot_with_entry_points(files, exported_symbols, outbound_calls, BTreeSet::new())
2406 }
2407
2408 fn snapshot_with_entry_points(
2409 files: Vec<PathBuf>,
2410 exported_symbols: Vec<CallgraphExport>,
2411 outbound_calls: Vec<CallgraphOutboundCall>,
2412 entry_points: BTreeSet<PathBuf>,
2413 ) -> CallgraphSnapshot {
2414 CallgraphSnapshot {
2415 generated_at: None,
2416 files,
2417 exported_symbols,
2418 outbound_calls,
2419 entry_points,
2420 entry_point_symbols: BTreeMap::new(),
2421 }
2422 }
2423
2424 fn export(root: &Path, file: &str, symbol: &str, kind: &str) -> CallgraphExport {
2425 CallgraphExport {
2426 file: root.join(file),
2427 symbol: symbol.to_string(),
2428 kind: kind.to_string(),
2429 line: 1,
2430 }
2431 }
2432
2433 fn outbound(
2434 root: &Path,
2435 caller_file: &str,
2436 caller_symbol: &str,
2437 target: &str,
2438 ) -> CallgraphOutboundCall {
2439 CallgraphOutboundCall {
2440 caller_file: root.join(caller_file),
2441 caller_symbol: caller_symbol.to_string(),
2442 target: target.to_string(),
2443 line: 1,
2444 provenance: CALLGRAPH_PROVENANCE_TREESITTER.to_string(),
2445 }
2446 }
2447
2448 fn dispatched_target(target: &str, full_callee: &str) -> String {
2449 format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{full_callee}")
2450 }
2451
2452 fn scan(job: InspectJob) -> serde_json::Value {
2453 run_dead_code_scan(&job)
2454 .outcome
2455 .expect("scan succeeds")
2456 .aggregate
2457 }
2458
2459 fn aggregate_has_item(aggregate: &serde_json::Value, file: &str, symbol: &str) -> bool {
2460 aggregate
2461 .get("items")
2462 .and_then(serde_json::Value::as_array)
2463 .into_iter()
2464 .flatten()
2465 .any(|item| {
2466 item.get("file").and_then(serde_json::Value::as_str) == Some(file)
2467 && item.get("symbol").and_then(serde_json::Value::as_str) == Some(symbol)
2468 })
2469 }
2470
2471 fn scan_success_with_oxc(job: InspectJob) -> InspectScanSuccess {
2472 let entry_points = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
2473 let options = AnalyzeOptions {
2474 entry_points: job
2475 .callgraph_snapshot
2476 .as_ref()
2477 .map(|snapshot| snapshot.entry_points.iter().cloned().collect())
2478 .unwrap_or_default(),
2479 public_api_files: Vec::new(),
2480 executable_root_exports: entry_points.executable_root_exports(),
2481 force_reparse_files: Vec::new(),
2482 entry_reachability: true,
2483 };
2484 let oxc_result =
2485 crate::inspect::oxc_engine::analyze_files(&job.project_root, &job.scope_files, options)
2486 .expect("oxc analyze succeeds");
2487 run_dead_code_scan_with_oxc(&job, Some(&oxc_result))
2488 .outcome
2489 .expect("scan succeeds")
2490 }
2491
2492 fn scan_with_oxc(job: InspectJob) -> serde_json::Value {
2493 scan_success_with_oxc(job).aggregate
2494 }
2495
2496 fn aggregate_item<'a>(
2497 aggregate: &'a serde_json::Value,
2498 file: &str,
2499 symbol: &str,
2500 ) -> Option<&'a serde_json::Value> {
2501 aggregate["items"].as_array()?.iter().find(|item| {
2502 item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
2503 })
2504 }
2505
2506 fn aggregate_test_only_item<'a>(
2507 aggregate: &'a serde_json::Value,
2508 file: &str,
2509 symbol: &str,
2510 ) -> Option<&'a serde_json::Value> {
2511 aggregate["test_only_items"]
2512 .as_array()?
2513 .iter()
2514 .find(|item| {
2515 item["file"].as_str() == Some(file) && item["symbol"].as_str() == Some(symbol)
2516 })
2517 }
2518
2519 #[test]
2520 fn oxc_dead_code_splits_test_only_references_from_headline() {
2521 let (_temp_dir, root, paths) = fixture_project(&[
2522 ("package.json", r#"{"main":"src/main.ts"}"#),
2523 (
2524 "src/main.ts",
2525 "import { productUsed } from './api';
2526export function main() { productUsed(); }
2527",
2528 ),
2529 (
2530 "src/api.ts",
2531 "export function testOnly() {}
2532export function productUsed() {}
2533",
2534 ),
2535 (
2536 "src/dead.ts",
2537 "export function plantedDead() {}
2538",
2539 ),
2540 (
2541 "src/api.test.ts",
2542 "import { testOnly } from './api';
2543testOnly();
2544",
2545 ),
2546 (
2547 "src/barrel-target.ts",
2548 "export function throughBarrel() {}
2549export function barrelDead() {}
2550",
2551 ),
2552 (
2553 "src/barrel.ts",
2554 "export { throughBarrel } from './barrel-target';
2555",
2556 ),
2557 (
2558 "src/barrel.test.ts",
2559 "import { throughBarrel } from './barrel';
2560throughBarrel();
2561",
2562 ),
2563 ]);
2564 let root = fs::canonicalize(root).expect("canonical project root");
2565 let paths = paths
2566 .into_iter()
2567 .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
2568 .collect::<Vec<_>>();
2569 let entry_points = BTreeSet::from([root.join("src/main.ts")]);
2570 let graph = snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), entry_points);
2571
2572 let aggregate = scan_with_oxc(job(&root, paths, graph));
2573
2574 assert_eq!(aggregate["count"], 2, "{aggregate:#}");
2575 assert!(aggregate_item(&aggregate, "src/dead.ts", "plantedDead").is_some());
2576 assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "barrelDead").is_some());
2577 assert!(aggregate_item(&aggregate, "src/api.ts", "testOnly").is_none());
2578 assert!(aggregate_item(&aggregate, "src/api.ts", "productUsed").is_none());
2579 assert!(aggregate_item(&aggregate, "src/barrel-target.ts", "throughBarrel").is_none());
2580
2581 assert_eq!(aggregate["test_only_count"], 2, "{aggregate:#}");
2582 assert_eq!(
2583 aggregate_test_only_item(&aggregate, "src/api.ts", "testOnly")
2584 .and_then(|item| item["used_by"].as_array())
2585 .and_then(|items| items.first())
2586 .and_then(serde_json::Value::as_str),
2587 Some("api.test.ts")
2588 );
2589 assert_eq!(
2590 aggregate_test_only_item(&aggregate, "src/barrel-target.ts", "throughBarrel")
2591 .and_then(|item| item["used_by"].as_array())
2592 .and_then(|items| items.first())
2593 .and_then(serde_json::Value::as_str),
2594 Some("barrel.test.ts")
2595 );
2596 }
2597
2598 #[test]
2599 fn oxc_dead_code_test_file_edit_cached_rollup_matches_cold() {
2600 let (_temp_dir, root, paths) = fixture_project(&[
2601 (
2602 "src/api.ts",
2603 "export function testOnly() {}
2604export function plantedDead() {}
2605",
2606 ),
2607 (
2608 "src/api.test.ts",
2609 "import { testOnly } from './api';
2610testOnly();
2611",
2612 ),
2613 ]);
2614 let root = fs::canonicalize(root).expect("canonical project root");
2615 let paths = paths
2616 .into_iter()
2617 .map(|path| fs::canonicalize(path).expect("canonical fixture path"))
2618 .collect::<Vec<_>>();
2619 let graph =
2620 snapshot_with_entry_points(paths.clone(), Vec::new(), Vec::new(), BTreeSet::new());
2621 let first = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
2622 assert_eq!(first.aggregate["count"], 1, "{:#}", first.aggregate);
2623 assert_eq!(
2624 first.aggregate["test_only_count"], 1,
2625 "{:#}",
2626 first.aggregate
2627 );
2628
2629 fs::write(
2630 root.join("src/api.test.ts"),
2631 "console.log('import removed');
2632",
2633 )
2634 .expect("edit test file");
2635
2636 let cold = scan_success_with_oxc(job(&root, paths.clone(), graph.clone()));
2637 let changed_test = scan_success_with_oxc(job(
2638 &root,
2639 vec![root.join("src/api.test.ts")],
2640 graph.clone(),
2641 ));
2642 let mut cached_contributions = first.contributions.clone();
2643 for changed in changed_test.contributions {
2644 let slot = cached_contributions
2645 .iter_mut()
2646 .find(|contribution| contribution.file_path == changed.file_path)
2647 .expect("cached test contribution exists");
2648 *slot = changed;
2649 }
2650 let roles = crate::inspect::entry_points::resolve_project_roles(&root);
2651 let rolled_up = aggregate_dead_code_contributions_with_snapshot(
2652 &root,
2653 &graph,
2654 &cached_contributions,
2655 &collect_public_api_files(&root),
2656 &roles,
2657 Some(MAX_DRILL_DOWN_ITEMS),
2658 );
2659
2660 assert_eq!(rolled_up, cold.aggregate);
2661 assert_eq!(rolled_up["count"], 2, "{rolled_up:#}");
2662 assert_eq!(rolled_up["test_only_count"], 0, "{rolled_up:#}");
2663 }
2664
2665 #[test]
2666 fn method_dispatched_by_receiver_call_is_live() {
2667 let (_temp_dir, root, paths) = fixture_project(&[
2668 ("src/service.ts", "export class Service { render() {} }\n"),
2669 (
2670 "src/consumer.ts",
2671 "function run(service: Service) { service.render(); }\n",
2672 ),
2673 ]);
2674 let aggregate = scan(job(
2675 &root,
2676 paths.clone(),
2677 snapshot(
2678 paths,
2679 vec![export(&root, "src/service.ts", "render", "method")],
2680 vec![outbound(
2681 &root,
2682 "src/consumer.ts",
2683 "run",
2684 &dispatched_target("render", "service.render"),
2685 )],
2686 ),
2687 ));
2688
2689 assert_eq!(aggregate["count"], 0);
2690 assert_eq!(aggregate["uncertain_count"], 0);
2691 assert!(aggregate["items"].as_array().unwrap().is_empty());
2692 }
2693
2694 #[test]
2695 fn method_without_any_dispatch_is_still_dead() {
2696 let (_temp_dir, root, paths) =
2697 fixture_project(&[("src/service.ts", "export class Service { render() {} }\n")]);
2698 let aggregate = scan(job(
2699 &root,
2700 paths.clone(),
2701 snapshot(
2702 paths,
2703 vec![export(&root, "src/service.ts", "render", "method")],
2704 Vec::new(),
2705 ),
2706 ));
2707
2708 assert_eq!(aggregate["count"], 1);
2709 assert_eq!(aggregate["items"][0]["symbol"], "render");
2710 assert_eq!(aggregate["uncertain_count"], 0);
2711 }
2712
2713 #[test]
2714 fn free_function_called_from_dispatch_live_method_body_is_live() {
2715 let (_temp_dir, root, paths) = fixture_project(&[
2726 (
2727 "src/service.ts",
2728 "export class Service { render() { helper(); } }\n",
2729 ),
2730 ("src/helper.ts", "export function helper() {}\n"),
2731 (
2732 "src/consumer.ts",
2733 "function run(service: Service) { service.render(); }\n",
2734 ),
2735 ]);
2736 let helper_target = format!("{}::helper", root.join("src/helper.ts").display());
2737 let aggregate = scan(job(
2738 &root,
2739 paths.clone(),
2740 snapshot(
2741 paths,
2742 vec![
2743 export(&root, "src/service.ts", "render", "method"),
2744 export(&root, "src/helper.ts", "helper", "function"),
2745 ],
2746 vec![
2747 outbound(
2750 &root,
2751 "src/consumer.ts",
2752 "run",
2753 &dispatched_target("render", "service.render"),
2754 ),
2755 outbound(&root, "src/service.ts", "Service::render", &helper_target),
2759 ],
2760 ),
2761 ));
2762
2763 assert_eq!(
2764 aggregate["count"], 0,
2765 "free function reached via dispatch-live method body must be live: {aggregate:#}"
2766 );
2767 assert!(aggregate["items"].as_array().unwrap().is_empty());
2768 }
2769
2770 #[test]
2771 fn rust_struct_referenced_only_in_types_is_live() {
2772 let (_temp_dir, root, paths) = fixture_project(&[
2773 ("src/types.rs", "pub struct Widget { id: u64 }\n"),
2774 (
2775 "src/main.rs",
2776 "use crate::types::Widget;\nstruct Holder { value: Widget }\npub fn main(input: Widget) -> Widget { input }\n",
2777 ),
2778 ]);
2779 let aggregate = scan(job(
2780 &root,
2781 paths.clone(),
2782 snapshot_with_entry_points(
2783 paths,
2784 vec![
2785 export(&root, "src/types.rs", "Widget", "struct"),
2786 export(&root, "src/main.rs", "main", "function"),
2787 ],
2788 Vec::new(),
2789 BTreeSet::from([root.join("src/main.rs")]),
2790 ),
2791 ));
2792
2793 assert_eq!(aggregate["count"], 0);
2794 assert_eq!(aggregate["uncertain_count"], 0);
2795 assert!(aggregate["items"].as_array().unwrap().is_empty());
2796 }
2797
2798 #[test]
2799 fn ts_interface_referenced_only_in_type_annotation_is_live() {
2800 let (_temp_dir, root, paths) = fixture_project(&[
2801 ("src/types.ts", "export interface Widget { id: string }\n"),
2802 (
2803 "src/main.ts",
2804 "import type { Widget } from './types';\nexport function run(input: Widget): void {}\n",
2805 ),
2806 ]);
2807 let aggregate = scan(job(
2808 &root,
2809 paths.clone(),
2810 snapshot_with_entry_points(
2811 paths,
2812 vec![
2813 export(&root, "src/types.ts", "Widget", "interface"),
2814 export(&root, "src/main.ts", "run", "function"),
2815 ],
2816 Vec::new(),
2817 BTreeSet::from([root.join("src/main.ts")]),
2818 ),
2819 ));
2820
2821 assert_eq!(aggregate["count"], 0);
2822 assert_eq!(aggregate["uncertain_count"], 0);
2823 assert!(aggregate["items"].as_array().unwrap().is_empty());
2824 }
2825
2826 #[test]
2827 fn type_like_export_without_call_or_type_ref_is_precise_dead() {
2828 let (_temp_dir, root, paths) =
2829 fixture_project(&[("src/types.ts", "export interface Widget { id: string }\n")]);
2830 let aggregate = scan(job(
2831 &root,
2832 paths.clone(),
2833 snapshot(
2834 paths,
2835 vec![export(&root, "src/types.ts", "Widget", "interface")],
2836 Vec::new(),
2837 ),
2838 ));
2839
2840 assert_eq!(aggregate["count"], 1);
2841 assert_eq!(aggregate["items"][0]["symbol"], "Widget");
2842 assert_eq!(aggregate["uncertain_count"], 0);
2843 assert!(aggregate["uncertain_items"].as_array().unwrap().is_empty());
2844 }
2845
2846 #[test]
2847 fn rust_attribute_entry_points_seed_command_liveness() {
2848 let (_temp_dir, root, paths) = fixture_project(&[
2849 (
2850 "src/commands.rs",
2851 r#"use crate::db;
2852
2853#[tauri::command]
2854pub fn get_primers() -> String {
2855 db::helper()
2856}
2857
2858pub fn planted_dead() -> String {
2859 "dead".to_string()
2860}
2861
2862#[tauri::command]
2863fn private_command() -> String {
2864 db::private_helper()
2865}
2866"#,
2867 ),
2868 (
2869 "src/imported.rs",
2870 r#"use crate::db;
2871use tauri::command;
2872
2873#[command]
2874pub fn imported_command() -> String {
2875 db::imported_helper()
2876}
2877"#,
2878 ),
2879 (
2880 "src/unimported.rs",
2881 r#"use crate::db;
2882
2883#[command]
2884pub fn false_command() -> String {
2885 db::false_helper()
2886}
2887"#,
2888 ),
2889 (
2890 "src/db.rs",
2891 r#"pub fn helper() -> String { "live".to_string() }
2892pub fn imported_helper() -> String { "live".to_string() }
2893pub fn private_helper() -> String { "live".to_string() }
2894pub fn false_helper() -> String { "dead".to_string() }
2895"#,
2896 ),
2897 ]);
2898 let helper_target = format!("{}::helper", root.join("src/db.rs").display());
2899 let imported_helper_target =
2900 format!("{}::imported_helper", root.join("src/db.rs").display());
2901 let private_helper_target = format!("{}::private_helper", root.join("src/db.rs").display());
2902 let false_helper_target = format!("{}::false_helper", root.join("src/db.rs").display());
2903 let aggregate = scan(job(
2904 &root,
2905 paths.clone(),
2906 snapshot(
2907 paths,
2908 vec![
2909 export(&root, "src/commands.rs", "get_primers", "function"),
2910 export(&root, "src/commands.rs", "planted_dead", "function"),
2911 export(&root, "src/imported.rs", "imported_command", "function"),
2912 export(&root, "src/unimported.rs", "false_command", "function"),
2913 export(&root, "src/db.rs", "helper", "function"),
2914 export(&root, "src/db.rs", "imported_helper", "function"),
2915 export(&root, "src/db.rs", "private_helper", "function"),
2916 export(&root, "src/db.rs", "false_helper", "function"),
2917 ],
2918 vec![
2919 outbound(&root, "src/commands.rs", "get_primers", &helper_target),
2920 outbound(
2921 &root,
2922 "src/imported.rs",
2923 "imported_command",
2924 &imported_helper_target,
2925 ),
2926 outbound(
2927 &root,
2928 "src/commands.rs",
2929 "private_command",
2930 &private_helper_target,
2931 ),
2932 outbound(
2933 &root,
2934 "src/unimported.rs",
2935 "false_command",
2936 &false_helper_target,
2937 ),
2938 ],
2939 ),
2940 ));
2941
2942 assert!(!aggregate_has_item(
2943 &aggregate,
2944 "src/commands.rs",
2945 "get_primers"
2946 ));
2947 assert!(!aggregate_has_item(&aggregate, "src/db.rs", "helper"));
2948 assert!(!aggregate_has_item(
2949 &aggregate,
2950 "src/imported.rs",
2951 "imported_command"
2952 ));
2953 assert!(!aggregate_has_item(
2954 &aggregate,
2955 "src/db.rs",
2956 "imported_helper"
2957 ));
2958 assert!(!aggregate_has_item(
2959 &aggregate,
2960 "src/db.rs",
2961 "private_helper"
2962 ));
2963 assert!(aggregate_has_item(
2964 &aggregate,
2965 "src/commands.rs",
2966 "planted_dead"
2967 ));
2968 assert!(aggregate_has_item(
2969 &aggregate,
2970 "src/unimported.rs",
2971 "false_command"
2972 ));
2973 assert!(aggregate_has_item(&aggregate, "src/db.rs", "false_helper"));
2974 }
2975
2976 #[test]
2977 fn genuinely_unreachable_function_is_still_dead() {
2978 let (_temp_dir, root, paths) =
2979 fixture_project(&[("src/build.ts", "export function build() {}\n")]);
2980 let aggregate = scan(job(
2981 &root,
2982 paths.clone(),
2983 snapshot(
2984 paths,
2985 vec![export(&root, "src/build.ts", "build", "function")],
2986 Vec::new(),
2987 ),
2988 ));
2989
2990 assert_eq!(aggregate["count"], 1);
2991 assert_eq!(aggregate["items"][0]["symbol"], "build");
2992 assert_eq!(aggregate["uncertain_count"], 0);
2993 }
2994}