1mod boundary;
2pub mod feature_flags;
3mod package_json_utils;
4mod predicates;
5mod unused_deps;
6mod unused_exports;
7mod unused_files;
8mod unused_members;
9
10use rustc_hash::FxHashMap;
11
12use fallow_config::{PackageJson, ResolvedConfig, Severity};
13
14use crate::discover::FileId;
15use crate::extract::ModuleInfo;
16use crate::graph::ModuleGraph;
17use crate::resolve::ResolvedModule;
18use crate::results::{AnalysisResults, CircularDependency};
19use crate::suppress::IssueKind;
20
21use unused_deps::{
22 find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
23 find_unresolved_imports, find_unused_dependencies,
24};
25use unused_exports::{collect_export_usages, find_duplicate_exports, find_unused_exports};
26use unused_files::find_unused_files;
27use unused_members::find_unused_members;
28
29pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
32
33pub fn byte_offset_to_line_col(
36 line_offsets_map: &LineOffsetsMap<'_>,
37 file_id: FileId,
38 byte_offset: u32,
39) -> (u32, u32) {
40 line_offsets_map
41 .get(&file_id)
42 .map_or((1, byte_offset), |offsets| {
43 fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
44 })
45}
46
47fn read_source(path: &std::path::Path) -> String {
51 std::fs::read_to_string(path).unwrap_or_default()
52}
53
54fn is_cross_package_cycle(
59 files: &[std::path::PathBuf],
60 workspaces: &[fallow_config::WorkspaceInfo],
61) -> bool {
62 let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
63 workspaces
64 .iter()
65 .map(|w| w.root.as_path())
66 .filter(|root| path.starts_with(root))
67 .max_by_key(|root| root.components().count())
68 };
69
70 let mut seen_workspace: Option<&std::path::Path> = None;
71 for file in files {
72 if let Some(ws) = find_workspace(file) {
73 match &seen_workspace {
74 None => seen_workspace = Some(ws),
75 Some(prev) if *prev != ws => return true,
76 _ => {}
77 }
78 }
79 }
80 false
81}
82
83#[expect(
85 clippy::too_many_lines,
86 reason = "orchestration function calling all detectors; split candidate for sig-audit-loop"
87)]
88pub fn find_dead_code_full(
89 graph: &ModuleGraph,
90 config: &ResolvedConfig,
91 resolved_modules: &[ResolvedModule],
92 plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
93 workspaces: &[fallow_config::WorkspaceInfo],
94 modules: &[ModuleInfo],
95 collect_usages: bool,
96) -> AnalysisResults {
97 let _span = tracing::info_span!("find_dead_code").entered();
98
99 let suppressions = crate::suppress::SuppressionContext::new(modules);
101
102 let line_offsets_by_file: LineOffsetsMap<'_> = modules
105 .iter()
106 .filter(|m| !m.line_offsets.is_empty())
107 .map(|m| (m.file_id, m.line_offsets.as_slice()))
108 .collect();
109
110 let mut results = AnalysisResults::default();
111
112 if config.rules.unused_files != Severity::Off {
113 results.unused_files = find_unused_files(graph, &suppressions);
114 }
115
116 if config.rules.unused_exports != Severity::Off || config.rules.unused_types != Severity::Off {
117 let (exports, types, stale_expected) = find_unused_exports(
118 graph,
119 config,
120 plugin_result,
121 &suppressions,
122 &line_offsets_by_file,
123 );
124 if config.rules.unused_exports != Severity::Off {
125 results.unused_exports = exports;
126 }
127 if config.rules.unused_types != Severity::Off {
128 results.unused_types = types;
129 }
130 if config.rules.stale_suppressions != Severity::Off {
132 results.stale_suppressions.extend(stale_expected);
133 }
134 }
135
136 if config.rules.unused_enum_members != Severity::Off
137 || config.rules.unused_class_members != Severity::Off
138 {
139 let mut user_class_members = config.used_class_members.clone();
144 if let Some(plugin_result) = plugin_result {
145 user_class_members.extend(plugin_result.used_class_members.iter().cloned());
146 }
147
148 let (enum_members, class_members) = find_unused_members(
149 graph,
150 resolved_modules,
151 modules,
152 &suppressions,
153 &line_offsets_by_file,
154 &user_class_members,
155 );
156 if config.rules.unused_enum_members != Severity::Off {
157 results.unused_enum_members = enum_members;
158 }
159 if config.rules.unused_class_members != Severity::Off {
160 results.unused_class_members = class_members;
161 }
162 }
163
164 let pkg_path = config.root.join("package.json");
166 let pkg = PackageJson::load(&pkg_path).ok();
167 if let Some(ref pkg) = pkg {
168 if config.rules.unused_dependencies != Severity::Off
169 || config.rules.unused_dev_dependencies != Severity::Off
170 || config.rules.unused_optional_dependencies != Severity::Off
171 {
172 let (deps, dev_deps, optional_deps) =
173 find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
174 if config.rules.unused_dependencies != Severity::Off {
175 results.unused_dependencies = deps;
176 }
177 if config.rules.unused_dev_dependencies != Severity::Off {
178 results.unused_dev_dependencies = dev_deps;
179 }
180 if config.rules.unused_optional_dependencies != Severity::Off {
181 results.unused_optional_dependencies = optional_deps;
182 }
183 }
184
185 if config.rules.unlisted_dependencies != Severity::Off {
186 results.unlisted_dependencies = find_unlisted_dependencies(
187 graph,
188 pkg,
189 config,
190 workspaces,
191 plugin_result,
192 resolved_modules,
193 &line_offsets_by_file,
194 );
195 }
196 }
197
198 if config.rules.unresolved_imports != Severity::Off && !resolved_modules.is_empty() {
199 let virtual_prefixes: Vec<&str> = plugin_result
200 .map(|pr| {
201 pr.virtual_module_prefixes
202 .iter()
203 .map(String::as_str)
204 .collect()
205 })
206 .unwrap_or_default();
207 let generated_patterns: Vec<&str> = plugin_result
208 .map(|pr| {
209 pr.generated_import_patterns
210 .iter()
211 .map(String::as_str)
212 .collect()
213 })
214 .unwrap_or_default();
215 results.unresolved_imports = find_unresolved_imports(
216 resolved_modules,
217 config,
218 &suppressions,
219 &virtual_prefixes,
220 &generated_patterns,
221 &line_offsets_by_file,
222 );
223 }
224
225 if config.rules.duplicate_exports != Severity::Off {
226 results.duplicate_exports =
227 find_duplicate_exports(graph, &suppressions, &line_offsets_by_file);
228 }
229
230 if config.production
232 && let Some(ref pkg) = pkg
233 {
234 results.type_only_dependencies =
235 find_type_only_dependencies(graph, pkg, config, workspaces);
236 }
237
238 if !config.production
240 && config.rules.test_only_dependencies != Severity::Off
241 && let Some(ref pkg) = pkg
242 {
243 results.test_only_dependencies =
244 find_test_only_dependencies(graph, pkg, config, workspaces);
245 }
246
247 if config.rules.boundary_violation != Severity::Off && !config.boundaries.is_empty() {
249 results.boundary_violations =
250 boundary::find_boundary_violations(graph, config, &suppressions, &line_offsets_by_file);
251 }
252
253 if config.rules.circular_dependencies != Severity::Off {
255 let cycles = graph.find_cycles();
256 results.circular_dependencies = cycles
257 .into_iter()
258 .filter(|cycle| {
259 !cycle
261 .iter()
262 .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
263 })
264 .map(|cycle| {
265 let files: Vec<std::path::PathBuf> = cycle
266 .iter()
267 .map(|&id| graph.modules[id.0 as usize].path.clone())
268 .collect();
269 let length = files.len();
270 let (line, col) = if cycle.len() >= 2 {
272 graph
273 .find_import_span_start(cycle[0], cycle[1])
274 .map_or((1, 0), |span_start| {
275 byte_offset_to_line_col(&line_offsets_by_file, cycle[0], span_start)
276 })
277 } else {
278 (1, 0)
279 };
280 CircularDependency {
281 files,
282 length,
283 line,
284 col,
285 is_cross_package: false,
286 }
287 })
288 .collect();
289
290 if !workspaces.is_empty() {
292 for dep in &mut results.circular_dependencies {
293 dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
294 }
295 }
296 }
297
298 if collect_usages {
301 results.export_usages = collect_export_usages(graph, &line_offsets_by_file);
302 }
303
304 if !config.public_packages.is_empty() && !workspaces.is_empty() {
307 let public_roots: Vec<&std::path::Path> = workspaces
308 .iter()
309 .filter(|ws| {
310 config.public_packages.iter().any(|pattern| {
311 ws.name == *pattern
312 || globset::Glob::new(pattern)
313 .ok()
314 .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
315 })
316 })
317 .map(|ws| ws.root.as_path())
318 .collect();
319
320 if !public_roots.is_empty() {
321 results
322 .unused_exports
323 .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
324 results
325 .unused_types
326 .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
327 }
328 }
329
330 if config.rules.stale_suppressions != Severity::Off {
332 results
333 .stale_suppressions
334 .extend(suppressions.find_stale(graph));
335 }
336
337 results.sort();
341
342 results
343}
344
345#[cfg(test)]
346mod tests {
347 use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
348
349 fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
351 let offsets = compute_line_offsets(source);
352 byte_offset_to_line_col(&offsets, byte_offset)
353 }
354
355 #[test]
358 fn compute_offsets_empty() {
359 assert_eq!(compute_line_offsets(""), vec![0]);
360 }
361
362 #[test]
363 fn compute_offsets_single_line() {
364 assert_eq!(compute_line_offsets("hello"), vec![0]);
365 }
366
367 #[test]
368 fn compute_offsets_multiline() {
369 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
370 }
371
372 #[test]
373 fn compute_offsets_trailing_newline() {
374 assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
375 }
376
377 #[test]
378 fn compute_offsets_crlf() {
379 assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
380 }
381
382 #[test]
383 fn compute_offsets_consecutive_newlines() {
384 assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
385 }
386
387 #[test]
390 fn byte_offset_empty_source() {
391 assert_eq!(line_col("", 0), (1, 0));
392 }
393
394 #[test]
395 fn byte_offset_single_line_start() {
396 assert_eq!(line_col("hello", 0), (1, 0));
397 }
398
399 #[test]
400 fn byte_offset_single_line_middle() {
401 assert_eq!(line_col("hello", 4), (1, 4));
402 }
403
404 #[test]
405 fn byte_offset_multiline_start_of_line2() {
406 assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
407 }
408
409 #[test]
410 fn byte_offset_multiline_middle_of_line3() {
411 assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
412 }
413
414 #[test]
415 fn byte_offset_at_newline_boundary() {
416 assert_eq!(line_col("line1\nline2", 5), (1, 5));
417 }
418
419 #[test]
420 fn byte_offset_multibyte_utf8() {
421 let source = "hi\n\u{1F600}x";
422 assert_eq!(line_col(source, 3), (2, 0));
423 assert_eq!(line_col(source, 7), (2, 4));
424 }
425
426 #[test]
427 fn byte_offset_multibyte_accented_chars() {
428 let source = "caf\u{00E9}\nbar";
429 assert_eq!(line_col(source, 6), (2, 0));
430 assert_eq!(line_col(source, 3), (1, 3));
431 }
432
433 #[test]
434 fn byte_offset_via_map_fallback() {
435 use super::*;
436 let map: LineOffsetsMap<'_> = FxHashMap::default();
437 assert_eq!(
438 super::byte_offset_to_line_col(&map, FileId(99), 42),
439 (1, 42)
440 );
441 }
442
443 #[test]
444 fn byte_offset_via_map_lookup() {
445 use super::*;
446 let offsets = compute_line_offsets("abc\ndef\nghi");
447 let mut map: LineOffsetsMap<'_> = FxHashMap::default();
448 map.insert(FileId(0), &offsets);
449 assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
450 }
451
452 mod orchestration {
455 use super::super::*;
456 use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
457 use std::path::PathBuf;
458
459 fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
460 find_dead_code_full(graph, config, &[], None, &[], &[], false)
461 }
462
463 fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
464 FallowConfig {
465 rules,
466 ..Default::default()
467 }
468 .resolve(
469 PathBuf::from("/tmp/orchestration-test"),
470 OutputFormat::Human,
471 1,
472 true,
473 true,
474 )
475 }
476
477 #[test]
478 fn find_dead_code_all_rules_off_returns_empty() {
479 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
480 use crate::graph::ModuleGraph;
481 use crate::resolve::ResolvedModule;
482 use rustc_hash::FxHashSet;
483
484 let files = vec![DiscoveredFile {
485 id: FileId(0),
486 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
487 size_bytes: 100,
488 }];
489 let entry_points = vec![EntryPoint {
490 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
491 source: EntryPointSource::ManualEntry,
492 }];
493 let resolved = vec![ResolvedModule {
494 file_id: FileId(0),
495 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
496 exports: vec![],
497 re_exports: vec![],
498 resolved_imports: vec![],
499 resolved_dynamic_imports: vec![],
500 resolved_dynamic_patterns: vec![],
501 member_accesses: vec![],
502 whole_object_uses: vec![],
503 has_cjs_exports: false,
504 unused_import_bindings: FxHashSet::default(),
505 }];
506 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
507
508 let rules = RulesConfig {
509 unused_files: Severity::Off,
510 unused_exports: Severity::Off,
511 unused_types: Severity::Off,
512 unused_dependencies: Severity::Off,
513 unused_dev_dependencies: Severity::Off,
514 unused_optional_dependencies: Severity::Off,
515 unused_enum_members: Severity::Off,
516 unused_class_members: Severity::Off,
517 unresolved_imports: Severity::Off,
518 unlisted_dependencies: Severity::Off,
519 duplicate_exports: Severity::Off,
520 type_only_dependencies: Severity::Off,
521 circular_dependencies: Severity::Off,
522 test_only_dependencies: Severity::Off,
523 boundary_violation: Severity::Off,
524 coverage_gaps: Severity::Off,
525 feature_flags: Severity::Off,
526 stale_suppressions: Severity::Off,
527 };
528 let config = make_config_with_rules(rules);
529 let results = find_dead_code(&graph, &config);
530
531 assert!(results.unused_files.is_empty());
532 assert!(results.unused_exports.is_empty());
533 assert!(results.unused_types.is_empty());
534 assert!(results.unused_dependencies.is_empty());
535 assert!(results.unused_dev_dependencies.is_empty());
536 assert!(results.unused_optional_dependencies.is_empty());
537 assert!(results.unused_enum_members.is_empty());
538 assert!(results.unused_class_members.is_empty());
539 assert!(results.unresolved_imports.is_empty());
540 assert!(results.unlisted_dependencies.is_empty());
541 assert!(results.duplicate_exports.is_empty());
542 assert!(results.circular_dependencies.is_empty());
543 assert!(results.export_usages.is_empty());
544 }
545
546 #[test]
547 fn find_dead_code_full_collect_usages_flag() {
548 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
549 use crate::extract::{ExportName, VisibilityTag};
550 use crate::graph::{ExportSymbol, ModuleGraph};
551 use crate::resolve::ResolvedModule;
552 use oxc_span::Span;
553 use rustc_hash::FxHashSet;
554
555 let files = vec![DiscoveredFile {
556 id: FileId(0),
557 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
558 size_bytes: 100,
559 }];
560 let entry_points = vec![EntryPoint {
561 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
562 source: EntryPointSource::ManualEntry,
563 }];
564 let resolved = vec![ResolvedModule {
565 file_id: FileId(0),
566 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
567 exports: vec![],
568 re_exports: vec![],
569 resolved_imports: vec![],
570 resolved_dynamic_imports: vec![],
571 resolved_dynamic_patterns: vec![],
572 member_accesses: vec![],
573 whole_object_uses: vec![],
574 has_cjs_exports: false,
575 unused_import_bindings: FxHashSet::default(),
576 }];
577 let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
578 graph.modules[0].exports = vec![ExportSymbol {
579 name: ExportName::Named("myExport".to_string()),
580 is_type_only: false,
581 visibility: VisibilityTag::None,
582 span: Span::new(10, 30),
583 references: vec![],
584 members: vec![],
585 }];
586
587 let rules = RulesConfig::default();
588 let config = make_config_with_rules(rules);
589
590 let results_no_collect = find_dead_code_full(
592 &graph,
593 &config,
594 &[],
595 None,
596 &[],
597 &[],
598 false, );
600 assert!(
601 results_no_collect.export_usages.is_empty(),
602 "export_usages should be empty when collect_usages is false"
603 );
604
605 let results_with_collect = find_dead_code_full(
607 &graph,
608 &config,
609 &[],
610 None,
611 &[],
612 &[],
613 true, );
615 assert!(
616 !results_with_collect.export_usages.is_empty(),
617 "export_usages should be populated when collect_usages is true"
618 );
619 assert_eq!(
620 results_with_collect.export_usages[0].export_name,
621 "myExport"
622 );
623 }
624
625 #[test]
626 fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
627 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
628 use crate::graph::ModuleGraph;
629 use crate::resolve::ResolvedModule;
630 use rustc_hash::FxHashSet;
631
632 let files = vec![DiscoveredFile {
633 id: FileId(0),
634 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
635 size_bytes: 100,
636 }];
637 let entry_points = vec![EntryPoint {
638 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
639 source: EntryPointSource::ManualEntry,
640 }];
641 let resolved = vec![ResolvedModule {
642 file_id: FileId(0),
643 path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
644 exports: vec![],
645 re_exports: vec![],
646 resolved_imports: vec![],
647 resolved_dynamic_imports: vec![],
648 resolved_dynamic_patterns: vec![],
649 member_accesses: vec![],
650 whole_object_uses: vec![],
651 has_cjs_exports: false,
652 unused_import_bindings: FxHashSet::default(),
653 }];
654 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
655 let config = make_config_with_rules(RulesConfig::default());
656
657 let results = find_dead_code(&graph, &config);
659 assert!(results.unused_exports.is_empty());
661 }
662
663 #[test]
664 fn suppressions_built_from_modules() {
665 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
666 use crate::extract::ModuleInfo;
667 use crate::graph::ModuleGraph;
668 use crate::resolve::ResolvedModule;
669 use crate::suppress::{IssueKind, Suppression};
670 use rustc_hash::FxHashSet;
671
672 let files = vec![
673 DiscoveredFile {
674 id: FileId(0),
675 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
676 size_bytes: 100,
677 },
678 DiscoveredFile {
679 id: FileId(1),
680 path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
681 size_bytes: 100,
682 },
683 ];
684 let entry_points = vec![EntryPoint {
685 path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
686 source: EntryPointSource::ManualEntry,
687 }];
688 let resolved = files
689 .iter()
690 .map(|f| ResolvedModule {
691 file_id: f.id,
692 path: f.path.clone(),
693 exports: vec![],
694 re_exports: vec![],
695 resolved_imports: vec![],
696 resolved_dynamic_imports: vec![],
697 resolved_dynamic_patterns: vec![],
698 member_accesses: vec![],
699 whole_object_uses: vec![],
700 has_cjs_exports: false,
701 unused_import_bindings: FxHashSet::default(),
702 })
703 .collect::<Vec<_>>();
704 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
705
706 let modules = vec![ModuleInfo {
708 file_id: FileId(1),
709 exports: vec![],
710 imports: vec![],
711 re_exports: vec![],
712 dynamic_imports: vec![],
713 dynamic_import_patterns: vec![],
714 require_calls: vec![],
715 member_accesses: vec![],
716 whole_object_uses: vec![],
717 has_cjs_exports: false,
718 content_hash: 0,
719 suppressions: vec![Suppression {
720 line: 0,
721 comment_line: 1,
722 kind: Some(IssueKind::UnusedFile),
723 }],
724 unused_import_bindings: vec![],
725 line_offsets: vec![],
726 complexity: vec![],
727 flag_uses: vec![],
728 class_heritage: vec![],
729 }];
730
731 let rules = RulesConfig {
732 unused_files: Severity::Error,
733 ..RulesConfig::default()
734 };
735 let config = make_config_with_rules(rules);
736
737 let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
738
739 assert!(
744 !results
745 .unused_files
746 .iter()
747 .any(|f| f.path.to_string_lossy().contains("utils.ts")),
748 "suppressed file should not appear in unused_files"
749 );
750 }
751 }
752}