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