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