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