1mod boundary;
2pub mod feature_flags;
3mod iconify;
4mod package_json_utils;
5mod predicates;
6mod re_export_cycles;
7mod security;
8mod unused_catalog;
9mod unused_deps;
10mod unused_exports;
11mod unused_files;
12mod unused_members;
13mod unused_overrides;
14
15#[cfg(test)]
16pub(crate) use unused_deps::matches_virtual_prefix;
17
18pub use security::catalogue_title as security_catalogue_title;
22
23use rustc_hash::{FxHashMap, FxHashSet};
24
25use fallow_config::{PackageJson, ResolvedConfig, Severity};
26
27use crate::discover::FileId;
28use crate::extract::ModuleInfo;
29use crate::graph::ModuleGraph;
30use crate::resolve::ResolvedModule;
31use fallow_types::output_dead_code::{
32 BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
33 EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
34 ReExportCycleFinding, TestOnlyDependencyFinding, TypeOnlyDependencyFinding,
35 UnlistedDependencyFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
36 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
37 UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
38 UnusedExportFinding, UnusedFileFinding, UnusedOptionalDependencyFinding, UnusedTypeFinding,
39};
40
41use crate::results::{AnalysisResults, CircularDependency, CircularDependencyEdge};
42use crate::suppress::IssueKind;
43
44use re_export_cycles::find_re_export_cycles;
45#[expect(
46 deprecated,
47 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
48)]
49use unused_catalog::{
50 find_empty_catalog_groups, find_unresolved_catalog_references, find_unused_catalog_entries,
51 gather_pnpm_catalog_state,
52};
53#[expect(
54 deprecated,
55 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
56)]
57use unused_deps::{
58 find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
59 find_unresolved_imports, find_unused_dependencies,
60};
61#[expect(
62 deprecated,
63 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
64)]
65use unused_exports::{
66 collect_export_usages, find_private_type_leaks, find_unused_exports,
67 suppress_signature_backing_types,
68};
69#[expect(
70 deprecated,
71 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
72)]
73use unused_files::find_unused_files;
74use unused_members::find_unused_members_with_public_api_entry_points;
75#[expect(
76 deprecated,
77 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
78)]
79use unused_overrides::{
80 find_misconfigured_dependency_overrides, find_unused_dependency_overrides,
81 gather_pnpm_override_state,
82};
83
84#[doc(hidden)]
87pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
88
89#[doc(hidden)]
92pub fn byte_offset_to_line_col(
93 line_offsets_map: &LineOffsetsMap<'_>,
94 file_id: FileId,
95 byte_offset: u32,
96) -> (u32, u32) {
97 line_offsets_map
98 .get(&file_id)
99 .map_or((1, byte_offset), |offsets| {
100 fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
101 })
102}
103
104fn cycle_edge_line_col(
105 graph: &ModuleGraph,
106 line_offsets_map: &LineOffsetsMap<'_>,
107 cycle: &[FileId],
108 edge_index: usize,
109) -> Option<(u32, u32)> {
110 if cycle.is_empty() {
111 return None;
112 }
113
114 let from = cycle[edge_index];
115 let to = cycle[(edge_index + 1) % cycle.len()];
116 graph
117 .find_import_span_start(from, to)
118 .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
119}
120
121fn is_circular_dependency_suppressed(
122 graph: &ModuleGraph,
123 line_offsets_map: &LineOffsetsMap<'_>,
124 suppressions: &crate::suppress::SuppressionContext<'_>,
125 cycle: &[FileId],
126) -> bool {
127 if cycle
128 .iter()
129 .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
130 {
131 return true;
132 }
133
134 let mut line_suppressed = false;
135 for edge_index in 0..cycle.len() {
136 let from = cycle[edge_index];
137 if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
138 && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
139 {
140 line_suppressed = true;
141 }
142 }
143 line_suppressed
144}
145
146fn read_source(path: &std::path::Path) -> String {
150 std::fs::read_to_string(path).unwrap_or_default()
151}
152
153fn is_cross_package_cycle(
158 files: &[std::path::PathBuf],
159 workspaces: &[fallow_config::WorkspaceInfo],
160) -> bool {
161 let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
162 workspaces
163 .iter()
164 .map(|w| w.root.as_path())
165 .filter(|root| path.starts_with(root))
166 .max_by_key(|root| root.components().count())
167 };
168
169 let mut seen_workspace: Option<&std::path::Path> = None;
170 for file in files {
171 if let Some(ws) = find_workspace(file) {
172 match &seen_workspace {
173 None => seen_workspace = Some(ws),
174 Some(prev) if *prev != ws => return true,
175 _ => {}
176 }
177 }
178 }
179 false
180}
181
182fn public_workspace_roots<'a>(
183 public_packages: &[String],
184 workspaces: &'a [fallow_config::WorkspaceInfo],
185) -> Vec<&'a std::path::Path> {
186 if public_packages.is_empty() || workspaces.is_empty() {
187 return Vec::new();
188 }
189
190 workspaces
191 .iter()
192 .filter(|ws| {
193 public_packages.iter().any(|pattern| {
194 ws.name == *pattern
195 || globset::Glob::new(pattern)
196 .ok()
197 .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
198 })
199 })
200 .map(|ws| ws.root.as_path())
201 .collect()
202}
203
204fn graph_path_to_file_id(graph: &ModuleGraph) -> FxHashMap<std::path::PathBuf, FileId> {
205 let mut path_to_file_id = FxHashMap::default();
206 for module in &graph.modules {
207 path_to_file_id.insert(module.path.clone(), module.file_id);
208 if let Ok(canonical) = dunce::canonicalize(&module.path) {
209 path_to_file_id.insert(canonical, module.file_id);
210 }
211 }
212 path_to_file_id
213}
214
215fn add_package_public_api_entry_points(
216 public_api_entry_points: &mut FxHashSet<FileId>,
217 path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
218 package_root: &std::path::Path,
219 package_json: &PackageJson,
220 canonical_project_root: &std::path::Path,
221) {
222 if package_json.private.unwrap_or(false) {
223 return;
224 }
225
226 for entry in package_json.entry_points() {
227 let Some(entry_point) = crate::discover::resolve_entry_path(
228 package_root,
229 &entry,
230 canonical_project_root,
231 crate::discover::EntryPointSource::PackageJsonExports,
232 ) else {
233 continue;
234 };
235
236 if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
237 dunce::canonicalize(&entry_point.path)
238 .ok()
239 .and_then(|canonical| path_to_file_id.get(&canonical).copied())
240 }) {
241 public_api_entry_points.insert(file_id);
242 }
243 }
244}
245
246fn is_source_index_under_package(path: &std::path::Path, package_root: &std::path::Path) -> bool {
247 let Ok(relative) = path.strip_prefix(package_root) else {
248 return false;
249 };
250
251 if !matches!(
252 relative.components().next(),
253 Some(std::path::Component::Normal(segment)) if segment == "src"
254 ) {
255 return false;
256 }
257
258 path.file_stem()
259 .and_then(|stem| stem.to_str())
260 .is_some_and(|stem| stem == "index")
261}
262
263fn add_exportless_package_source_indexes(
264 public_api_entry_points: &mut FxHashSet<FileId>,
265 graph: &ModuleGraph,
266 package_root: &std::path::Path,
267 package_json: &PackageJson,
268) {
269 if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
270 return;
271 }
272
273 let mut roots = vec![package_root.to_path_buf()];
274 if let Ok(canonical) = dunce::canonicalize(package_root) {
275 roots.push(canonical);
276 }
277
278 for module in &graph.modules {
279 if roots
280 .iter()
281 .any(|root| is_source_index_under_package(&module.path, root))
282 {
283 public_api_entry_points.insert(module.file_id);
284 }
285 }
286}
287
288fn public_api_package_entry_points(
289 graph: &ModuleGraph,
290 config: &ResolvedConfig,
291 root_pkg: Option<&PackageJson>,
292 workspaces: &[fallow_config::WorkspaceInfo],
293) -> FxHashSet<FileId> {
294 let mut public_api_entry_points = FxHashSet::default();
295 let path_to_file_id = graph_path_to_file_id(graph);
296 let canonical_project_root =
297 dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
298
299 if let Some(pkg) = root_pkg {
300 add_package_public_api_entry_points(
301 &mut public_api_entry_points,
302 &path_to_file_id,
303 &config.root,
304 pkg,
305 &canonical_project_root,
306 );
307 add_exportless_package_source_indexes(
308 &mut public_api_entry_points,
309 graph,
310 &config.root,
311 pkg,
312 );
313 }
314
315 for workspace in workspaces {
316 let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) else {
317 continue;
318 };
319 add_package_public_api_entry_points(
320 &mut public_api_entry_points,
321 &path_to_file_id,
322 &workspace.root,
323 &pkg,
324 &canonical_project_root,
325 );
326 add_exportless_package_source_indexes(
327 &mut public_api_entry_points,
328 graph,
329 &workspace.root,
330 &pkg,
331 );
332 }
333
334 public_api_entry_points
335}
336
337fn find_circular_dependencies(
338 graph: &ModuleGraph,
339 line_offsets_map: &LineOffsetsMap<'_>,
340 suppressions: &crate::suppress::SuppressionContext<'_>,
341 workspaces: &[fallow_config::WorkspaceInfo],
342) -> Vec<CircularDependency> {
343 let cycles = graph.find_cycles();
344 let mut dependencies: Vec<CircularDependency> = cycles
345 .into_iter()
346 .filter_map(|cycle| {
347 if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
348 return None;
349 }
350
351 let edges: Vec<CircularDependencyEdge> = (0..cycle.len())
357 .map(|edge_index| {
358 let from = cycle[edge_index];
359 let (line, col) =
360 cycle_edge_line_col(graph, line_offsets_map, &cycle, edge_index)
361 .unwrap_or((1, 0));
362 CircularDependencyEdge {
363 path: graph.modules[from.0 as usize].path.clone(),
364 line,
365 col,
366 }
367 })
368 .collect();
369
370 let files: Vec<std::path::PathBuf> =
371 edges.iter().map(|edge| edge.path.clone()).collect();
372 let length = files.len();
373 let (line, col) = edges.first().map_or((1, 0), |edge| (edge.line, edge.col));
376 Some(CircularDependency {
377 files,
378 length,
379 line,
380 col,
381 edges,
382 is_cross_package: false,
383 })
384 })
385 .collect();
386
387 if !workspaces.is_empty() {
388 for dep in &mut dependencies {
389 dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
390 }
391 }
392
393 dependencies
394}
395
396fn run_circular_dep_detector(
401 graph: &ModuleGraph,
402 config: &ResolvedConfig,
403 line_offsets_by_file: &LineOffsetsMap<'_>,
404 suppressions: &crate::suppress::SuppressionContext<'_>,
405 workspaces: &[fallow_config::WorkspaceInfo],
406) -> Vec<CircularDependencyFinding> {
407 if config.rules.circular_dependencies == Severity::Off {
408 return Vec::new();
409 }
410 find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
411 .into_iter()
412 .map(CircularDependencyFinding::with_actions)
413 .collect()
414}
415
416fn run_re_export_cycle_detector(
419 graph: &ModuleGraph,
420 config: &ResolvedConfig,
421 suppressions: &crate::suppress::SuppressionContext<'_>,
422) -> Vec<ReExportCycleFinding> {
423 if config.rules.re_export_cycle == Severity::Off {
424 return Vec::new();
425 }
426 find_re_export_cycles(graph, suppressions)
427}
428
429fn run_export_usages_collector(
432 graph: &ModuleGraph,
433 line_offsets_by_file: &LineOffsetsMap<'_>,
434 collect_usages: bool,
435) -> Vec<crate::results::ExportUsage> {
436 if collect_usages {
437 collect_export_usages(graph, line_offsets_by_file)
438 } else {
439 Vec::new()
440 }
441}
442
443fn collect_declared_dependency_names(
450 config: &ResolvedConfig,
451 root_pkg: Option<&PackageJson>,
452 workspaces: &[fallow_config::WorkspaceInfo],
453) -> FxHashSet<String> {
454 let mut deps: FxHashSet<String> = FxHashSet::default();
455 if let Some(pkg) = root_pkg {
456 deps.extend(pkg.all_dependency_names());
457 }
458 for ws in workspaces {
459 if ws.root == config.root {
460 continue; }
462 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
463 deps.extend(pkg.all_dependency_names());
464 }
465 }
466 deps
467}
468
469#[expect(
471 deprecated,
472 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
473)]
474#[deprecated(
475 since = "2.76.0",
476 note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
477)]
478#[expect(
479 clippy::too_many_lines,
480 reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
481)]
482pub fn find_dead_code_full(
483 graph: &ModuleGraph,
484 config: &ResolvedConfig,
485 resolved_modules: &[ResolvedModule],
486 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
487 workspaces: &[fallow_config::WorkspaceInfo],
488 modules: &[ModuleInfo],
489 collect_usages: bool,
490) -> AnalysisResults {
491 let _span = tracing::info_span!("find_dead_code").entered();
492
493 let suppressions = crate::suppress::SuppressionContext::new(modules);
494
495 let line_offsets_by_file: LineOffsetsMap<'_> = modules
496 .iter()
497 .filter(|m| !m.line_offsets.is_empty())
498 .map(|m| (m.file_id, m.line_offsets.as_slice()))
499 .collect();
500
501 let pkg_path = config.root.join("package.json");
502 let pkg = PackageJson::load(&pkg_path).ok();
503 let public_api_entry_points =
504 public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
505
506 let iconify_referenced =
507 iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
508 let augmented_plugin_result;
509 let plugin_result = if iconify_referenced.is_empty() {
510 plugin_result
511 } else {
512 let mut owned = plugin_result.cloned().unwrap_or_default();
513 owned.referenced_dependencies.extend(iconify_referenced);
514 augmented_plugin_result = owned;
515 Some(&augmented_plugin_result)
516 };
517
518 let mut user_class_members = config.used_class_members.clone();
519 if let Some(plugin_result) = plugin_result {
520 user_class_members.extend(plugin_result.used_class_members.iter().cloned());
521 }
522
523 let virtual_prefixes: Vec<&str> = plugin_result
524 .map(|pr| {
525 pr.virtual_module_prefixes
526 .iter()
527 .map(String::as_str)
528 .collect()
529 })
530 .unwrap_or_default();
531 let generated_patterns: Vec<&str> = plugin_result
532 .map(|pr| {
533 pr.generated_import_patterns
534 .iter()
535 .map(String::as_str)
536 .collect()
537 })
538 .unwrap_or_default();
539 let generated_type_prefixes: Vec<&str> = plugin_result
540 .map(|pr| {
541 pr.generated_type_import_prefixes
542 .iter()
543 .map(String::as_str)
544 .collect()
545 })
546 .unwrap_or_default();
547
548 let (
549 (unused_files, export_results),
550 (
551 (member_results, dependency_results),
552 (
553 (unresolved_imports, duplicate_exports),
554 (boundary_violations, (circular_dependencies, (re_export_cycles, export_usages))),
555 ),
556 ),
557 ) = rayon::join(
558 || {
559 rayon::join(
560 || {
561 if config.rules.unused_files != Severity::Off {
562 find_unused_files(graph, &suppressions)
563 .into_iter()
564 .map(UnusedFileFinding::with_actions)
565 .collect::<Vec<_>>()
566 } else {
567 Vec::new()
568 }
569 },
570 || {
571 let mut results = AnalysisResults::default();
572 if config.rules.unused_exports != Severity::Off
573 || config.rules.unused_types != Severity::Off
574 || config.rules.private_type_leaks != Severity::Off
575 {
576 let (exports, types, stale_expected) = find_unused_exports(
577 graph,
578 modules,
579 config,
580 plugin_result,
581 &suppressions,
582 &line_offsets_by_file,
583 );
584 if config.rules.unused_exports != Severity::Off {
585 results.unused_exports = exports
586 .into_iter()
587 .map(UnusedExportFinding::with_actions)
588 .collect();
589 }
590 if config.rules.unused_types != Severity::Off {
591 let mut typed = types;
592 suppress_signature_backing_types(&mut typed, graph, modules);
593 results.unused_types = typed
594 .into_iter()
595 .map(UnusedTypeFinding::with_actions)
596 .collect();
597 }
598 if config.rules.private_type_leaks != Severity::Off {
599 results.private_type_leaks = find_private_type_leaks(
600 graph,
601 modules,
602 config,
603 &suppressions,
604 &line_offsets_by_file,
605 )
606 .into_iter()
607 .map(PrivateTypeLeakFinding::with_actions)
608 .collect();
609 }
610 if config.rules.stale_suppressions != Severity::Off {
611 results.stale_suppressions.extend(stale_expected);
612 }
613 }
614 results
615 },
616 )
617 },
618 || {
619 rayon::join(
620 || {
621 rayon::join(
622 || {
623 let mut results = AnalysisResults::default();
624 if config.rules.unused_enum_members != Severity::Off
625 || config.rules.unused_class_members != Severity::Off
626 {
627 let (enum_members, class_members) =
628 find_unused_members_with_public_api_entry_points(
629 graph,
630 resolved_modules,
631 modules,
632 &suppressions,
633 &line_offsets_by_file,
634 &user_class_members,
635 &config.ignore_decorators,
636 &public_api_entry_points,
637 );
638 if config.rules.unused_enum_members != Severity::Off {
639 results.unused_enum_members = enum_members
640 .into_iter()
641 .map(UnusedEnumMemberFinding::with_actions)
642 .collect();
643 }
644 if config.rules.unused_class_members != Severity::Off {
645 results.unused_class_members = class_members
646 .into_iter()
647 .map(UnusedClassMemberFinding::with_actions)
648 .collect();
649 }
650 }
651 results
652 },
653 || {
654 let mut results = AnalysisResults::default();
655 if let Some(ref pkg) = pkg {
656 if config.rules.unused_dependencies != Severity::Off
657 || config.rules.unused_dev_dependencies != Severity::Off
658 || config.rules.unused_optional_dependencies != Severity::Off
659 {
660 let (deps, dev_deps, optional_deps) = find_unused_dependencies(
661 graph,
662 pkg,
663 config,
664 plugin_result,
665 workspaces,
666 );
667 if config.rules.unused_dependencies != Severity::Off {
668 results.unused_dependencies = deps
669 .into_iter()
670 .map(UnusedDependencyFinding::with_actions)
671 .collect();
672 }
673 if config.rules.unused_dev_dependencies != Severity::Off {
674 results.unused_dev_dependencies = dev_deps
675 .into_iter()
676 .map(UnusedDevDependencyFinding::with_actions)
677 .collect();
678 }
679 if config.rules.unused_optional_dependencies != Severity::Off {
680 results.unused_optional_dependencies = optional_deps
681 .into_iter()
682 .map(UnusedOptionalDependencyFinding::with_actions)
683 .collect();
684 }
685 }
686
687 if config.rules.unlisted_dependencies != Severity::Off {
688 results.unlisted_dependencies = find_unlisted_dependencies(
689 graph,
690 pkg,
691 config,
692 workspaces,
693 plugin_result,
694 resolved_modules,
695 &line_offsets_by_file,
696 )
697 .into_iter()
698 .map(UnlistedDependencyFinding::with_actions)
699 .collect();
700 }
701
702 if config.production {
703 results.type_only_dependencies =
704 find_type_only_dependencies(graph, pkg, config, workspaces)
705 .into_iter()
706 .map(TypeOnlyDependencyFinding::with_actions)
707 .collect();
708 }
709
710 if !config.production
711 && config.rules.test_only_dependencies != Severity::Off
712 {
713 results.test_only_dependencies =
714 find_test_only_dependencies(graph, pkg, config, workspaces)
715 .into_iter()
716 .map(TestOnlyDependencyFinding::with_actions)
717 .collect();
718 }
719 }
720 results
721 },
722 )
723 },
724 || {
725 rayon::join(
726 || {
727 rayon::join(
728 || {
729 if config.rules.unresolved_imports != Severity::Off
730 && !resolved_modules.is_empty()
731 {
732 find_unresolved_imports(
733 resolved_modules,
734 config,
735 &suppressions,
736 &virtual_prefixes,
737 &generated_patterns,
738 &generated_type_prefixes,
739 &line_offsets_by_file,
740 )
741 .into_iter()
742 .map(UnresolvedImportFinding::with_actions)
743 .collect::<Vec<_>>()
744 } else {
745 Vec::new()
746 }
747 },
748 || {
749 if config.rules.duplicate_exports != Severity::Off {
750 let duplicate_exports =
751 if let Some(plugin_result) = plugin_result {
752 unused_exports::find_duplicate_exports_with_plugins(
753 graph,
754 config,
755 &suppressions,
756 &line_offsets_by_file,
757 Some(plugin_result),
758 resolved_modules,
759 )
760 } else {
761 unused_exports::find_duplicate_exports(
762 graph,
763 config,
764 &suppressions,
765 &line_offsets_by_file,
766 resolved_modules,
767 )
768 };
769 duplicate_exports
770 .into_iter()
771 .map(DuplicateExportFinding::with_actions)
772 .collect::<Vec<_>>()
773 } else {
774 Vec::new()
775 }
776 },
777 )
778 },
779 || {
780 rayon::join(
781 || {
782 if config.rules.boundary_violation != Severity::Off
783 && !config.boundaries.is_empty()
784 {
785 boundary::find_boundary_violations(
786 graph,
787 config,
788 &suppressions,
789 &line_offsets_by_file,
790 )
791 .into_iter()
792 .map(BoundaryViolationFinding::with_actions)
793 .collect::<Vec<_>>()
794 } else {
795 Vec::new()
796 }
797 },
798 || {
799 rayon::join(
800 || {
801 run_circular_dep_detector(
802 graph,
803 config,
804 &line_offsets_by_file,
805 &suppressions,
806 workspaces,
807 )
808 },
809 || {
810 rayon::join(
811 || {
812 run_re_export_cycle_detector(
813 graph,
814 config,
815 &suppressions,
816 )
817 },
818 || {
819 run_export_usages_collector(
820 graph,
821 &line_offsets_by_file,
822 collect_usages,
823 )
824 },
825 )
826 },
827 )
828 },
829 )
830 },
831 )
832 },
833 )
834 },
835 );
836
837 let mut results = AnalysisResults {
838 unused_files,
839 unused_exports: export_results.unused_exports,
840 unused_types: export_results.unused_types,
841 private_type_leaks: export_results.private_type_leaks,
842 stale_suppressions: export_results.stale_suppressions,
843 unused_enum_members: member_results.unused_enum_members,
844 unused_class_members: member_results.unused_class_members,
845 unused_dependencies: dependency_results.unused_dependencies,
846 unused_dev_dependencies: dependency_results.unused_dev_dependencies,
847 unused_optional_dependencies: dependency_results.unused_optional_dependencies,
848 unlisted_dependencies: dependency_results.unlisted_dependencies,
849 type_only_dependencies: dependency_results.type_only_dependencies,
850 test_only_dependencies: dependency_results.test_only_dependencies,
851 unresolved_imports,
852 duplicate_exports,
853 boundary_violations,
854 circular_dependencies,
855 re_export_cycles,
856 export_usages,
857 ..AnalysisResults::default()
858 };
859
860 let public_roots = public_workspace_roots(&config.public_packages, workspaces);
861 if !public_roots.is_empty() {
862 results.unused_exports.retain(|e| {
863 !public_roots
864 .iter()
865 .any(|root| e.export.path.starts_with(root))
866 });
867 results.unused_types.retain(|e| {
868 !public_roots
869 .iter()
870 .any(|root| e.export.path.starts_with(root))
871 });
872 results.unused_enum_members.retain(|e| {
873 !public_roots
874 .iter()
875 .any(|root| e.member.path.starts_with(root))
876 });
877 results.unused_class_members.retain(|e| {
878 !public_roots
879 .iter()
880 .any(|root| e.member.path.starts_with(root))
881 });
882 }
883
884 if config.rules.security_client_server_leak != Severity::Off {
885 let (security_findings, stats) =
886 security::find_security_findings(graph, modules, &suppressions, &line_offsets_by_file);
887 results.security_findings = security_findings;
888 results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
889 }
890
891 if config.rules.security_sink != Severity::Off {
892 let categories = config.security.categories.as_ref();
893 let filter = security::CategoryFilter::new(
894 categories.and_then(|c| c.include.clone()),
895 categories.and_then(|c| c.exclude.clone()),
896 );
897 let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
902 let (sink_findings, sink_stats) = security::find_tainted_sinks(
903 graph,
904 modules,
905 &suppressions,
906 &line_offsets_by_file,
907 &filter,
908 &declared_deps,
909 &config.root,
910 );
911 results.security_findings.extend(sink_findings);
912 results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
913 }
914
915 if !results.security_findings.is_empty() {
921 let boundary_anchor_paths: rustc_hash::FxHashSet<std::path::PathBuf> = results
922 .boundary_violations
923 .iter()
924 .flat_map(|b| [b.violation.from_path.clone(), b.violation.to_path.clone()])
925 .collect();
926 security::rank_security_findings(
927 graph,
928 &boundary_anchor_paths,
929 &mut results.security_findings,
930 );
931 }
932
933 if config.rules.stale_suppressions != Severity::Off {
934 results
935 .stale_suppressions
936 .extend(suppressions.find_stale(graph, config));
937 }
938 results.suppression_count = suppressions.used_count();
939 results.active_suppressions = suppressions.all_suppressions(graph);
940
941 let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
942 let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
943 let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
944 if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
945 && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
946 {
947 if need_unused_catalogs {
948 results.unused_catalog_entries = find_unused_catalog_entries(&state)
949 .into_iter()
950 .map(UnusedCatalogEntryFinding::with_actions)
951 .collect();
952 }
953 if need_empty_catalog_groups {
954 results.empty_catalog_groups = find_empty_catalog_groups(&state)
955 .into_iter()
956 .map(EmptyCatalogGroupFinding::with_actions)
957 .collect();
958 }
959 if need_unresolved_refs {
960 results.unresolved_catalog_references = find_unresolved_catalog_references(
961 &state,
962 &config.compiled_ignore_catalog_references,
963 &config.root,
964 )
965 .into_iter()
966 .map(UnresolvedCatalogReferenceFinding::with_actions)
967 .collect();
968 }
969 }
970
971 let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
972 let need_misconfigured_overrides =
973 config.rules.misconfigured_dependency_overrides != Severity::Off;
974 if (need_unused_overrides || need_misconfigured_overrides)
975 && let Some(state) = gather_pnpm_override_state(config, workspaces)
976 {
977 if need_unused_overrides {
978 results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
979 .into_iter()
980 .map(UnusedDependencyOverrideFinding::with_actions)
981 .collect();
982 }
983 if need_misconfigured_overrides {
984 results.misconfigured_dependency_overrides =
985 find_misconfigured_dependency_overrides(&state, config)
986 .into_iter()
987 .map(MisconfiguredDependencyOverrideFinding::with_actions)
988 .collect();
989 }
990 }
991
992 results.sort();
993
994 results
995}
996
997#[cfg(test)]
998#[expect(
999 deprecated,
1000 reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
1001)]
1002mod tests {
1003 use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
1004
1005 fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
1006 let offsets = compute_line_offsets(source);
1007 byte_offset_to_line_col(&offsets, byte_offset)
1008 }
1009
1010 #[test]
1011 fn compute_offsets_empty() {
1012 assert_eq!(compute_line_offsets(""), vec![0]);
1013 }
1014
1015 #[test]
1016 fn compute_offsets_single_line() {
1017 assert_eq!(compute_line_offsets("hello"), vec![0]);
1018 }
1019
1020 #[test]
1021 fn compute_offsets_multiline() {
1022 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1023 }
1024
1025 #[test]
1026 fn compute_offsets_trailing_newline() {
1027 assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
1028 }
1029
1030 #[test]
1031 fn compute_offsets_crlf() {
1032 assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1033 }
1034
1035 #[test]
1036 fn compute_offsets_consecutive_newlines() {
1037 assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1038 }
1039
1040 #[test]
1041 fn byte_offset_empty_source() {
1042 assert_eq!(line_col("", 0), (1, 0));
1043 }
1044
1045 #[test]
1046 fn byte_offset_single_line_start() {
1047 assert_eq!(line_col("hello", 0), (1, 0));
1048 }
1049
1050 #[test]
1051 fn byte_offset_single_line_middle() {
1052 assert_eq!(line_col("hello", 4), (1, 4));
1053 }
1054
1055 #[test]
1056 fn byte_offset_multiline_start_of_line2() {
1057 assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1058 }
1059
1060 #[test]
1061 fn byte_offset_multiline_middle_of_line3() {
1062 assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1063 }
1064
1065 #[test]
1066 fn byte_offset_at_newline_boundary() {
1067 assert_eq!(line_col("line1\nline2", 5), (1, 5));
1068 }
1069
1070 #[test]
1071 fn byte_offset_multibyte_utf8() {
1072 let source = "hi\n\u{1F600}x";
1073 assert_eq!(line_col(source, 3), (2, 0));
1074 assert_eq!(line_col(source, 7), (2, 4));
1075 }
1076
1077 #[test]
1078 fn byte_offset_multibyte_accented_chars() {
1079 let source = "caf\u{00E9}\nbar";
1080 assert_eq!(line_col(source, 6), (2, 0));
1081 assert_eq!(line_col(source, 3), (1, 3));
1082 }
1083
1084 #[test]
1085 fn byte_offset_via_map_fallback() {
1086 use super::*;
1087 let map: LineOffsetsMap<'_> = FxHashMap::default();
1088 assert_eq!(
1089 super::byte_offset_to_line_col(&map, FileId(99), 42),
1090 (1, 42)
1091 );
1092 }
1093
1094 #[test]
1095 fn byte_offset_via_map_lookup() {
1096 use super::*;
1097 let offsets = compute_line_offsets("abc\ndef\nghi");
1098 let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1099 map.insert(FileId(0), &offsets);
1100 assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1101 }
1102
1103 mod orchestration {
1104 use super::super::*;
1105 use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1106 use std::path::PathBuf;
1107
1108 fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1109 find_dead_code_full(graph, config, &[], None, &[], &[], false)
1110 }
1111
1112 fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1113 FallowConfig {
1114 rules,
1115 ..Default::default()
1116 }
1117 .resolve(
1118 PathBuf::from("/tmp/orchestration-test"),
1119 OutputFormat::Human,
1120 1,
1121 true,
1122 true,
1123 None,
1124 )
1125 }
1126
1127 #[test]
1128 fn find_dead_code_all_rules_off_returns_empty() {
1129 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1130 use crate::graph::ModuleGraph;
1131 use crate::resolve::ResolvedModule;
1132 use rustc_hash::FxHashSet;
1133
1134 let files = vec![DiscoveredFile {
1135 id: FileId(0),
1136 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1137 size_bytes: 100,
1138 }];
1139 let entry_points = vec![EntryPoint {
1140 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1141 source: EntryPointSource::ManualEntry,
1142 }];
1143 let resolved = vec![ResolvedModule {
1144 file_id: FileId(0),
1145 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1146 exports: vec![],
1147 re_exports: vec![],
1148 resolved_imports: vec![],
1149 resolved_dynamic_imports: vec![],
1150 resolved_dynamic_patterns: vec![],
1151 member_accesses: vec![],
1152 whole_object_uses: vec![],
1153 has_cjs_exports: false,
1154 has_angular_component_template_url: false,
1155 unused_import_bindings: FxHashSet::default(),
1156 type_referenced_import_bindings: vec![],
1157 value_referenced_import_bindings: vec![],
1158 namespace_object_aliases: vec![],
1159 }];
1160 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1161
1162 let rules = RulesConfig {
1163 unused_files: Severity::Off,
1164 unused_exports: Severity::Off,
1165 unused_types: Severity::Off,
1166 private_type_leaks: Severity::Off,
1167 unused_dependencies: Severity::Off,
1168 unused_dev_dependencies: Severity::Off,
1169 unused_optional_dependencies: Severity::Off,
1170 unused_enum_members: Severity::Off,
1171 unused_class_members: Severity::Off,
1172 unresolved_imports: Severity::Off,
1173 unlisted_dependencies: Severity::Off,
1174 duplicate_exports: Severity::Off,
1175 type_only_dependencies: Severity::Off,
1176 circular_dependencies: Severity::Off,
1177 re_export_cycle: Severity::Off,
1178 test_only_dependencies: Severity::Off,
1179 boundary_violation: Severity::Off,
1180 coverage_gaps: Severity::Off,
1181 feature_flags: Severity::Off,
1182 stale_suppressions: Severity::Off,
1183 unused_catalog_entries: Severity::Off,
1184 empty_catalog_groups: Severity::Off,
1185 unresolved_catalog_references: Severity::Off,
1186 unused_dependency_overrides: Severity::Off,
1187 misconfigured_dependency_overrides: Severity::Off,
1188 security_client_server_leak: Severity::Off,
1189 security_sink: Severity::Off,
1190 };
1191 let config = make_config_with_rules(rules);
1192 let results = find_dead_code(&graph, &config);
1193
1194 assert!(results.unused_files.is_empty());
1195 assert!(results.unused_exports.is_empty());
1196 assert!(results.unused_types.is_empty());
1197 assert!(results.unused_dependencies.is_empty());
1198 assert!(results.unused_dev_dependencies.is_empty());
1199 assert!(results.unused_optional_dependencies.is_empty());
1200 assert!(results.unused_enum_members.is_empty());
1201 assert!(results.unused_class_members.is_empty());
1202 assert!(results.unresolved_imports.is_empty());
1203 assert!(results.unlisted_dependencies.is_empty());
1204 assert!(results.duplicate_exports.is_empty());
1205 assert!(results.circular_dependencies.is_empty());
1206 assert!(results.export_usages.is_empty());
1207 }
1208
1209 #[test]
1210 fn find_dead_code_full_collect_usages_flag() {
1211 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1212 use crate::extract::{ExportName, VisibilityTag};
1213 use crate::graph::{ExportSymbol, ModuleGraph};
1214 use crate::resolve::ResolvedModule;
1215 use oxc_span::Span;
1216 use rustc_hash::FxHashSet;
1217
1218 let files = vec![DiscoveredFile {
1219 id: FileId(0),
1220 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1221 size_bytes: 100,
1222 }];
1223 let entry_points = vec![EntryPoint {
1224 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1225 source: EntryPointSource::ManualEntry,
1226 }];
1227 let resolved = vec![ResolvedModule {
1228 file_id: FileId(0),
1229 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1230 exports: vec![],
1231 re_exports: vec![],
1232 resolved_imports: vec![],
1233 resolved_dynamic_imports: vec![],
1234 resolved_dynamic_patterns: vec![],
1235 member_accesses: vec![],
1236 whole_object_uses: vec![],
1237 has_cjs_exports: false,
1238 has_angular_component_template_url: false,
1239 unused_import_bindings: FxHashSet::default(),
1240 type_referenced_import_bindings: vec![],
1241 value_referenced_import_bindings: vec![],
1242 namespace_object_aliases: vec![],
1243 }];
1244 let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1245 graph.modules[0].exports = vec![ExportSymbol {
1246 name: ExportName::Named("myExport".to_string()),
1247 is_type_only: false,
1248 is_side_effect_used: false,
1249 visibility: VisibilityTag::None,
1250 span: Span::new(10, 30),
1251 references: vec![],
1252 members: vec![],
1253 }];
1254
1255 let rules = RulesConfig::default();
1256 let config = make_config_with_rules(rules);
1257
1258 let results_no_collect = find_dead_code_full(
1259 &graph,
1260 &config,
1261 &[],
1262 None,
1263 &[],
1264 &[],
1265 false, );
1267 assert!(
1268 results_no_collect.export_usages.is_empty(),
1269 "export_usages should be empty when collect_usages is false"
1270 );
1271
1272 let results_with_collect = find_dead_code_full(
1273 &graph,
1274 &config,
1275 &[],
1276 None,
1277 &[],
1278 &[],
1279 true, );
1281 assert!(
1282 !results_with_collect.export_usages.is_empty(),
1283 "export_usages should be populated when collect_usages is true"
1284 );
1285 assert_eq!(
1286 results_with_collect.export_usages[0].export_name,
1287 "myExport"
1288 );
1289 }
1290
1291 #[test]
1292 fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1293 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1294 use crate::graph::ModuleGraph;
1295 use crate::resolve::ResolvedModule;
1296 use rustc_hash::FxHashSet;
1297
1298 let files = vec![DiscoveredFile {
1299 id: FileId(0),
1300 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1301 size_bytes: 100,
1302 }];
1303 let entry_points = vec![EntryPoint {
1304 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1305 source: EntryPointSource::ManualEntry,
1306 }];
1307 let resolved = vec![ResolvedModule {
1308 file_id: FileId(0),
1309 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1310 exports: vec![],
1311 re_exports: vec![],
1312 resolved_imports: vec![],
1313 resolved_dynamic_imports: vec![],
1314 resolved_dynamic_patterns: vec![],
1315 member_accesses: vec![],
1316 whole_object_uses: vec![],
1317 has_cjs_exports: false,
1318 has_angular_component_template_url: false,
1319 unused_import_bindings: FxHashSet::default(),
1320 type_referenced_import_bindings: vec![],
1321 value_referenced_import_bindings: vec![],
1322 namespace_object_aliases: vec![],
1323 }];
1324 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1325 let config = make_config_with_rules(RulesConfig::default());
1326
1327 let results = find_dead_code(&graph, &config);
1328 assert!(results.unused_exports.is_empty());
1329 }
1330
1331 #[test]
1332 fn suppressions_built_from_modules() {
1333 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1334 use crate::extract::ModuleInfo;
1335 use crate::graph::ModuleGraph;
1336 use crate::resolve::ResolvedModule;
1337 use crate::suppress::{IssueKind, Suppression};
1338 use rustc_hash::FxHashSet;
1339
1340 let files = vec![
1341 DiscoveredFile {
1342 id: FileId(0),
1343 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1344 size_bytes: 100,
1345 },
1346 DiscoveredFile {
1347 id: FileId(1),
1348 path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1349 size_bytes: 100,
1350 },
1351 ];
1352 let entry_points = vec![EntryPoint {
1353 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1354 source: EntryPointSource::ManualEntry,
1355 }];
1356 let resolved = files
1357 .iter()
1358 .map(|f| ResolvedModule {
1359 file_id: f.id,
1360 path: f.path.clone(),
1361 exports: vec![],
1362 re_exports: vec![],
1363 resolved_imports: vec![],
1364 resolved_dynamic_imports: vec![],
1365 resolved_dynamic_patterns: vec![],
1366 member_accesses: vec![],
1367 whole_object_uses: vec![],
1368 has_cjs_exports: false,
1369 has_angular_component_template_url: false,
1370 unused_import_bindings: FxHashSet::default(),
1371 type_referenced_import_bindings: vec![],
1372 value_referenced_import_bindings: vec![],
1373 namespace_object_aliases: vec![],
1374 })
1375 .collect::<Vec<_>>();
1376 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1377
1378 let modules = vec![ModuleInfo {
1379 file_id: FileId(1),
1380 exports: vec![],
1381 imports: vec![],
1382 re_exports: vec![],
1383 dynamic_imports: vec![],
1384 dynamic_import_patterns: vec![],
1385 require_calls: vec![],
1386 package_path_references: vec![],
1387 member_accesses: vec![],
1388 whole_object_uses: vec![],
1389 has_cjs_exports: false,
1390 has_angular_component_template_url: false,
1391 content_hash: 0,
1392 suppressions: vec![Suppression {
1393 line: 0,
1394 comment_line: 1,
1395 kind: Some(IssueKind::UnusedFile),
1396 }],
1397 unknown_suppression_kinds: vec![],
1398 unused_import_bindings: vec![],
1399 type_referenced_import_bindings: vec![],
1400 value_referenced_import_bindings: vec![],
1401 line_offsets: vec![],
1402 complexity: vec![],
1403 flag_uses: vec![],
1404 class_heritage: vec![],
1405 injection_tokens: vec![],
1406 local_type_declarations: Vec::new(),
1407 public_signature_type_references: Vec::new(),
1408 namespace_object_aliases: Vec::new(),
1409 iconify_prefixes: Vec::new(),
1410 iconify_icon_names: Vec::new(),
1411 auto_import_candidates: Vec::new(),
1412 directives: Vec::new(),
1413 security_sinks: Vec::new(),
1414 security_sinks_skipped: 0,
1415 tainted_bindings: Vec::new(),
1416 sanitized_sink_args: Vec::new(),
1417 }];
1418
1419 let rules = RulesConfig {
1420 unused_files: Severity::Error,
1421 ..RulesConfig::default()
1422 };
1423 let config = make_config_with_rules(rules);
1424
1425 let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1426
1427 assert!(
1428 !results.unused_files.iter().any(|f| f
1429 .file
1430 .path
1431 .to_string_lossy()
1432 .contains("utils.ts")),
1433 "suppressed file should not appear in unused_files"
1434 );
1435 }
1436 }
1437}