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