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