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};
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_duplicate_exports, 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 files: Vec<std::path::PathBuf> = cycle
352 .iter()
353 .map(|&id| graph.modules[id.0 as usize].path.clone())
354 .collect();
355 let length = files.len();
356 let (line, col) =
357 cycle_edge_line_col(graph, line_offsets_map, &cycle, 0).unwrap_or((1, 0));
358 Some(CircularDependency {
359 files,
360 length,
361 line,
362 col,
363 is_cross_package: false,
364 })
365 })
366 .collect();
367
368 if !workspaces.is_empty() {
369 for dep in &mut dependencies {
370 dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
371 }
372 }
373
374 dependencies
375}
376
377fn run_circular_dep_detector(
382 graph: &ModuleGraph,
383 config: &ResolvedConfig,
384 line_offsets_by_file: &LineOffsetsMap<'_>,
385 suppressions: &crate::suppress::SuppressionContext<'_>,
386 workspaces: &[fallow_config::WorkspaceInfo],
387) -> Vec<CircularDependencyFinding> {
388 if config.rules.circular_dependencies == Severity::Off {
389 return Vec::new();
390 }
391 find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
392 .into_iter()
393 .map(CircularDependencyFinding::with_actions)
394 .collect()
395}
396
397fn run_re_export_cycle_detector(
400 graph: &ModuleGraph,
401 config: &ResolvedConfig,
402 suppressions: &crate::suppress::SuppressionContext<'_>,
403) -> Vec<ReExportCycleFinding> {
404 if config.rules.re_export_cycle == Severity::Off {
405 return Vec::new();
406 }
407 find_re_export_cycles(graph, suppressions)
408}
409
410fn run_export_usages_collector(
413 graph: &ModuleGraph,
414 line_offsets_by_file: &LineOffsetsMap<'_>,
415 collect_usages: bool,
416) -> Vec<crate::results::ExportUsage> {
417 if collect_usages {
418 collect_export_usages(graph, line_offsets_by_file)
419 } else {
420 Vec::new()
421 }
422}
423
424#[expect(
426 deprecated,
427 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
428)]
429#[deprecated(
430 since = "2.76.0",
431 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."
432)]
433#[expect(
434 clippy::too_many_lines,
435 reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
436)]
437pub fn find_dead_code_full(
438 graph: &ModuleGraph,
439 config: &ResolvedConfig,
440 resolved_modules: &[ResolvedModule],
441 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
442 workspaces: &[fallow_config::WorkspaceInfo],
443 modules: &[ModuleInfo],
444 collect_usages: bool,
445) -> AnalysisResults {
446 let _span = tracing::info_span!("find_dead_code").entered();
447
448 let suppressions = crate::suppress::SuppressionContext::new(modules);
449
450 let line_offsets_by_file: LineOffsetsMap<'_> = modules
451 .iter()
452 .filter(|m| !m.line_offsets.is_empty())
453 .map(|m| (m.file_id, m.line_offsets.as_slice()))
454 .collect();
455
456 let pkg_path = config.root.join("package.json");
457 let pkg = PackageJson::load(&pkg_path).ok();
458 let public_api_entry_points =
459 public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
460
461 let iconify_referenced =
462 iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
463 let augmented_plugin_result;
464 let plugin_result = if iconify_referenced.is_empty() {
465 plugin_result
466 } else {
467 let mut owned = plugin_result.cloned().unwrap_or_default();
468 owned.referenced_dependencies.extend(iconify_referenced);
469 augmented_plugin_result = owned;
470 Some(&augmented_plugin_result)
471 };
472
473 let mut user_class_members = config.used_class_members.clone();
474 if let Some(plugin_result) = plugin_result {
475 user_class_members.extend(plugin_result.used_class_members.iter().cloned());
476 }
477
478 let virtual_prefixes: Vec<&str> = plugin_result
479 .map(|pr| {
480 pr.virtual_module_prefixes
481 .iter()
482 .map(String::as_str)
483 .collect()
484 })
485 .unwrap_or_default();
486 let generated_patterns: Vec<&str> = plugin_result
487 .map(|pr| {
488 pr.generated_import_patterns
489 .iter()
490 .map(String::as_str)
491 .collect()
492 })
493 .unwrap_or_default();
494 let generated_type_prefixes: Vec<&str> = plugin_result
495 .map(|pr| {
496 pr.generated_type_import_prefixes
497 .iter()
498 .map(String::as_str)
499 .collect()
500 })
501 .unwrap_or_default();
502
503 let (
504 (unused_files, export_results),
505 (
506 (member_results, dependency_results),
507 (
508 (unresolved_imports, duplicate_exports),
509 (boundary_violations, (circular_dependencies, (re_export_cycles, export_usages))),
510 ),
511 ),
512 ) = rayon::join(
513 || {
514 rayon::join(
515 || {
516 if config.rules.unused_files != Severity::Off {
517 find_unused_files(graph, &suppressions)
518 .into_iter()
519 .map(UnusedFileFinding::with_actions)
520 .collect::<Vec<_>>()
521 } else {
522 Vec::new()
523 }
524 },
525 || {
526 let mut results = AnalysisResults::default();
527 if config.rules.unused_exports != Severity::Off
528 || config.rules.unused_types != Severity::Off
529 || config.rules.private_type_leaks != Severity::Off
530 {
531 let (exports, types, stale_expected) = find_unused_exports(
532 graph,
533 modules,
534 config,
535 plugin_result,
536 &suppressions,
537 &line_offsets_by_file,
538 );
539 if config.rules.unused_exports != Severity::Off {
540 results.unused_exports = exports
541 .into_iter()
542 .map(UnusedExportFinding::with_actions)
543 .collect();
544 }
545 if config.rules.unused_types != Severity::Off {
546 let mut typed = types;
547 suppress_signature_backing_types(&mut typed, graph, modules);
548 results.unused_types = typed
549 .into_iter()
550 .map(UnusedTypeFinding::with_actions)
551 .collect();
552 }
553 if config.rules.private_type_leaks != Severity::Off {
554 results.private_type_leaks = find_private_type_leaks(
555 graph,
556 modules,
557 config,
558 &suppressions,
559 &line_offsets_by_file,
560 )
561 .into_iter()
562 .map(PrivateTypeLeakFinding::with_actions)
563 .collect();
564 }
565 if config.rules.stale_suppressions != Severity::Off {
566 results.stale_suppressions.extend(stale_expected);
567 }
568 }
569 results
570 },
571 )
572 },
573 || {
574 rayon::join(
575 || {
576 rayon::join(
577 || {
578 let mut results = AnalysisResults::default();
579 if config.rules.unused_enum_members != Severity::Off
580 || config.rules.unused_class_members != Severity::Off
581 {
582 let (enum_members, class_members) =
583 find_unused_members_with_public_api_entry_points(
584 graph,
585 resolved_modules,
586 modules,
587 &suppressions,
588 &line_offsets_by_file,
589 &user_class_members,
590 &config.ignore_decorators,
591 &public_api_entry_points,
592 );
593 if config.rules.unused_enum_members != Severity::Off {
594 results.unused_enum_members = enum_members
595 .into_iter()
596 .map(UnusedEnumMemberFinding::with_actions)
597 .collect();
598 }
599 if config.rules.unused_class_members != Severity::Off {
600 results.unused_class_members = class_members
601 .into_iter()
602 .map(UnusedClassMemberFinding::with_actions)
603 .collect();
604 }
605 }
606 results
607 },
608 || {
609 let mut results = AnalysisResults::default();
610 if let Some(ref pkg) = pkg {
611 if config.rules.unused_dependencies != Severity::Off
612 || config.rules.unused_dev_dependencies != Severity::Off
613 || config.rules.unused_optional_dependencies != Severity::Off
614 {
615 let (deps, dev_deps, optional_deps) = find_unused_dependencies(
616 graph,
617 pkg,
618 config,
619 plugin_result,
620 workspaces,
621 );
622 if config.rules.unused_dependencies != Severity::Off {
623 results.unused_dependencies = deps
624 .into_iter()
625 .map(UnusedDependencyFinding::with_actions)
626 .collect();
627 }
628 if config.rules.unused_dev_dependencies != Severity::Off {
629 results.unused_dev_dependencies = dev_deps
630 .into_iter()
631 .map(UnusedDevDependencyFinding::with_actions)
632 .collect();
633 }
634 if config.rules.unused_optional_dependencies != Severity::Off {
635 results.unused_optional_dependencies = optional_deps
636 .into_iter()
637 .map(UnusedOptionalDependencyFinding::with_actions)
638 .collect();
639 }
640 }
641
642 if config.rules.unlisted_dependencies != Severity::Off {
643 results.unlisted_dependencies = find_unlisted_dependencies(
644 graph,
645 pkg,
646 config,
647 workspaces,
648 plugin_result,
649 resolved_modules,
650 &line_offsets_by_file,
651 )
652 .into_iter()
653 .map(UnlistedDependencyFinding::with_actions)
654 .collect();
655 }
656
657 if config.production {
658 results.type_only_dependencies =
659 find_type_only_dependencies(graph, pkg, config, workspaces)
660 .into_iter()
661 .map(TypeOnlyDependencyFinding::with_actions)
662 .collect();
663 }
664
665 if !config.production
666 && config.rules.test_only_dependencies != Severity::Off
667 {
668 results.test_only_dependencies =
669 find_test_only_dependencies(graph, pkg, config, workspaces)
670 .into_iter()
671 .map(TestOnlyDependencyFinding::with_actions)
672 .collect();
673 }
674 }
675 results
676 },
677 )
678 },
679 || {
680 rayon::join(
681 || {
682 rayon::join(
683 || {
684 if config.rules.unresolved_imports != Severity::Off
685 && !resolved_modules.is_empty()
686 {
687 find_unresolved_imports(
688 resolved_modules,
689 config,
690 &suppressions,
691 &virtual_prefixes,
692 &generated_patterns,
693 &generated_type_prefixes,
694 &line_offsets_by_file,
695 )
696 .into_iter()
697 .map(UnresolvedImportFinding::with_actions)
698 .collect::<Vec<_>>()
699 } else {
700 Vec::new()
701 }
702 },
703 || {
704 if config.rules.duplicate_exports != Severity::Off {
705 find_duplicate_exports(
706 graph,
707 config,
708 &suppressions,
709 &line_offsets_by_file,
710 resolved_modules,
711 )
712 .into_iter()
713 .map(DuplicateExportFinding::with_actions)
714 .collect::<Vec<_>>()
715 } else {
716 Vec::new()
717 }
718 },
719 )
720 },
721 || {
722 rayon::join(
723 || {
724 if config.rules.boundary_violation != Severity::Off
725 && !config.boundaries.is_empty()
726 {
727 boundary::find_boundary_violations(
728 graph,
729 config,
730 &suppressions,
731 &line_offsets_by_file,
732 )
733 .into_iter()
734 .map(BoundaryViolationFinding::with_actions)
735 .collect::<Vec<_>>()
736 } else {
737 Vec::new()
738 }
739 },
740 || {
741 rayon::join(
742 || {
743 run_circular_dep_detector(
744 graph,
745 config,
746 &line_offsets_by_file,
747 &suppressions,
748 workspaces,
749 )
750 },
751 || {
752 rayon::join(
753 || {
754 run_re_export_cycle_detector(
755 graph,
756 config,
757 &suppressions,
758 )
759 },
760 || {
761 run_export_usages_collector(
762 graph,
763 &line_offsets_by_file,
764 collect_usages,
765 )
766 },
767 )
768 },
769 )
770 },
771 )
772 },
773 )
774 },
775 )
776 },
777 );
778
779 let mut results = AnalysisResults {
780 unused_files,
781 unused_exports: export_results.unused_exports,
782 unused_types: export_results.unused_types,
783 private_type_leaks: export_results.private_type_leaks,
784 stale_suppressions: export_results.stale_suppressions,
785 unused_enum_members: member_results.unused_enum_members,
786 unused_class_members: member_results.unused_class_members,
787 unused_dependencies: dependency_results.unused_dependencies,
788 unused_dev_dependencies: dependency_results.unused_dev_dependencies,
789 unused_optional_dependencies: dependency_results.unused_optional_dependencies,
790 unlisted_dependencies: dependency_results.unlisted_dependencies,
791 type_only_dependencies: dependency_results.type_only_dependencies,
792 test_only_dependencies: dependency_results.test_only_dependencies,
793 unresolved_imports,
794 duplicate_exports,
795 boundary_violations,
796 circular_dependencies,
797 re_export_cycles,
798 export_usages,
799 ..AnalysisResults::default()
800 };
801
802 let public_roots = public_workspace_roots(&config.public_packages, workspaces);
803 if !public_roots.is_empty() {
804 results.unused_exports.retain(|e| {
805 !public_roots
806 .iter()
807 .any(|root| e.export.path.starts_with(root))
808 });
809 results.unused_types.retain(|e| {
810 !public_roots
811 .iter()
812 .any(|root| e.export.path.starts_with(root))
813 });
814 results.unused_enum_members.retain(|e| {
815 !public_roots
816 .iter()
817 .any(|root| e.member.path.starts_with(root))
818 });
819 results.unused_class_members.retain(|e| {
820 !public_roots
821 .iter()
822 .any(|root| e.member.path.starts_with(root))
823 });
824 }
825
826 if config.rules.security_client_server_leak != Severity::Off {
827 let (security_findings, stats) =
828 security::find_security_findings(graph, modules, &suppressions, &line_offsets_by_file);
829 results.security_findings = security_findings;
830 results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
831 }
832
833 if config.rules.security_sink != Severity::Off {
834 let categories = config.security.categories.as_ref();
835 let filter = security::CategoryFilter::new(
836 categories.and_then(|c| c.include.clone()),
837 categories.and_then(|c| c.exclude.clone()),
838 );
839 let (sink_findings, sink_stats) = security::find_tainted_sinks(
840 graph,
841 modules,
842 &suppressions,
843 &line_offsets_by_file,
844 &filter,
845 &config.root,
846 );
847 results.security_findings.extend(sink_findings);
848 results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
849 }
850
851 if config.rules.stale_suppressions != Severity::Off {
852 results
853 .stale_suppressions
854 .extend(suppressions.find_stale(graph, config));
855 }
856 results.suppression_count = suppressions.used_count();
857 results.active_suppressions = suppressions.all_suppressions(graph);
858
859 let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
860 let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
861 let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
862 if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
863 && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
864 {
865 if need_unused_catalogs {
866 results.unused_catalog_entries = find_unused_catalog_entries(&state)
867 .into_iter()
868 .map(UnusedCatalogEntryFinding::with_actions)
869 .collect();
870 }
871 if need_empty_catalog_groups {
872 results.empty_catalog_groups = find_empty_catalog_groups(&state)
873 .into_iter()
874 .map(EmptyCatalogGroupFinding::with_actions)
875 .collect();
876 }
877 if need_unresolved_refs {
878 results.unresolved_catalog_references = find_unresolved_catalog_references(
879 &state,
880 &config.compiled_ignore_catalog_references,
881 &config.root,
882 )
883 .into_iter()
884 .map(UnresolvedCatalogReferenceFinding::with_actions)
885 .collect();
886 }
887 }
888
889 let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
890 let need_misconfigured_overrides =
891 config.rules.misconfigured_dependency_overrides != Severity::Off;
892 if (need_unused_overrides || need_misconfigured_overrides)
893 && let Some(state) = gather_pnpm_override_state(config, workspaces)
894 {
895 if need_unused_overrides {
896 results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
897 .into_iter()
898 .map(UnusedDependencyOverrideFinding::with_actions)
899 .collect();
900 }
901 if need_misconfigured_overrides {
902 results.misconfigured_dependency_overrides =
903 find_misconfigured_dependency_overrides(&state, config)
904 .into_iter()
905 .map(MisconfiguredDependencyOverrideFinding::with_actions)
906 .collect();
907 }
908 }
909
910 results.sort();
911
912 results
913}
914
915#[cfg(test)]
916#[expect(
917 deprecated,
918 reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
919)]
920mod tests {
921 use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
922
923 fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
924 let offsets = compute_line_offsets(source);
925 byte_offset_to_line_col(&offsets, byte_offset)
926 }
927
928 #[test]
929 fn compute_offsets_empty() {
930 assert_eq!(compute_line_offsets(""), vec![0]);
931 }
932
933 #[test]
934 fn compute_offsets_single_line() {
935 assert_eq!(compute_line_offsets("hello"), vec![0]);
936 }
937
938 #[test]
939 fn compute_offsets_multiline() {
940 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
941 }
942
943 #[test]
944 fn compute_offsets_trailing_newline() {
945 assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
946 }
947
948 #[test]
949 fn compute_offsets_crlf() {
950 assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
951 }
952
953 #[test]
954 fn compute_offsets_consecutive_newlines() {
955 assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
956 }
957
958 #[test]
959 fn byte_offset_empty_source() {
960 assert_eq!(line_col("", 0), (1, 0));
961 }
962
963 #[test]
964 fn byte_offset_single_line_start() {
965 assert_eq!(line_col("hello", 0), (1, 0));
966 }
967
968 #[test]
969 fn byte_offset_single_line_middle() {
970 assert_eq!(line_col("hello", 4), (1, 4));
971 }
972
973 #[test]
974 fn byte_offset_multiline_start_of_line2() {
975 assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
976 }
977
978 #[test]
979 fn byte_offset_multiline_middle_of_line3() {
980 assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
981 }
982
983 #[test]
984 fn byte_offset_at_newline_boundary() {
985 assert_eq!(line_col("line1\nline2", 5), (1, 5));
986 }
987
988 #[test]
989 fn byte_offset_multibyte_utf8() {
990 let source = "hi\n\u{1F600}x";
991 assert_eq!(line_col(source, 3), (2, 0));
992 assert_eq!(line_col(source, 7), (2, 4));
993 }
994
995 #[test]
996 fn byte_offset_multibyte_accented_chars() {
997 let source = "caf\u{00E9}\nbar";
998 assert_eq!(line_col(source, 6), (2, 0));
999 assert_eq!(line_col(source, 3), (1, 3));
1000 }
1001
1002 #[test]
1003 fn byte_offset_via_map_fallback() {
1004 use super::*;
1005 let map: LineOffsetsMap<'_> = FxHashMap::default();
1006 assert_eq!(
1007 super::byte_offset_to_line_col(&map, FileId(99), 42),
1008 (1, 42)
1009 );
1010 }
1011
1012 #[test]
1013 fn byte_offset_via_map_lookup() {
1014 use super::*;
1015 let offsets = compute_line_offsets("abc\ndef\nghi");
1016 let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1017 map.insert(FileId(0), &offsets);
1018 assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1019 }
1020
1021 mod orchestration {
1022 use super::super::*;
1023 use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1024 use std::path::PathBuf;
1025
1026 fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1027 find_dead_code_full(graph, config, &[], None, &[], &[], false)
1028 }
1029
1030 fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1031 FallowConfig {
1032 rules,
1033 ..Default::default()
1034 }
1035 .resolve(
1036 PathBuf::from("/tmp/orchestration-test"),
1037 OutputFormat::Human,
1038 1,
1039 true,
1040 true,
1041 None,
1042 )
1043 }
1044
1045 #[test]
1046 fn find_dead_code_all_rules_off_returns_empty() {
1047 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1048 use crate::graph::ModuleGraph;
1049 use crate::resolve::ResolvedModule;
1050 use rustc_hash::FxHashSet;
1051
1052 let files = vec![DiscoveredFile {
1053 id: FileId(0),
1054 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1055 size_bytes: 100,
1056 }];
1057 let entry_points = vec![EntryPoint {
1058 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1059 source: EntryPointSource::ManualEntry,
1060 }];
1061 let resolved = vec![ResolvedModule {
1062 file_id: FileId(0),
1063 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1064 exports: vec![],
1065 re_exports: vec![],
1066 resolved_imports: vec![],
1067 resolved_dynamic_imports: vec![],
1068 resolved_dynamic_patterns: vec![],
1069 member_accesses: vec![],
1070 whole_object_uses: vec![],
1071 has_cjs_exports: false,
1072 has_angular_component_template_url: false,
1073 unused_import_bindings: FxHashSet::default(),
1074 type_referenced_import_bindings: vec![],
1075 value_referenced_import_bindings: vec![],
1076 namespace_object_aliases: vec![],
1077 }];
1078 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1079
1080 let rules = RulesConfig {
1081 unused_files: Severity::Off,
1082 unused_exports: Severity::Off,
1083 unused_types: Severity::Off,
1084 private_type_leaks: Severity::Off,
1085 unused_dependencies: Severity::Off,
1086 unused_dev_dependencies: Severity::Off,
1087 unused_optional_dependencies: Severity::Off,
1088 unused_enum_members: Severity::Off,
1089 unused_class_members: Severity::Off,
1090 unresolved_imports: Severity::Off,
1091 unlisted_dependencies: Severity::Off,
1092 duplicate_exports: Severity::Off,
1093 type_only_dependencies: Severity::Off,
1094 circular_dependencies: Severity::Off,
1095 re_export_cycle: Severity::Off,
1096 test_only_dependencies: Severity::Off,
1097 boundary_violation: Severity::Off,
1098 coverage_gaps: Severity::Off,
1099 feature_flags: Severity::Off,
1100 stale_suppressions: Severity::Off,
1101 unused_catalog_entries: Severity::Off,
1102 empty_catalog_groups: Severity::Off,
1103 unresolved_catalog_references: Severity::Off,
1104 unused_dependency_overrides: Severity::Off,
1105 misconfigured_dependency_overrides: Severity::Off,
1106 security_client_server_leak: Severity::Off,
1107 security_sink: Severity::Off,
1108 };
1109 let config = make_config_with_rules(rules);
1110 let results = find_dead_code(&graph, &config);
1111
1112 assert!(results.unused_files.is_empty());
1113 assert!(results.unused_exports.is_empty());
1114 assert!(results.unused_types.is_empty());
1115 assert!(results.unused_dependencies.is_empty());
1116 assert!(results.unused_dev_dependencies.is_empty());
1117 assert!(results.unused_optional_dependencies.is_empty());
1118 assert!(results.unused_enum_members.is_empty());
1119 assert!(results.unused_class_members.is_empty());
1120 assert!(results.unresolved_imports.is_empty());
1121 assert!(results.unlisted_dependencies.is_empty());
1122 assert!(results.duplicate_exports.is_empty());
1123 assert!(results.circular_dependencies.is_empty());
1124 assert!(results.export_usages.is_empty());
1125 }
1126
1127 #[test]
1128 fn find_dead_code_full_collect_usages_flag() {
1129 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1130 use crate::extract::{ExportName, VisibilityTag};
1131 use crate::graph::{ExportSymbol, ModuleGraph};
1132 use crate::resolve::ResolvedModule;
1133 use oxc_span::Span;
1134 use rustc_hash::FxHashSet;
1135
1136 let files = vec![DiscoveredFile {
1137 id: FileId(0),
1138 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1139 size_bytes: 100,
1140 }];
1141 let entry_points = vec![EntryPoint {
1142 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1143 source: EntryPointSource::ManualEntry,
1144 }];
1145 let resolved = vec![ResolvedModule {
1146 file_id: FileId(0),
1147 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1148 exports: vec![],
1149 re_exports: vec![],
1150 resolved_imports: vec![],
1151 resolved_dynamic_imports: vec![],
1152 resolved_dynamic_patterns: vec![],
1153 member_accesses: vec![],
1154 whole_object_uses: vec![],
1155 has_cjs_exports: false,
1156 has_angular_component_template_url: false,
1157 unused_import_bindings: FxHashSet::default(),
1158 type_referenced_import_bindings: vec![],
1159 value_referenced_import_bindings: vec![],
1160 namespace_object_aliases: vec![],
1161 }];
1162 let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1163 graph.modules[0].exports = vec![ExportSymbol {
1164 name: ExportName::Named("myExport".to_string()),
1165 is_type_only: false,
1166 is_side_effect_used: false,
1167 visibility: VisibilityTag::None,
1168 span: Span::new(10, 30),
1169 references: vec![],
1170 members: vec![],
1171 }];
1172
1173 let rules = RulesConfig::default();
1174 let config = make_config_with_rules(rules);
1175
1176 let results_no_collect = find_dead_code_full(
1177 &graph,
1178 &config,
1179 &[],
1180 None,
1181 &[],
1182 &[],
1183 false, );
1185 assert!(
1186 results_no_collect.export_usages.is_empty(),
1187 "export_usages should be empty when collect_usages is false"
1188 );
1189
1190 let results_with_collect = find_dead_code_full(
1191 &graph,
1192 &config,
1193 &[],
1194 None,
1195 &[],
1196 &[],
1197 true, );
1199 assert!(
1200 !results_with_collect.export_usages.is_empty(),
1201 "export_usages should be populated when collect_usages is true"
1202 );
1203 assert_eq!(
1204 results_with_collect.export_usages[0].export_name,
1205 "myExport"
1206 );
1207 }
1208
1209 #[test]
1210 fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1211 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1212 use crate::graph::ModuleGraph;
1213 use crate::resolve::ResolvedModule;
1214 use rustc_hash::FxHashSet;
1215
1216 let files = vec![DiscoveredFile {
1217 id: FileId(0),
1218 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1219 size_bytes: 100,
1220 }];
1221 let entry_points = vec![EntryPoint {
1222 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1223 source: EntryPointSource::ManualEntry,
1224 }];
1225 let resolved = vec![ResolvedModule {
1226 file_id: FileId(0),
1227 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1228 exports: vec![],
1229 re_exports: vec![],
1230 resolved_imports: vec![],
1231 resolved_dynamic_imports: vec![],
1232 resolved_dynamic_patterns: vec![],
1233 member_accesses: vec![],
1234 whole_object_uses: vec![],
1235 has_cjs_exports: false,
1236 has_angular_component_template_url: false,
1237 unused_import_bindings: FxHashSet::default(),
1238 type_referenced_import_bindings: vec![],
1239 value_referenced_import_bindings: vec![],
1240 namespace_object_aliases: vec![],
1241 }];
1242 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1243 let config = make_config_with_rules(RulesConfig::default());
1244
1245 let results = find_dead_code(&graph, &config);
1246 assert!(results.unused_exports.is_empty());
1247 }
1248
1249 #[test]
1250 fn suppressions_built_from_modules() {
1251 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1252 use crate::extract::ModuleInfo;
1253 use crate::graph::ModuleGraph;
1254 use crate::resolve::ResolvedModule;
1255 use crate::suppress::{IssueKind, Suppression};
1256 use rustc_hash::FxHashSet;
1257
1258 let files = vec![
1259 DiscoveredFile {
1260 id: FileId(0),
1261 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1262 size_bytes: 100,
1263 },
1264 DiscoveredFile {
1265 id: FileId(1),
1266 path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1267 size_bytes: 100,
1268 },
1269 ];
1270 let entry_points = vec![EntryPoint {
1271 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1272 source: EntryPointSource::ManualEntry,
1273 }];
1274 let resolved = files
1275 .iter()
1276 .map(|f| ResolvedModule {
1277 file_id: f.id,
1278 path: f.path.clone(),
1279 exports: vec![],
1280 re_exports: vec![],
1281 resolved_imports: vec![],
1282 resolved_dynamic_imports: vec![],
1283 resolved_dynamic_patterns: vec![],
1284 member_accesses: vec![],
1285 whole_object_uses: vec![],
1286 has_cjs_exports: false,
1287 has_angular_component_template_url: false,
1288 unused_import_bindings: FxHashSet::default(),
1289 type_referenced_import_bindings: vec![],
1290 value_referenced_import_bindings: vec![],
1291 namespace_object_aliases: vec![],
1292 })
1293 .collect::<Vec<_>>();
1294 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1295
1296 let modules = vec![ModuleInfo {
1297 file_id: FileId(1),
1298 exports: vec![],
1299 imports: vec![],
1300 re_exports: vec![],
1301 dynamic_imports: vec![],
1302 dynamic_import_patterns: vec![],
1303 require_calls: vec![],
1304 member_accesses: vec![],
1305 whole_object_uses: vec![],
1306 has_cjs_exports: false,
1307 has_angular_component_template_url: false,
1308 content_hash: 0,
1309 suppressions: vec![Suppression {
1310 line: 0,
1311 comment_line: 1,
1312 kind: Some(IssueKind::UnusedFile),
1313 }],
1314 unknown_suppression_kinds: vec![],
1315 unused_import_bindings: vec![],
1316 type_referenced_import_bindings: vec![],
1317 value_referenced_import_bindings: vec![],
1318 line_offsets: vec![],
1319 complexity: vec![],
1320 flag_uses: vec![],
1321 class_heritage: vec![],
1322 local_type_declarations: Vec::new(),
1323 public_signature_type_references: Vec::new(),
1324 namespace_object_aliases: Vec::new(),
1325 iconify_prefixes: Vec::new(),
1326 auto_import_candidates: Vec::new(),
1327 directives: Vec::new(),
1328 security_sinks: Vec::new(),
1329 security_sinks_skipped: 0,
1330 }];
1331
1332 let rules = RulesConfig {
1333 unused_files: Severity::Error,
1334 ..RulesConfig::default()
1335 };
1336 let config = make_config_with_rules(rules);
1337
1338 let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1339
1340 assert!(
1341 !results.unused_files.iter().any(|f| f
1342 .file
1343 .path
1344 .to_string_lossy()
1345 .contains("utils.ts")),
1346 "suppressed file should not appear in unused_files"
1347 );
1348 }
1349 }
1350}