1mod boundary;
2mod boundary_calls;
3mod boundary_coverage;
4pub mod feature_flags;
5mod iconify;
6mod package_json_utils;
7mod policy;
8mod predicates;
9mod re_export_cycles;
10mod security;
11mod unused_catalog;
12mod unused_deps;
13mod unused_exports;
14mod unused_files;
15mod unused_members;
16mod unused_overrides;
17
18#[cfg(test)]
19pub(crate) use unused_deps::matches_virtual_prefix;
20
21pub use security::catalogue_title as security_catalogue_title;
25pub use security::derive_security_severity;
26
27use rustc_hash::{FxHashMap, FxHashSet};
28
29use fallow_config::{PackageJson, ResolvedConfig, Severity};
30
31use crate::discover::FileId;
32use crate::extract::ModuleInfo;
33use crate::graph::ModuleGraph;
34use crate::resolve::ResolvedModule;
35use fallow_types::output_dead_code::{
36 BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
37 CircularDependencyFinding, DuplicateExportFinding, EmptyCatalogGroupFinding,
38 MisconfiguredDependencyOverrideFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
39 ReExportCycleFinding, TestOnlyDependencyFinding, TypeOnlyDependencyFinding,
40 UnlistedDependencyFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
41 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
42 UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
43 UnusedExportFinding, UnusedFileFinding, UnusedOptionalDependencyFinding, UnusedTypeFinding,
44};
45
46use crate::results::{AnalysisResults, CircularDependency, CircularDependencyEdge};
47use crate::suppress::{IssueKind, SuppressionContext};
48
49use re_export_cycles::find_re_export_cycles;
50#[expect(
51 deprecated,
52 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
53)]
54use unused_catalog::{
55 find_empty_catalog_groups, find_unresolved_catalog_references, find_unused_catalog_entries,
56 gather_pnpm_catalog_state,
57};
58#[expect(
59 deprecated,
60 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
61)]
62use unused_deps::{
63 find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
64 find_unresolved_imports, find_unused_dependencies,
65};
66#[expect(
67 deprecated,
68 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
69)]
70use unused_exports::{
71 collect_export_usages, find_private_type_leaks, find_unused_exports,
72 suppress_signature_backing_types,
73};
74#[expect(
75 deprecated,
76 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
77)]
78use unused_files::find_unused_files;
79use unused_members::find_unused_members_with_public_api_entry_points;
80#[expect(
81 deprecated,
82 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
83)]
84use unused_overrides::{
85 find_misconfigured_dependency_overrides, find_unused_dependency_overrides,
86 gather_pnpm_override_state,
87};
88
89#[doc(hidden)]
92pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
93
94struct SecurityDetectionContext<'a, 'm> {
95 graph: &'a ModuleGraph,
96 modules: &'a [ModuleInfo],
97 config: &'a ResolvedConfig,
98 suppressions: &'a crate::suppress::SuppressionContext<'m>,
99 line_offsets_by_file: &'a LineOffsetsMap<'m>,
100 declared_deps: &'a FxHashSet<String>,
101 request_receivers: &'a FxHashSet<String>,
102}
103
104#[doc(hidden)]
107pub fn byte_offset_to_line_col(
108 line_offsets_map: &LineOffsetsMap<'_>,
109 file_id: FileId,
110 byte_offset: u32,
111) -> (u32, u32) {
112 line_offsets_map
113 .get(&file_id)
114 .map_or((1, byte_offset), |offsets| {
115 fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
116 })
117}
118
119fn cycle_edge_line_col(
120 graph: &ModuleGraph,
121 line_offsets_map: &LineOffsetsMap<'_>,
122 cycle: &[FileId],
123 edge_index: usize,
124) -> Option<(u32, u32)> {
125 if cycle.is_empty() {
126 return None;
127 }
128
129 let from = cycle[edge_index];
130 let to = cycle[(edge_index + 1) % cycle.len()];
131 graph
132 .find_import_span_start(from, to)
133 .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
134}
135
136fn is_circular_dependency_suppressed(
137 graph: &ModuleGraph,
138 line_offsets_map: &LineOffsetsMap<'_>,
139 suppressions: &crate::suppress::SuppressionContext<'_>,
140 cycle: &[FileId],
141) -> bool {
142 if cycle
143 .iter()
144 .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
145 {
146 return true;
147 }
148
149 let mut line_suppressed = false;
150 for edge_index in 0..cycle.len() {
151 let from = cycle[edge_index];
152 if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
153 && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
154 {
155 line_suppressed = true;
156 }
157 }
158 line_suppressed
159}
160
161fn read_source(path: &std::path::Path) -> String {
165 std::fs::read_to_string(path).unwrap_or_default()
166}
167
168fn is_cross_package_cycle(
173 files: &[std::path::PathBuf],
174 workspaces: &[fallow_config::WorkspaceInfo],
175) -> bool {
176 let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
177 workspaces
178 .iter()
179 .map(|w| w.root.as_path())
180 .filter(|root| path.starts_with(root))
181 .max_by_key(|root| root.components().count())
182 };
183
184 let mut seen_workspace: Option<&std::path::Path> = None;
185 for file in files {
186 if let Some(ws) = find_workspace(file) {
187 match &seen_workspace {
188 None => seen_workspace = Some(ws),
189 Some(prev) if *prev != ws => return true,
190 _ => {}
191 }
192 }
193 }
194 false
195}
196
197fn public_workspace_roots<'a>(
198 public_packages: &[String],
199 workspaces: &'a [fallow_config::WorkspaceInfo],
200) -> Vec<&'a std::path::Path> {
201 if public_packages.is_empty() || workspaces.is_empty() {
202 return Vec::new();
203 }
204
205 workspaces
206 .iter()
207 .filter(|ws| {
208 public_packages.iter().any(|pattern| {
209 ws.name == *pattern
210 || globset::Glob::new(pattern)
211 .ok()
212 .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
213 })
214 })
215 .map(|ws| ws.root.as_path())
216 .collect()
217}
218
219fn graph_path_to_file_id(graph: &ModuleGraph) -> FxHashMap<std::path::PathBuf, FileId> {
220 let mut path_to_file_id = FxHashMap::default();
221 for module in &graph.modules {
222 path_to_file_id.insert(module.path.clone(), module.file_id);
223 if let Ok(canonical) = dunce::canonicalize(&module.path) {
224 path_to_file_id.insert(canonical, module.file_id);
225 }
226 }
227 path_to_file_id
228}
229
230fn add_package_public_api_entry_points(
231 public_api_entry_points: &mut FxHashSet<FileId>,
232 path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
233 package_root: &std::path::Path,
234 package_json: &PackageJson,
235 canonical_project_root: &std::path::Path,
236) {
237 if package_json.private.unwrap_or(false) {
238 return;
239 }
240
241 for entry in package_json.entry_points() {
242 let Some(entry_point) = crate::discover::resolve_entry_path(
243 package_root,
244 &entry,
245 canonical_project_root,
246 crate::discover::EntryPointSource::PackageJsonExports,
247 ) else {
248 continue;
249 };
250
251 if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
252 dunce::canonicalize(&entry_point.path)
253 .ok()
254 .and_then(|canonical| path_to_file_id.get(&canonical).copied())
255 }) {
256 public_api_entry_points.insert(file_id);
257 }
258 }
259}
260
261fn is_source_index_under_package(path: &std::path::Path, package_root: &std::path::Path) -> bool {
262 let Ok(relative) = path.strip_prefix(package_root) else {
263 return false;
264 };
265
266 if !matches!(
267 relative.components().next(),
268 Some(std::path::Component::Normal(segment)) if segment == "src"
269 ) {
270 return false;
271 }
272
273 path.file_stem()
274 .and_then(|stem| stem.to_str())
275 .is_some_and(|stem| stem == "index")
276}
277
278fn add_exportless_package_source_indexes(
279 public_api_entry_points: &mut FxHashSet<FileId>,
280 graph: &ModuleGraph,
281 package_root: &std::path::Path,
282 package_json: &PackageJson,
283) {
284 if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
285 return;
286 }
287
288 let mut roots = vec![package_root.to_path_buf()];
289 if let Ok(canonical) = dunce::canonicalize(package_root) {
290 roots.push(canonical);
291 }
292
293 for module in &graph.modules {
294 if roots
295 .iter()
296 .any(|root| is_source_index_under_package(&module.path, root))
297 {
298 public_api_entry_points.insert(module.file_id);
299 }
300 }
301}
302
303fn public_api_package_entry_points(
304 graph: &ModuleGraph,
305 config: &ResolvedConfig,
306 root_pkg: Option<&PackageJson>,
307 workspaces: &[fallow_config::WorkspaceInfo],
308) -> FxHashSet<FileId> {
309 let mut public_api_entry_points = FxHashSet::default();
310 let path_to_file_id = graph_path_to_file_id(graph);
311 let canonical_project_root =
312 dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
313
314 if let Some(pkg) = root_pkg {
315 add_package_public_api_entry_points(
316 &mut public_api_entry_points,
317 &path_to_file_id,
318 &config.root,
319 pkg,
320 &canonical_project_root,
321 );
322 add_exportless_package_source_indexes(
323 &mut public_api_entry_points,
324 graph,
325 &config.root,
326 pkg,
327 );
328 }
329
330 for workspace in workspaces {
331 let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) else {
332 continue;
333 };
334 add_package_public_api_entry_points(
335 &mut public_api_entry_points,
336 &path_to_file_id,
337 &workspace.root,
338 &pkg,
339 &canonical_project_root,
340 );
341 add_exportless_package_source_indexes(
342 &mut public_api_entry_points,
343 graph,
344 &workspace.root,
345 &pkg,
346 );
347 }
348
349 public_api_entry_points
350}
351
352fn find_circular_dependencies(
353 graph: &ModuleGraph,
354 line_offsets_map: &LineOffsetsMap<'_>,
355 suppressions: &crate::suppress::SuppressionContext<'_>,
356 workspaces: &[fallow_config::WorkspaceInfo],
357) -> Vec<CircularDependency> {
358 let cycles = graph.find_cycles();
359 let mut dependencies: Vec<CircularDependency> = cycles
360 .into_iter()
361 .filter_map(|cycle| {
362 if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
363 return None;
364 }
365
366 let edges: Vec<CircularDependencyEdge> = (0..cycle.len())
372 .map(|edge_index| {
373 let from = cycle[edge_index];
374 let (line, col) =
375 cycle_edge_line_col(graph, line_offsets_map, &cycle, edge_index)
376 .unwrap_or((1, 0));
377 CircularDependencyEdge {
378 path: graph.modules[from.0 as usize].path.clone(),
379 line,
380 col,
381 }
382 })
383 .collect();
384
385 let files: Vec<std::path::PathBuf> =
386 edges.iter().map(|edge| edge.path.clone()).collect();
387 let length = files.len();
388 let (line, col) = edges.first().map_or((1, 0), |edge| (edge.line, edge.col));
391 Some(CircularDependency {
392 files,
393 length,
394 line,
395 col,
396 edges,
397 is_cross_package: false,
398 })
399 })
400 .collect();
401
402 if !workspaces.is_empty() {
403 for dep in &mut dependencies {
404 dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
405 }
406 }
407
408 dependencies
409}
410
411fn run_circular_dep_detector(
416 graph: &ModuleGraph,
417 config: &ResolvedConfig,
418 line_offsets_by_file: &LineOffsetsMap<'_>,
419 suppressions: &crate::suppress::SuppressionContext<'_>,
420 workspaces: &[fallow_config::WorkspaceInfo],
421) -> Vec<CircularDependencyFinding> {
422 if config.rules.circular_dependencies == Severity::Off {
423 return Vec::new();
424 }
425 find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
426 .into_iter()
427 .map(CircularDependencyFinding::with_actions)
428 .collect()
429}
430
431fn run_boundary_coverage_detector(
436 graph: &ModuleGraph,
437 config: &ResolvedConfig,
438 suppressions: &crate::suppress::SuppressionContext<'_>,
439) -> Vec<BoundaryCoverageViolationFinding> {
440 if config.rules.boundary_violation == Severity::Off {
441 return Vec::new();
442 }
443 boundary_coverage::find_boundary_coverage_violations(graph, config, suppressions)
444 .into_iter()
445 .map(BoundaryCoverageViolationFinding::with_actions)
446 .collect()
447}
448
449fn run_boundary_call_detector(
453 graph: &ModuleGraph,
454 modules: &[ModuleInfo],
455 config: &ResolvedConfig,
456 suppressions: &crate::suppress::SuppressionContext<'_>,
457 line_offsets_by_file: &LineOffsetsMap<'_>,
458) -> Vec<BoundaryCallViolationFinding> {
459 if config.rules.boundary_violation == Severity::Off {
460 return Vec::new();
461 }
462 boundary_calls::find_boundary_call_violations(
463 graph,
464 modules,
465 config,
466 suppressions,
467 line_offsets_by_file,
468 )
469 .into_iter()
470 .map(BoundaryCallViolationFinding::with_actions)
471 .collect()
472}
473
474fn run_policy_detector(
479 graph: &ModuleGraph,
480 modules: &[ModuleInfo],
481 config: &ResolvedConfig,
482 suppressions: &crate::suppress::SuppressionContext<'_>,
483 line_offsets_by_file: &LineOffsetsMap<'_>,
484) -> Vec<PolicyViolationFinding> {
485 if config.rules.policy_violation == Severity::Off || config.rule_packs.is_empty() {
486 return Vec::new();
487 }
488 policy::find_policy_violations(graph, modules, config, suppressions, line_offsets_by_file)
489 .into_iter()
490 .map(PolicyViolationFinding::with_actions)
491 .collect()
492}
493
494fn run_boundary_aux_detectors(
498 graph: &ModuleGraph,
499 modules: &[ModuleInfo],
500 config: &ResolvedConfig,
501 suppressions: &crate::suppress::SuppressionContext<'_>,
502 line_offsets_by_file: &LineOffsetsMap<'_>,
503) -> (
504 Vec<BoundaryCoverageViolationFinding>,
505 (
506 Vec<BoundaryCallViolationFinding>,
507 Vec<PolicyViolationFinding>,
508 ),
509) {
510 rayon::join(
511 || run_boundary_coverage_detector(graph, config, suppressions),
512 || {
513 rayon::join(
514 || {
515 run_boundary_call_detector(
516 graph,
517 modules,
518 config,
519 suppressions,
520 line_offsets_by_file,
521 )
522 },
523 || run_policy_detector(graph, modules, config, suppressions, line_offsets_by_file),
524 )
525 },
526 )
527}
528
529fn run_re_export_cycle_detector(
532 graph: &ModuleGraph,
533 config: &ResolvedConfig,
534 suppressions: &crate::suppress::SuppressionContext<'_>,
535) -> Vec<ReExportCycleFinding> {
536 if config.rules.re_export_cycle == Severity::Off {
537 return Vec::new();
538 }
539 find_re_export_cycles(graph, suppressions)
540}
541
542fn run_export_usages_collector(
545 graph: &ModuleGraph,
546 line_offsets_by_file: &LineOffsetsMap<'_>,
547 collect_usages: bool,
548) -> Vec<crate::results::ExportUsage> {
549 if collect_usages {
550 collect_export_usages(graph, line_offsets_by_file)
551 } else {
552 Vec::new()
553 }
554}
555
556fn collect_declared_dependency_names(
563 config: &ResolvedConfig,
564 root_pkg: Option<&PackageJson>,
565 workspaces: &[fallow_config::WorkspaceInfo],
566) -> FxHashSet<String> {
567 let mut deps: FxHashSet<String> = FxHashSet::default();
568 if let Some(pkg) = root_pkg {
569 deps.extend(pkg.all_dependency_names());
570 }
571 for ws in workspaces {
572 if ws.root == config.root {
573 continue; }
575 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
576 deps.extend(pkg.all_dependency_names());
577 }
578 }
579 deps
580}
581
582#[deprecated(
584 since = "2.76.0",
585 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."
586)]
587pub fn find_dead_code_full(
588 graph: &ModuleGraph,
589 config: &ResolvedConfig,
590 resolved_modules: &[ResolvedModule],
591 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
592 workspaces: &[fallow_config::WorkspaceInfo],
593 modules: &[ModuleInfo],
594 collect_usages: bool,
595) -> AnalysisResults {
596 let _span = tracing::info_span!("find_dead_code").entered();
597
598 let suppressions = crate::suppress::SuppressionContext::new(modules);
599
600 let line_offsets_by_file: LineOffsetsMap<'_> = modules
601 .iter()
602 .filter(|m| !m.line_offsets.is_empty())
603 .map(|m| (m.file_id, m.line_offsets.as_slice()))
604 .collect();
605
606 let pkg_path = config.root.join("package.json");
607 let pkg = PackageJson::load(&pkg_path).ok();
608 let public_api_entry_points =
609 public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
610
611 let iconify_referenced =
612 iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
613 let augmented_plugin_result;
614 let plugin_result = if iconify_referenced.is_empty() {
615 plugin_result
616 } else {
617 let mut owned = plugin_result.cloned().unwrap_or_default();
618 owned.referenced_dependencies.extend(iconify_referenced);
619 augmented_plugin_result = owned;
620 Some(&augmented_plugin_result)
621 };
622
623 let mut user_class_members = config.used_class_members.clone();
624 if let Some(plugin_result) = plugin_result {
625 user_class_members.extend(plugin_result.used_class_members.iter().cloned());
626 }
627
628 let virtual_prefixes: Vec<&str> = plugin_result
629 .map(|pr| {
630 pr.virtual_module_prefixes
631 .iter()
632 .map(String::as_str)
633 .collect()
634 })
635 .unwrap_or_default();
636 let generated_patterns: Vec<&str> = plugin_result
637 .map(|pr| {
638 pr.generated_import_patterns
639 .iter()
640 .map(String::as_str)
641 .collect()
642 })
643 .unwrap_or_default();
644 let generated_type_prefixes: Vec<&str> = plugin_result
645 .map(|pr| {
646 pr.generated_type_import_prefixes
647 .iter()
648 .map(String::as_str)
649 .collect()
650 })
651 .unwrap_or_default();
652
653 let mut results = run_parallel_dead_code_detectors(DeadCodeDetectorInput {
654 graph,
655 config,
656 resolved_modules,
657 workspaces,
658 modules,
659 suppressions: &suppressions,
660 line_offsets_by_file: &line_offsets_by_file,
661 plugin_result,
662 pkg: pkg.as_ref(),
663 user_class_members: &user_class_members,
664 public_api_entry_points: &public_api_entry_points,
665 virtual_prefixes: &virtual_prefixes,
666 generated_patterns: &generated_patterns,
667 generated_type_prefixes: &generated_type_prefixes,
668 collect_usages,
669 });
670
671 filter_public_workspace_results(config, workspaces, &mut results);
672
673 let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
674 let request_receivers = config
675 .security
676 .request_receivers
677 .iter()
678 .cloned()
679 .collect::<FxHashSet<_>>();
680
681 populate_security_findings(
682 &SecurityDetectionContext {
683 graph,
684 modules,
685 config,
686 suppressions: &suppressions,
687 line_offsets_by_file: &line_offsets_by_file,
688 declared_deps: &declared_deps,
689 request_receivers: &request_receivers,
690 },
691 &mut results,
692 );
693
694 if config.rules.stale_suppressions != Severity::Off {
695 results
696 .stale_suppressions
697 .extend(suppressions.find_stale(graph, config));
698 }
699 results.suppression_count = suppressions.used_count();
700 results.active_suppressions = suppressions.all_suppressions(graph);
701
702 populate_pnpm_catalog_findings(config, workspaces, &mut results);
703 populate_pnpm_override_findings(config, workspaces, &mut results);
704
705 results.sort();
706
707 results
708}
709
710#[derive(Clone, Copy)]
711struct DeadCodeDetectorInput<'a> {
712 graph: &'a ModuleGraph,
713 config: &'a ResolvedConfig,
714 resolved_modules: &'a [ResolvedModule],
715 workspaces: &'a [fallow_config::WorkspaceInfo],
716 modules: &'a [ModuleInfo],
717 suppressions: &'a SuppressionContext<'a>,
718 line_offsets_by_file: &'a LineOffsetsMap<'a>,
719 plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
720 pkg: Option<&'a PackageJson>,
721 user_class_members: &'a [fallow_config::UsedClassMemberRule],
722 public_api_entry_points: &'a FxHashSet<FileId>,
723 virtual_prefixes: &'a [&'a str],
724 generated_patterns: &'a [&'a str],
725 generated_type_prefixes: &'a [&'a str],
726 collect_usages: bool,
727}
728
729fn run_parallel_dead_code_detectors(input: DeadCodeDetectorInput<'_>) -> AnalysisResults {
730 let (
731 (unused_files, export_results),
732 (
733 (member_results, dependency_results),
734 (
735 (unresolved_imports, duplicate_exports),
736 (
737 (
738 boundary_violations,
739 (
740 boundary_coverage_violations,
741 (boundary_call_violations, policy_violations),
742 ),
743 ),
744 (circular_dependencies, (re_export_cycles, export_usages)),
745 ),
746 ),
747 ),
748 ) = rayon::join(
749 || run_file_and_export_detectors(input),
750 || {
751 rayon::join(
752 || run_member_and_dependency_detectors(input),
753 || {
754 rayon::join(
755 || run_import_and_duplicate_detectors(input),
756 || run_boundary_cycle_and_usage_detectors(input),
757 )
758 },
759 )
760 },
761 );
762
763 AnalysisResults {
764 unused_files,
765 unused_exports: export_results.unused_exports,
766 unused_types: export_results.unused_types,
767 private_type_leaks: export_results.private_type_leaks,
768 stale_suppressions: export_results.stale_suppressions,
769 unused_enum_members: member_results.unused_enum_members,
770 unused_class_members: member_results.unused_class_members,
771 unused_dependencies: dependency_results.unused_dependencies,
772 unused_dev_dependencies: dependency_results.unused_dev_dependencies,
773 unused_optional_dependencies: dependency_results.unused_optional_dependencies,
774 unlisted_dependencies: dependency_results.unlisted_dependencies,
775 type_only_dependencies: dependency_results.type_only_dependencies,
776 test_only_dependencies: dependency_results.test_only_dependencies,
777 unresolved_imports,
778 duplicate_exports,
779 boundary_violations,
780 boundary_coverage_violations,
781 boundary_call_violations,
782 policy_violations,
783 circular_dependencies,
784 re_export_cycles,
785 export_usages,
786 ..AnalysisResults::default()
787 }
788}
789
790fn run_file_and_export_detectors(
791 input: DeadCodeDetectorInput<'_>,
792) -> (Vec<UnusedFileFinding>, AnalysisResults) {
793 rayon::join(
794 || run_unused_file_detector(input.graph, input.config, input.suppressions),
795 || {
796 run_export_detectors(
797 input.graph,
798 input.modules,
799 input.config,
800 input.plugin_result,
801 input.suppressions,
802 input.line_offsets_by_file,
803 )
804 },
805 )
806}
807
808fn run_member_and_dependency_detectors(
809 input: DeadCodeDetectorInput<'_>,
810) -> (AnalysisResults, AnalysisResults) {
811 rayon::join(
812 || {
813 run_member_detectors(
814 input.graph,
815 input.resolved_modules,
816 input.modules,
817 input.config,
818 input.suppressions,
819 input.line_offsets_by_file,
820 input.user_class_members,
821 input.public_api_entry_points,
822 )
823 },
824 || {
825 run_dependency_detectors(
826 input.graph,
827 input.pkg,
828 input.config,
829 input.plugin_result,
830 input.workspaces,
831 input.resolved_modules,
832 input.line_offsets_by_file,
833 )
834 },
835 )
836}
837
838fn run_import_and_duplicate_detectors(
839 input: DeadCodeDetectorInput<'_>,
840) -> (Vec<UnresolvedImportFinding>, Vec<DuplicateExportFinding>) {
841 rayon::join(
842 || {
843 run_unresolved_import_detector(
844 input.resolved_modules,
845 input.config,
846 input.suppressions,
847 input.virtual_prefixes,
848 input.generated_patterns,
849 input.generated_type_prefixes,
850 input.line_offsets_by_file,
851 )
852 },
853 || {
854 run_duplicate_export_detector(
855 input.graph,
856 input.config,
857 input.suppressions,
858 input.line_offsets_by_file,
859 input.plugin_result,
860 input.resolved_modules,
861 )
862 },
863 )
864}
865
866type BoundaryAuxResults = (
867 Vec<BoundaryCoverageViolationFinding>,
868 (
869 Vec<BoundaryCallViolationFinding>,
870 Vec<PolicyViolationFinding>,
871 ),
872);
873
874type BoundaryCycleUsageResults = (
875 (Vec<BoundaryViolationFinding>, BoundaryAuxResults),
876 (
877 Vec<CircularDependencyFinding>,
878 (Vec<ReExportCycleFinding>, Vec<crate::results::ExportUsage>),
879 ),
880);
881
882fn run_boundary_cycle_and_usage_detectors(
883 input: DeadCodeDetectorInput<'_>,
884) -> BoundaryCycleUsageResults {
885 rayon::join(
886 || {
887 rayon::join(
888 || {
889 run_boundary_violation_detector(
890 input.graph,
891 input.config,
892 input.suppressions,
893 input.line_offsets_by_file,
894 )
895 },
896 || {
897 run_boundary_aux_detectors(
898 input.graph,
899 input.modules,
900 input.config,
901 input.suppressions,
902 input.line_offsets_by_file,
903 )
904 },
905 )
906 },
907 || {
908 rayon::join(
909 || {
910 run_circular_dep_detector(
911 input.graph,
912 input.config,
913 input.line_offsets_by_file,
914 input.suppressions,
915 input.workspaces,
916 )
917 },
918 || {
919 rayon::join(
920 || {
921 run_re_export_cycle_detector(
922 input.graph,
923 input.config,
924 input.suppressions,
925 )
926 },
927 || {
928 run_export_usages_collector(
929 input.graph,
930 input.line_offsets_by_file,
931 input.collect_usages,
932 )
933 },
934 )
935 },
936 )
937 },
938 )
939}
940
941#[expect(
942 deprecated,
943 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
944)]
945fn run_duplicate_export_detector(
946 graph: &ModuleGraph,
947 config: &ResolvedConfig,
948 suppressions: &SuppressionContext<'_>,
949 line_offsets_by_file: &LineOffsetsMap<'_>,
950 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
951 resolved_modules: &[ResolvedModule],
952) -> Vec<DuplicateExportFinding> {
953 if config.rules.duplicate_exports == Severity::Off {
954 return Vec::new();
955 }
956 let duplicate_exports = if let Some(plugin_result) = plugin_result {
957 unused_exports::find_duplicate_exports_with_plugins(
958 graph,
959 config,
960 suppressions,
961 line_offsets_by_file,
962 Some(plugin_result),
963 resolved_modules,
964 )
965 } else {
966 unused_exports::find_duplicate_exports(
967 graph,
968 config,
969 suppressions,
970 line_offsets_by_file,
971 resolved_modules,
972 )
973 };
974 duplicate_exports
975 .into_iter()
976 .map(DuplicateExportFinding::with_actions)
977 .collect()
978}
979
980#[expect(
981 deprecated,
982 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
983)]
984fn run_boundary_violation_detector(
985 graph: &ModuleGraph,
986 config: &ResolvedConfig,
987 suppressions: &SuppressionContext<'_>,
988 line_offsets_by_file: &LineOffsetsMap<'_>,
989) -> Vec<BoundaryViolationFinding> {
990 if config.rules.boundary_violation == Severity::Off || config.boundaries.is_empty() {
991 return Vec::new();
992 }
993 boundary::find_boundary_violations(graph, config, suppressions, line_offsets_by_file)
994 .into_iter()
995 .map(BoundaryViolationFinding::with_actions)
996 .collect()
997}
998
999fn filter_public_workspace_results(
1000 config: &ResolvedConfig,
1001 workspaces: &[fallow_config::WorkspaceInfo],
1002 results: &mut AnalysisResults,
1003) {
1004 let public_roots = public_workspace_roots(&config.public_packages, workspaces);
1005 if public_roots.is_empty() {
1006 return;
1007 }
1008 results.unused_exports.retain(|e| {
1009 !public_roots
1010 .iter()
1011 .any(|root| e.export.path.starts_with(root))
1012 });
1013 results.unused_types.retain(|e| {
1014 !public_roots
1015 .iter()
1016 .any(|root| e.export.path.starts_with(root))
1017 });
1018 results.unused_enum_members.retain(|e| {
1019 !public_roots
1020 .iter()
1021 .any(|root| e.member.path.starts_with(root))
1022 });
1023 results.unused_class_members.retain(|e| {
1024 !public_roots
1025 .iter()
1026 .any(|root| e.member.path.starts_with(root))
1027 });
1028}
1029
1030#[expect(
1031 deprecated,
1032 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1033)]
1034fn populate_pnpm_catalog_findings(
1035 config: &ResolvedConfig,
1036 workspaces: &[fallow_config::WorkspaceInfo],
1037 results: &mut AnalysisResults,
1038) {
1039 let need_unused = config.rules.unused_catalog_entries != Severity::Off;
1040 let need_empty_groups = config.rules.empty_catalog_groups != Severity::Off;
1041 let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
1042 let Some(state) = ((need_unused || need_empty_groups || need_unresolved_refs)
1043 .then(|| gather_pnpm_catalog_state(config, workspaces)))
1044 .flatten() else {
1045 return;
1046 };
1047
1048 if need_unused {
1049 results.unused_catalog_entries = find_unused_catalog_entries(&state)
1050 .into_iter()
1051 .map(UnusedCatalogEntryFinding::with_actions)
1052 .collect();
1053 }
1054 if need_empty_groups {
1055 results.empty_catalog_groups = find_empty_catalog_groups(&state)
1056 .into_iter()
1057 .map(EmptyCatalogGroupFinding::with_actions)
1058 .collect();
1059 }
1060 if need_unresolved_refs {
1061 results.unresolved_catalog_references = find_unresolved_catalog_references(
1062 &state,
1063 &config.compiled_ignore_catalog_references,
1064 &config.root,
1065 )
1066 .into_iter()
1067 .map(UnresolvedCatalogReferenceFinding::with_actions)
1068 .collect();
1069 }
1070}
1071
1072#[expect(
1073 deprecated,
1074 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1075)]
1076fn populate_pnpm_override_findings(
1077 config: &ResolvedConfig,
1078 workspaces: &[fallow_config::WorkspaceInfo],
1079 results: &mut AnalysisResults,
1080) {
1081 let need_unused = config.rules.unused_dependency_overrides != Severity::Off;
1082 let need_misconfigured = config.rules.misconfigured_dependency_overrides != Severity::Off;
1083 let Some(state) = ((need_unused || need_misconfigured)
1084 .then(|| gather_pnpm_override_state(config, workspaces)))
1085 .flatten() else {
1086 return;
1087 };
1088
1089 if need_unused {
1090 results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
1091 .into_iter()
1092 .map(UnusedDependencyOverrideFinding::with_actions)
1093 .collect();
1094 }
1095 if need_misconfigured {
1096 results.misconfigured_dependency_overrides =
1097 find_misconfigured_dependency_overrides(&state, config)
1098 .into_iter()
1099 .map(MisconfiguredDependencyOverrideFinding::with_actions)
1100 .collect();
1101 }
1102}
1103
1104fn populate_security_findings(
1105 ctx: &SecurityDetectionContext<'_, '_>,
1106 results: &mut AnalysisResults,
1107) {
1108 if ctx.config.rules.security_client_server_leak != Severity::Off {
1109 let (security_findings, stats) = security::find_security_findings(
1110 ctx.graph,
1111 ctx.modules,
1112 ctx.suppressions,
1113 ctx.line_offsets_by_file,
1114 );
1115 results.security_findings = security_findings;
1116 results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
1117 }
1118
1119 if ctx.config.rules.security_sink != Severity::Off {
1120 populate_tainted_sink_findings(ctx, results);
1121 }
1122
1123 if !results.security_findings.is_empty() {
1124 annotate_security_findings(ctx, results);
1125 }
1126}
1127
1128fn populate_tainted_sink_findings(
1129 ctx: &SecurityDetectionContext<'_, '_>,
1130 results: &mut AnalysisResults,
1131) {
1132 let categories = ctx.config.security.categories.as_ref();
1133 let filter = security::CategoryFilter::new(
1134 categories.and_then(|c| c.include.clone()),
1135 categories.and_then(|c| c.exclude.clone()),
1136 );
1137 let (sink_findings, sink_stats) = security::find_tainted_sinks(
1138 ctx.graph,
1139 ctx.modules,
1140 ctx.suppressions,
1141 ctx.line_offsets_by_file,
1142 ctx.declared_deps,
1143 &security::TaintedSinkContext {
1144 category_filter: &filter,
1145 request_receivers: ctx.request_receivers,
1146 root: &ctx.config.root,
1147 },
1148 );
1149 results.security_findings.extend(sink_findings);
1150 results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
1151 results.security_unresolved_callee_diagnostics = sink_stats.unresolved_callee_diagnostics;
1152 results
1153 .security_findings
1154 .extend(security::find_hardcoded_secret_candidates(
1155 ctx.graph,
1156 ctx.modules,
1157 ctx.suppressions,
1158 ctx.line_offsets_by_file,
1159 &filter,
1160 &ctx.config.root,
1161 ));
1162}
1163
1164fn annotate_security_findings(
1165 ctx: &SecurityDetectionContext<'_, '_>,
1166 results: &mut AnalysisResults,
1167) {
1168 security::annotate_dead_code_cross_links(
1169 ctx.graph,
1170 ctx.modules,
1171 ctx.line_offsets_by_file,
1172 &results.unused_files,
1173 &results.unused_exports,
1174 &mut results.security_findings,
1175 );
1176 let boundary_crossings = boundary_crossings_by_file(&results.boundary_violations);
1177 security::rank_security_findings(
1178 ctx.graph,
1179 ctx.modules,
1180 ctx.line_offsets_by_file,
1181 ctx.declared_deps,
1182 ctx.request_receivers,
1183 &boundary_crossings,
1184 &mut results.security_findings,
1185 );
1186}
1187
1188fn boundary_crossings_by_file(
1189 boundary_violations: &[BoundaryViolationFinding],
1190) -> FxHashMap<std::path::PathBuf, (String, String)> {
1191 let mut boundary_crossings: FxHashMap<std::path::PathBuf, (String, String)> =
1192 FxHashMap::default();
1193 for violation in boundary_violations {
1194 let zones = (
1195 violation.violation.from_zone.clone(),
1196 violation.violation.to_zone.clone(),
1197 );
1198 for path in [
1199 violation.violation.from_path.clone(),
1200 violation.violation.to_path.clone(),
1201 ] {
1202 boundary_crossings
1203 .entry(path)
1204 .and_modify(|existing| {
1205 if zones < *existing {
1206 *existing = zones.clone();
1207 }
1208 })
1209 .or_insert_with(|| zones.clone());
1210 }
1211 }
1212 boundary_crossings
1213}
1214
1215#[expect(
1216 deprecated,
1217 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1218)]
1219fn run_unused_file_detector(
1220 graph: &ModuleGraph,
1221 config: &ResolvedConfig,
1222 suppressions: &crate::suppress::SuppressionContext<'_>,
1223) -> Vec<UnusedFileFinding> {
1224 if config.rules.unused_files == Severity::Off {
1225 return Vec::new();
1226 }
1227 find_unused_files(graph, suppressions)
1228 .into_iter()
1229 .map(UnusedFileFinding::with_actions)
1230 .collect()
1231}
1232
1233#[expect(
1234 deprecated,
1235 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1236)]
1237fn run_export_detectors(
1238 graph: &ModuleGraph,
1239 modules: &[ModuleInfo],
1240 config: &ResolvedConfig,
1241 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
1242 suppressions: &crate::suppress::SuppressionContext<'_>,
1243 line_offsets_by_file: &LineOffsetsMap<'_>,
1244) -> AnalysisResults {
1245 let mut results = AnalysisResults::default();
1246 if config.rules.unused_exports == Severity::Off
1247 && config.rules.unused_types == Severity::Off
1248 && config.rules.private_type_leaks == Severity::Off
1249 {
1250 return results;
1251 }
1252
1253 let (exports, types, stale_expected) = find_unused_exports(
1254 graph,
1255 modules,
1256 config,
1257 plugin_result,
1258 suppressions,
1259 line_offsets_by_file,
1260 );
1261 if config.rules.unused_exports != Severity::Off {
1262 results.unused_exports = exports
1263 .into_iter()
1264 .map(UnusedExportFinding::with_actions)
1265 .collect();
1266 }
1267 if config.rules.unused_types != Severity::Off {
1268 let mut typed = types;
1269 suppress_signature_backing_types(&mut typed, graph, modules);
1270 results.unused_types = typed
1271 .into_iter()
1272 .map(UnusedTypeFinding::with_actions)
1273 .collect();
1274 }
1275 if config.rules.private_type_leaks != Severity::Off {
1276 results.private_type_leaks =
1277 find_private_type_leaks(graph, modules, config, suppressions, line_offsets_by_file)
1278 .into_iter()
1279 .map(PrivateTypeLeakFinding::with_actions)
1280 .collect();
1281 }
1282 if config.rules.stale_suppressions != Severity::Off {
1283 results.stale_suppressions.extend(stale_expected);
1284 }
1285 results
1286}
1287
1288#[expect(
1289 clippy::too_many_arguments,
1290 reason = "member detection needs graph context plus public API and allowlist filters"
1291)]
1292fn run_member_detectors(
1293 graph: &ModuleGraph,
1294 resolved_modules: &[ResolvedModule],
1295 modules: &[ModuleInfo],
1296 config: &ResolvedConfig,
1297 suppressions: &crate::suppress::SuppressionContext<'_>,
1298 line_offsets_by_file: &LineOffsetsMap<'_>,
1299 user_class_members: &[fallow_config::UsedClassMemberRule],
1300 public_api_entry_points: &FxHashSet<FileId>,
1301) -> AnalysisResults {
1302 let mut results = AnalysisResults::default();
1303 if config.rules.unused_enum_members == Severity::Off
1304 && config.rules.unused_class_members == Severity::Off
1305 {
1306 return results;
1307 }
1308
1309 let (enum_members, class_members) = find_unused_members_with_public_api_entry_points(
1310 graph,
1311 resolved_modules,
1312 modules,
1313 suppressions,
1314 line_offsets_by_file,
1315 user_class_members,
1316 &config.ignore_decorators,
1317 public_api_entry_points,
1318 );
1319 if config.rules.unused_enum_members != Severity::Off {
1320 results.unused_enum_members = enum_members
1321 .into_iter()
1322 .map(UnusedEnumMemberFinding::with_actions)
1323 .collect();
1324 }
1325 if config.rules.unused_class_members != Severity::Off {
1326 results.unused_class_members = class_members
1327 .into_iter()
1328 .map(UnusedClassMemberFinding::with_actions)
1329 .collect();
1330 }
1331 results
1332}
1333
1334#[expect(
1335 deprecated,
1336 reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1337)]
1338fn run_dependency_detectors(
1339 graph: &ModuleGraph,
1340 pkg: Option<&PackageJson>,
1341 config: &ResolvedConfig,
1342 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
1343 workspaces: &[fallow_config::WorkspaceInfo],
1344 resolved_modules: &[ResolvedModule],
1345 line_offsets_by_file: &LineOffsetsMap<'_>,
1346) -> AnalysisResults {
1347 let mut results = AnalysisResults::default();
1348 let Some(pkg) = pkg else {
1349 return results;
1350 };
1351
1352 if config.rules.unused_dependencies != Severity::Off
1353 || config.rules.unused_dev_dependencies != Severity::Off
1354 || config.rules.unused_optional_dependencies != Severity::Off
1355 {
1356 let (deps, dev_deps, optional_deps) =
1357 find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
1358 if config.rules.unused_dependencies != Severity::Off {
1359 results.unused_dependencies = deps
1360 .into_iter()
1361 .map(UnusedDependencyFinding::with_actions)
1362 .collect();
1363 }
1364 if config.rules.unused_dev_dependencies != Severity::Off {
1365 results.unused_dev_dependencies = dev_deps
1366 .into_iter()
1367 .map(UnusedDevDependencyFinding::with_actions)
1368 .collect();
1369 }
1370 if config.rules.unused_optional_dependencies != Severity::Off {
1371 results.unused_optional_dependencies = optional_deps
1372 .into_iter()
1373 .map(UnusedOptionalDependencyFinding::with_actions)
1374 .collect();
1375 }
1376 }
1377
1378 if config.rules.unlisted_dependencies != Severity::Off {
1379 results.unlisted_dependencies = find_unlisted_dependencies(
1380 graph,
1381 pkg,
1382 config,
1383 workspaces,
1384 plugin_result,
1385 resolved_modules,
1386 line_offsets_by_file,
1387 )
1388 .into_iter()
1389 .map(UnlistedDependencyFinding::with_actions)
1390 .collect();
1391 }
1392
1393 if config.production {
1394 results.type_only_dependencies =
1395 find_type_only_dependencies(graph, pkg, config, workspaces)
1396 .into_iter()
1397 .map(TypeOnlyDependencyFinding::with_actions)
1398 .collect();
1399 }
1400
1401 if !config.production && config.rules.test_only_dependencies != Severity::Off {
1402 results.test_only_dependencies =
1403 find_test_only_dependencies(graph, pkg, config, workspaces)
1404 .into_iter()
1405 .map(TestOnlyDependencyFinding::with_actions)
1406 .collect();
1407 }
1408 results
1409}
1410
1411fn run_unresolved_import_detector(
1412 resolved_modules: &[ResolvedModule],
1413 config: &ResolvedConfig,
1414 suppressions: &crate::suppress::SuppressionContext<'_>,
1415 virtual_prefixes: &[&str],
1416 generated_patterns: &[&str],
1417 generated_type_prefixes: &[&str],
1418 line_offsets_by_file: &LineOffsetsMap<'_>,
1419) -> Vec<UnresolvedImportFinding> {
1420 if config.rules.unresolved_imports == Severity::Off || resolved_modules.is_empty() {
1421 return Vec::new();
1422 }
1423 find_unresolved_imports(
1424 resolved_modules,
1425 config,
1426 suppressions,
1427 virtual_prefixes,
1428 generated_patterns,
1429 generated_type_prefixes,
1430 line_offsets_by_file,
1431 )
1432 .into_iter()
1433 .map(UnresolvedImportFinding::with_actions)
1434 .collect()
1435}
1436
1437#[cfg(test)]
1438#[expect(
1439 deprecated,
1440 reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
1441)]
1442mod tests {
1443 use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
1444
1445 fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
1446 let offsets = compute_line_offsets(source);
1447 byte_offset_to_line_col(&offsets, byte_offset)
1448 }
1449
1450 #[test]
1451 fn compute_offsets_empty() {
1452 assert_eq!(compute_line_offsets(""), vec![0]);
1453 }
1454
1455 #[test]
1456 fn compute_offsets_single_line() {
1457 assert_eq!(compute_line_offsets("hello"), vec![0]);
1458 }
1459
1460 #[test]
1461 fn compute_offsets_multiline() {
1462 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1463 }
1464
1465 #[test]
1466 fn compute_offsets_trailing_newline() {
1467 assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
1468 }
1469
1470 #[test]
1471 fn compute_offsets_crlf() {
1472 assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1473 }
1474
1475 #[test]
1476 fn compute_offsets_consecutive_newlines() {
1477 assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1478 }
1479
1480 #[test]
1481 fn byte_offset_empty_source() {
1482 assert_eq!(line_col("", 0), (1, 0));
1483 }
1484
1485 #[test]
1486 fn byte_offset_single_line_start() {
1487 assert_eq!(line_col("hello", 0), (1, 0));
1488 }
1489
1490 #[test]
1491 fn byte_offset_single_line_middle() {
1492 assert_eq!(line_col("hello", 4), (1, 4));
1493 }
1494
1495 #[test]
1496 fn byte_offset_multiline_start_of_line2() {
1497 assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1498 }
1499
1500 #[test]
1501 fn byte_offset_multiline_middle_of_line3() {
1502 assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1503 }
1504
1505 #[test]
1506 fn byte_offset_at_newline_boundary() {
1507 assert_eq!(line_col("line1\nline2", 5), (1, 5));
1508 }
1509
1510 #[test]
1511 fn byte_offset_multibyte_utf8() {
1512 let source = "hi\n\u{1F600}x";
1513 assert_eq!(line_col(source, 3), (2, 0));
1514 assert_eq!(line_col(source, 7), (2, 4));
1515 }
1516
1517 #[test]
1518 fn byte_offset_multibyte_accented_chars() {
1519 let source = "caf\u{00E9}\nbar";
1520 assert_eq!(line_col(source, 6), (2, 0));
1521 assert_eq!(line_col(source, 3), (1, 3));
1522 }
1523
1524 #[test]
1525 fn byte_offset_via_map_fallback() {
1526 use super::*;
1527 let map: LineOffsetsMap<'_> = FxHashMap::default();
1528 assert_eq!(
1529 super::byte_offset_to_line_col(&map, FileId(99), 42),
1530 (1, 42)
1531 );
1532 }
1533
1534 #[test]
1535 fn byte_offset_via_map_lookup() {
1536 use super::*;
1537 let offsets = compute_line_offsets("abc\ndef\nghi");
1538 let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1539 map.insert(FileId(0), &offsets);
1540 assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1541 }
1542
1543 mod orchestration {
1544 use super::super::*;
1545 use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1546 use std::path::PathBuf;
1547
1548 fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1549 find_dead_code_full(graph, config, &[], None, &[], &[], false)
1550 }
1551
1552 fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1553 FallowConfig {
1554 rules,
1555 ..Default::default()
1556 }
1557 .resolve(
1558 PathBuf::from("/tmp/orchestration-test"),
1559 OutputFormat::Human,
1560 1,
1561 true,
1562 true,
1563 None,
1564 )
1565 }
1566
1567 #[test]
1568 fn find_dead_code_all_rules_off_returns_empty() {
1569 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1570 use crate::graph::ModuleGraph;
1571 use crate::resolve::ResolvedModule;
1572 use rustc_hash::FxHashSet;
1573
1574 let files = vec![DiscoveredFile {
1575 id: FileId(0),
1576 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1577 size_bytes: 100,
1578 }];
1579 let entry_points = vec![EntryPoint {
1580 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1581 source: EntryPointSource::ManualEntry,
1582 }];
1583 let resolved = vec![ResolvedModule {
1584 file_id: FileId(0),
1585 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1586 exports: vec![],
1587 re_exports: vec![],
1588 resolved_imports: vec![],
1589 resolved_dynamic_imports: vec![],
1590 resolved_dynamic_patterns: vec![],
1591 member_accesses: vec![],
1592 whole_object_uses: vec![],
1593 has_cjs_exports: false,
1594 has_angular_component_template_url: false,
1595 unused_import_bindings: FxHashSet::default(),
1596 type_referenced_import_bindings: vec![],
1597 value_referenced_import_bindings: vec![],
1598 namespace_object_aliases: vec![],
1599 }];
1600 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1601
1602 let rules = RulesConfig {
1603 unused_files: Severity::Off,
1604 unused_exports: Severity::Off,
1605 unused_types: Severity::Off,
1606 private_type_leaks: Severity::Off,
1607 unused_dependencies: Severity::Off,
1608 unused_dev_dependencies: Severity::Off,
1609 unused_optional_dependencies: Severity::Off,
1610 unused_enum_members: Severity::Off,
1611 unused_class_members: Severity::Off,
1612 unresolved_imports: Severity::Off,
1613 unlisted_dependencies: Severity::Off,
1614 duplicate_exports: Severity::Off,
1615 type_only_dependencies: Severity::Off,
1616 circular_dependencies: Severity::Off,
1617 re_export_cycle: Severity::Off,
1618 test_only_dependencies: Severity::Off,
1619 boundary_violation: Severity::Off,
1620 coverage_gaps: Severity::Off,
1621 feature_flags: Severity::Off,
1622 stale_suppressions: Severity::Off,
1623 unused_catalog_entries: Severity::Off,
1624 empty_catalog_groups: Severity::Off,
1625 unresolved_catalog_references: Severity::Off,
1626 unused_dependency_overrides: Severity::Off,
1627 misconfigured_dependency_overrides: Severity::Off,
1628 security_client_server_leak: Severity::Off,
1629 security_sink: Severity::Off,
1630 policy_violation: Severity::Off,
1631 };
1632 let config = make_config_with_rules(rules);
1633 let results = find_dead_code(&graph, &config);
1634
1635 assert!(results.unused_files.is_empty());
1636 assert!(results.unused_exports.is_empty());
1637 assert!(results.unused_types.is_empty());
1638 assert!(results.unused_dependencies.is_empty());
1639 assert!(results.unused_dev_dependencies.is_empty());
1640 assert!(results.unused_optional_dependencies.is_empty());
1641 assert!(results.unused_enum_members.is_empty());
1642 assert!(results.unused_class_members.is_empty());
1643 assert!(results.unresolved_imports.is_empty());
1644 assert!(results.unlisted_dependencies.is_empty());
1645 assert!(results.duplicate_exports.is_empty());
1646 assert!(results.circular_dependencies.is_empty());
1647 assert!(results.export_usages.is_empty());
1648 }
1649
1650 #[test]
1651 fn find_dead_code_full_collect_usages_flag() {
1652 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1653 use crate::extract::{ExportName, VisibilityTag};
1654 use crate::graph::{ExportSymbol, ModuleGraph};
1655 use crate::resolve::ResolvedModule;
1656 use oxc_span::Span;
1657 use rustc_hash::FxHashSet;
1658
1659 let files = vec![DiscoveredFile {
1660 id: FileId(0),
1661 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1662 size_bytes: 100,
1663 }];
1664 let entry_points = vec![EntryPoint {
1665 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1666 source: EntryPointSource::ManualEntry,
1667 }];
1668 let resolved = vec![ResolvedModule {
1669 file_id: FileId(0),
1670 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1671 exports: vec![],
1672 re_exports: vec![],
1673 resolved_imports: vec![],
1674 resolved_dynamic_imports: vec![],
1675 resolved_dynamic_patterns: vec![],
1676 member_accesses: vec![],
1677 whole_object_uses: vec![],
1678 has_cjs_exports: false,
1679 has_angular_component_template_url: false,
1680 unused_import_bindings: FxHashSet::default(),
1681 type_referenced_import_bindings: vec![],
1682 value_referenced_import_bindings: vec![],
1683 namespace_object_aliases: vec![],
1684 }];
1685 let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1686 graph.modules[0].exports = vec![ExportSymbol {
1687 name: ExportName::Named("myExport".to_string()),
1688 is_type_only: false,
1689 is_side_effect_used: false,
1690 visibility: VisibilityTag::None,
1691 span: Span::new(10, 30),
1692 references: vec![],
1693 members: vec![],
1694 }];
1695
1696 let rules = RulesConfig::default();
1697 let config = make_config_with_rules(rules);
1698
1699 let results_no_collect = find_dead_code_full(
1700 &graph,
1701 &config,
1702 &[],
1703 None,
1704 &[],
1705 &[],
1706 false, );
1708 assert!(
1709 results_no_collect.export_usages.is_empty(),
1710 "export_usages should be empty when collect_usages is false"
1711 );
1712
1713 let results_with_collect = find_dead_code_full(
1714 &graph,
1715 &config,
1716 &[],
1717 None,
1718 &[],
1719 &[],
1720 true, );
1722 assert!(
1723 !results_with_collect.export_usages.is_empty(),
1724 "export_usages should be populated when collect_usages is true"
1725 );
1726 assert_eq!(
1727 results_with_collect.export_usages[0].export_name,
1728 "myExport"
1729 );
1730 }
1731
1732 #[test]
1733 fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1734 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1735 use crate::graph::ModuleGraph;
1736 use crate::resolve::ResolvedModule;
1737 use rustc_hash::FxHashSet;
1738
1739 let files = vec![DiscoveredFile {
1740 id: FileId(0),
1741 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1742 size_bytes: 100,
1743 }];
1744 let entry_points = vec![EntryPoint {
1745 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1746 source: EntryPointSource::ManualEntry,
1747 }];
1748 let resolved = vec![ResolvedModule {
1749 file_id: FileId(0),
1750 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1751 exports: vec![],
1752 re_exports: vec![],
1753 resolved_imports: vec![],
1754 resolved_dynamic_imports: vec![],
1755 resolved_dynamic_patterns: vec![],
1756 member_accesses: vec![],
1757 whole_object_uses: vec![],
1758 has_cjs_exports: false,
1759 has_angular_component_template_url: false,
1760 unused_import_bindings: FxHashSet::default(),
1761 type_referenced_import_bindings: vec![],
1762 value_referenced_import_bindings: vec![],
1763 namespace_object_aliases: vec![],
1764 }];
1765 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1766 let config = make_config_with_rules(RulesConfig::default());
1767
1768 let results = find_dead_code(&graph, &config);
1769 assert!(results.unused_exports.is_empty());
1770 }
1771
1772 #[test]
1773 fn suppressions_built_from_modules() {
1774 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1775 use crate::extract::ModuleInfo;
1776 use crate::graph::ModuleGraph;
1777 use crate::resolve::ResolvedModule;
1778 use crate::suppress::{IssueKind, Suppression};
1779 use rustc_hash::FxHashSet;
1780
1781 let files = vec![
1782 DiscoveredFile {
1783 id: FileId(0),
1784 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1785 size_bytes: 100,
1786 },
1787 DiscoveredFile {
1788 id: FileId(1),
1789 path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1790 size_bytes: 100,
1791 },
1792 ];
1793 let entry_points = vec![EntryPoint {
1794 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1795 source: EntryPointSource::ManualEntry,
1796 }];
1797 let resolved = files
1798 .iter()
1799 .map(|f| ResolvedModule {
1800 file_id: f.id,
1801 path: f.path.clone(),
1802 exports: vec![],
1803 re_exports: vec![],
1804 resolved_imports: vec![],
1805 resolved_dynamic_imports: vec![],
1806 resolved_dynamic_patterns: vec![],
1807 member_accesses: vec![],
1808 whole_object_uses: vec![],
1809 has_cjs_exports: false,
1810 has_angular_component_template_url: false,
1811 unused_import_bindings: FxHashSet::default(),
1812 type_referenced_import_bindings: vec![],
1813 value_referenced_import_bindings: vec![],
1814 namespace_object_aliases: vec![],
1815 })
1816 .collect::<Vec<_>>();
1817 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1818
1819 let modules = vec![ModuleInfo {
1820 file_id: FileId(1),
1821 exports: vec![],
1822 imports: vec![],
1823 re_exports: vec![],
1824 dynamic_imports: vec![],
1825 dynamic_import_patterns: vec![],
1826 require_calls: vec![],
1827 package_path_references: vec![],
1828 member_accesses: vec![],
1829 whole_object_uses: vec![],
1830 has_cjs_exports: false,
1831 has_angular_component_template_url: false,
1832 content_hash: 0,
1833 suppressions: vec![Suppression {
1834 line: 0,
1835 comment_line: 1,
1836 kind: Some(IssueKind::UnusedFile),
1837 }],
1838 unknown_suppression_kinds: vec![],
1839 unused_import_bindings: vec![],
1840 type_referenced_import_bindings: vec![],
1841 value_referenced_import_bindings: vec![],
1842 line_offsets: vec![],
1843 complexity: vec![],
1844 flag_uses: vec![],
1845 class_heritage: vec![],
1846 injection_tokens: vec![],
1847 local_type_declarations: Vec::new(),
1848 public_signature_type_references: Vec::new(),
1849 namespace_object_aliases: Vec::new(),
1850 iconify_prefixes: Vec::new(),
1851 iconify_icon_names: Vec::new(),
1852 auto_import_candidates: Vec::new(),
1853 directives: Vec::new(),
1854 security_sinks: Vec::new(),
1855 security_sinks_skipped: 0,
1856 security_unresolved_callee_sites: Vec::new(),
1857 tainted_bindings: Vec::new(),
1858 sanitized_sink_args: Vec::new(),
1859 security_control_sites: Vec::new(),
1860 callee_uses: Vec::new(),
1861 }];
1862
1863 let rules = RulesConfig {
1864 unused_files: Severity::Error,
1865 ..RulesConfig::default()
1866 };
1867 let config = make_config_with_rules(rules);
1868
1869 let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1870
1871 assert!(
1872 !results.unused_files.iter().any(|f| f
1873 .file
1874 .path
1875 .to_string_lossy()
1876 .contains("utils.ts")),
1877 "suppressed file should not appear in unused_files"
1878 );
1879 }
1880 }
1881}