Skip to main content

fallow_core/analyze/
mod.rs

1mod boundary;
2pub mod feature_flags;
3mod package_json_utils;
4mod predicates;
5mod unused_catalog;
6mod unused_deps;
7mod unused_exports;
8mod unused_files;
9mod unused_members;
10
11use rustc_hash::FxHashMap;
12
13use fallow_config::{PackageJson, ResolvedConfig, Severity};
14
15use crate::discover::FileId;
16use crate::extract::ModuleInfo;
17use crate::graph::ModuleGraph;
18use crate::resolve::ResolvedModule;
19use crate::results::{AnalysisResults, CircularDependency};
20use crate::suppress::IssueKind;
21
22use unused_catalog::find_unused_catalog_entries;
23use unused_deps::{
24    find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
25    find_unresolved_imports, find_unused_dependencies,
26};
27use unused_exports::{
28    collect_export_usages, find_duplicate_exports, find_private_type_leaks, find_unused_exports,
29    suppress_signature_backing_types,
30};
31use unused_files::find_unused_files;
32use unused_members::find_unused_members;
33
34/// Pre-computed line offset tables indexed by `FileId`, built during parse and
35/// carried through the cache. Eliminates redundant file reads during analysis.
36pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
37
38/// Convert a byte offset to (line, col) using pre-computed line offsets.
39/// Falls back to `(1, byte_offset)` when no line table is available.
40pub fn byte_offset_to_line_col(
41    line_offsets_map: &LineOffsetsMap<'_>,
42    file_id: FileId,
43    byte_offset: u32,
44) -> (u32, u32) {
45    line_offsets_map
46        .get(&file_id)
47        .map_or((1, byte_offset), |offsets| {
48            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
49        })
50}
51
52fn cycle_edge_line_col(
53    graph: &ModuleGraph,
54    line_offsets_map: &LineOffsetsMap<'_>,
55    cycle: &[FileId],
56    edge_index: usize,
57) -> Option<(u32, u32)> {
58    if cycle.is_empty() {
59        return None;
60    }
61
62    let from = cycle[edge_index];
63    let to = cycle[(edge_index + 1) % cycle.len()];
64    graph
65        .find_import_span_start(from, to)
66        .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
67}
68
69fn is_circular_dependency_suppressed(
70    graph: &ModuleGraph,
71    line_offsets_map: &LineOffsetsMap<'_>,
72    suppressions: &crate::suppress::SuppressionContext<'_>,
73    cycle: &[FileId],
74) -> bool {
75    if cycle
76        .iter()
77        .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
78    {
79        return true;
80    }
81
82    let mut line_suppressed = false;
83    for edge_index in 0..cycle.len() {
84        let from = cycle[edge_index];
85        if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
86            && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
87        {
88            line_suppressed = true;
89        }
90    }
91    line_suppressed
92}
93
94/// Read source content from disk, returning empty string on failure.
95/// Only used for LSP Code Lens reference resolution where the referencing
96/// file may not be in the line offsets map.
97fn read_source(path: &std::path::Path) -> String {
98    std::fs::read_to_string(path).unwrap_or_default()
99}
100
101/// Check whether any two files in a cycle belong to different workspace packages.
102/// Uses longest-prefix-match to assign each file to a workspace root.
103/// Files outside all workspace roots (e.g., root-level shared code) are ignored —
104/// only cycles between two distinct named workspaces are flagged.
105fn is_cross_package_cycle(
106    files: &[std::path::PathBuf],
107    workspaces: &[fallow_config::WorkspaceInfo],
108) -> bool {
109    let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
110        workspaces
111            .iter()
112            .map(|w| w.root.as_path())
113            .filter(|root| path.starts_with(root))
114            .max_by_key(|root| root.components().count())
115    };
116
117    let mut seen_workspace: Option<&std::path::Path> = None;
118    for file in files {
119        if let Some(ws) = find_workspace(file) {
120            match &seen_workspace {
121                None => seen_workspace = Some(ws),
122                Some(prev) if *prev != ws => return true,
123                _ => {}
124            }
125        }
126    }
127    false
128}
129
130fn public_workspace_roots<'a>(
131    public_packages: &[String],
132    workspaces: &'a [fallow_config::WorkspaceInfo],
133) -> Vec<&'a std::path::Path> {
134    if public_packages.is_empty() || workspaces.is_empty() {
135        return Vec::new();
136    }
137
138    workspaces
139        .iter()
140        .filter(|ws| {
141            public_packages.iter().any(|pattern| {
142                ws.name == *pattern
143                    || globset::Glob::new(pattern)
144                        .ok()
145                        .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
146            })
147        })
148        .map(|ws| ws.root.as_path())
149        .collect()
150}
151
152fn find_circular_dependencies(
153    graph: &ModuleGraph,
154    line_offsets_map: &LineOffsetsMap<'_>,
155    suppressions: &crate::suppress::SuppressionContext<'_>,
156    workspaces: &[fallow_config::WorkspaceInfo],
157) -> Vec<CircularDependency> {
158    let cycles = graph.find_cycles();
159    let mut dependencies: Vec<CircularDependency> = cycles
160        .into_iter()
161        .filter_map(|cycle| {
162            if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
163                return None;
164            }
165
166            let files: Vec<std::path::PathBuf> = cycle
167                .iter()
168                .map(|&id| graph.modules[id.0 as usize].path.clone())
169                .collect();
170            let length = files.len();
171            // Look up the import span from cycle[0] -> cycle[1] for precise location.
172            let (line, col) =
173                cycle_edge_line_col(graph, line_offsets_map, &cycle, 0).unwrap_or((1, 0));
174            Some(CircularDependency {
175                files,
176                length,
177                line,
178                col,
179                is_cross_package: false,
180            })
181        })
182        .collect();
183
184    // Mark cycles that cross workspace package boundaries.
185    if !workspaces.is_empty() {
186        for dep in &mut dependencies {
187            dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
188        }
189    }
190
191    dependencies
192}
193
194/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
195#[expect(
196    clippy::too_many_lines,
197    reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
198)]
199pub fn find_dead_code_full(
200    graph: &ModuleGraph,
201    config: &ResolvedConfig,
202    resolved_modules: &[ResolvedModule],
203    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
204    workspaces: &[fallow_config::WorkspaceInfo],
205    modules: &[ModuleInfo],
206    collect_usages: bool,
207) -> AnalysisResults {
208    let _span = tracing::info_span!("find_dead_code").entered();
209
210    // Build suppression context: tracks which suppressions are consumed by detectors
211    let suppressions = crate::suppress::SuppressionContext::new(modules);
212
213    // Build line offset index: FileId -> pre-computed line start offsets.
214    // Eliminates redundant file reads for byte-to-line/col conversion.
215    let line_offsets_by_file: LineOffsetsMap<'_> = modules
216        .iter()
217        .filter(|m| !m.line_offsets.is_empty())
218        .map(|m| (m.file_id, m.line_offsets.as_slice()))
219        .collect();
220
221    // Build merged dependency set from root + all workspace package.json files
222    let pkg_path = config.root.join("package.json");
223    let pkg = PackageJson::load(&pkg_path).ok();
224
225    // Merge the top-level config rules with any plugin-contributed rules.
226    // Plain string entries behave like the old global allowlist; scoped object
227    // entries only apply to classes that match `extends` / `implements`.
228    let mut user_class_members = config.used_class_members.clone();
229    if let Some(plugin_result) = plugin_result {
230        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
231    }
232
233    let virtual_prefixes: Vec<&str> = plugin_result
234        .map(|pr| {
235            pr.virtual_module_prefixes
236                .iter()
237                .map(String::as_str)
238                .collect()
239        })
240        .unwrap_or_default();
241    let generated_patterns: Vec<&str> = plugin_result
242        .map(|pr| {
243            pr.generated_import_patterns
244                .iter()
245                .map(String::as_str)
246                .collect()
247        })
248        .unwrap_or_default();
249
250    let (
251        (unused_files, export_results),
252        (
253            (member_results, dependency_results),
254            (
255                (unresolved_imports, duplicate_exports),
256                (boundary_violations, (circular_dependencies, export_usages)),
257            ),
258        ),
259    ) = rayon::join(
260        || {
261            rayon::join(
262                || {
263                    if config.rules.unused_files != Severity::Off {
264                        find_unused_files(graph, &suppressions)
265                    } else {
266                        Vec::new()
267                    }
268                },
269                || {
270                    let mut results = AnalysisResults::default();
271                    if config.rules.unused_exports != Severity::Off
272                        || config.rules.unused_types != Severity::Off
273                        || config.rules.private_type_leaks != Severity::Off
274                    {
275                        let (exports, types, stale_expected) = find_unused_exports(
276                            graph,
277                            modules,
278                            config,
279                            plugin_result,
280                            &suppressions,
281                            &line_offsets_by_file,
282                        );
283                        if config.rules.unused_exports != Severity::Off {
284                            results.unused_exports = exports;
285                        }
286                        if config.rules.unused_types != Severity::Off {
287                            results.unused_types = types;
288                            suppress_signature_backing_types(
289                                &mut results.unused_types,
290                                graph,
291                                modules,
292                            );
293                        }
294                        if config.rules.private_type_leaks != Severity::Off {
295                            results.private_type_leaks = find_private_type_leaks(
296                                graph,
297                                modules,
298                                config,
299                                &suppressions,
300                                &line_offsets_by_file,
301                            );
302                        }
303                        // @expected-unused tags that became stale (export is now used).
304                        if config.rules.stale_suppressions != Severity::Off {
305                            results.stale_suppressions.extend(stale_expected);
306                        }
307                    }
308                    results
309                },
310            )
311        },
312        || {
313            rayon::join(
314                || {
315                    rayon::join(
316                        || {
317                            let mut results = AnalysisResults::default();
318                            if config.rules.unused_enum_members != Severity::Off
319                                || config.rules.unused_class_members != Severity::Off
320                            {
321                                let (enum_members, class_members) = find_unused_members(
322                                    graph,
323                                    resolved_modules,
324                                    modules,
325                                    &suppressions,
326                                    &line_offsets_by_file,
327                                    &user_class_members,
328                                );
329                                if config.rules.unused_enum_members != Severity::Off {
330                                    results.unused_enum_members = enum_members;
331                                }
332                                if config.rules.unused_class_members != Severity::Off {
333                                    results.unused_class_members = class_members;
334                                }
335                            }
336                            results
337                        },
338                        || {
339                            let mut results = AnalysisResults::default();
340                            if let Some(ref pkg) = pkg {
341                                if config.rules.unused_dependencies != Severity::Off
342                                    || config.rules.unused_dev_dependencies != Severity::Off
343                                    || config.rules.unused_optional_dependencies != Severity::Off
344                                {
345                                    let (deps, dev_deps, optional_deps) = find_unused_dependencies(
346                                        graph,
347                                        pkg,
348                                        config,
349                                        plugin_result,
350                                        workspaces,
351                                    );
352                                    if config.rules.unused_dependencies != Severity::Off {
353                                        results.unused_dependencies = deps;
354                                    }
355                                    if config.rules.unused_dev_dependencies != Severity::Off {
356                                        results.unused_dev_dependencies = dev_deps;
357                                    }
358                                    if config.rules.unused_optional_dependencies != Severity::Off {
359                                        results.unused_optional_dependencies = optional_deps;
360                                    }
361                                }
362
363                                if config.rules.unlisted_dependencies != Severity::Off {
364                                    results.unlisted_dependencies = find_unlisted_dependencies(
365                                        graph,
366                                        pkg,
367                                        config,
368                                        workspaces,
369                                        plugin_result,
370                                        resolved_modules,
371                                        &line_offsets_by_file,
372                                    );
373                                }
374
375                                // In production mode, detect dependencies that are only used via
376                                // type-only imports.
377                                if config.production {
378                                    results.type_only_dependencies =
379                                        find_type_only_dependencies(graph, pkg, config, workspaces);
380                                }
381
382                                // In non-production mode, detect production deps only imported by
383                                // test/dev files.
384                                if !config.production
385                                    && config.rules.test_only_dependencies != Severity::Off
386                                {
387                                    results.test_only_dependencies =
388                                        find_test_only_dependencies(graph, pkg, config, workspaces);
389                                }
390                            }
391                            results
392                        },
393                    )
394                },
395                || {
396                    rayon::join(
397                        || {
398                            rayon::join(
399                                || {
400                                    if config.rules.unresolved_imports != Severity::Off
401                                        && !resolved_modules.is_empty()
402                                    {
403                                        find_unresolved_imports(
404                                            resolved_modules,
405                                            config,
406                                            &suppressions,
407                                            &virtual_prefixes,
408                                            &generated_patterns,
409                                            &line_offsets_by_file,
410                                        )
411                                    } else {
412                                        Vec::new()
413                                    }
414                                },
415                                || {
416                                    if config.rules.duplicate_exports != Severity::Off {
417                                        find_duplicate_exports(
418                                            graph,
419                                            config,
420                                            &suppressions,
421                                            &line_offsets_by_file,
422                                            resolved_modules,
423                                        )
424                                    } else {
425                                        Vec::new()
426                                    }
427                                },
428                            )
429                        },
430                        || {
431                            rayon::join(
432                                || {
433                                    if config.rules.boundary_violation != Severity::Off
434                                        && !config.boundaries.is_empty()
435                                    {
436                                        boundary::find_boundary_violations(
437                                            graph,
438                                            config,
439                                            &suppressions,
440                                            &line_offsets_by_file,
441                                        )
442                                    } else {
443                                        Vec::new()
444                                    }
445                                },
446                                || {
447                                    rayon::join(
448                                        || {
449                                            if config.rules.circular_dependencies != Severity::Off {
450                                                find_circular_dependencies(
451                                                    graph,
452                                                    &line_offsets_by_file,
453                                                    &suppressions,
454                                                    workspaces,
455                                                )
456                                            } else {
457                                                Vec::new()
458                                            }
459                                        },
460                                        || {
461                                            // Collect export usage counts for Code Lens (LSP
462                                            // feature). Skipped in CLI mode since the field is
463                                            // #[serde(skip)] in all output formats.
464                                            if collect_usages {
465                                                collect_export_usages(graph, &line_offsets_by_file)
466                                            } else {
467                                                Vec::new()
468                                            }
469                                        },
470                                    )
471                                },
472                            )
473                        },
474                    )
475                },
476            )
477        },
478    );
479
480    let mut results = AnalysisResults {
481        unused_files,
482        unused_exports: export_results.unused_exports,
483        unused_types: export_results.unused_types,
484        private_type_leaks: export_results.private_type_leaks,
485        stale_suppressions: export_results.stale_suppressions,
486        unused_enum_members: member_results.unused_enum_members,
487        unused_class_members: member_results.unused_class_members,
488        unused_dependencies: dependency_results.unused_dependencies,
489        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
490        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
491        unlisted_dependencies: dependency_results.unlisted_dependencies,
492        type_only_dependencies: dependency_results.type_only_dependencies,
493        test_only_dependencies: dependency_results.test_only_dependencies,
494        unresolved_imports,
495        duplicate_exports,
496        boundary_violations,
497        circular_dependencies,
498        export_usages,
499        ..AnalysisResults::default()
500    };
501
502    // Filter out exported API surface from public packages.
503    // Public packages are workspace packages whose exports are intended for external consumers.
504    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
505    if !public_roots.is_empty() {
506        results
507            .unused_exports
508            .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
509        results
510            .unused_types
511            .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
512        results
513            .unused_enum_members
514            .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
515        results
516            .unused_class_members
517            .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
518    }
519
520    // Detect stale suppression comments (must run after all detectors)
521    if config.rules.stale_suppressions != Severity::Off {
522        results
523            .stale_suppressions
524            .extend(suppressions.find_stale(graph));
525    }
526    results.suppression_count = suppressions.used_count();
527
528    // Detect unused pnpm catalog entries (purely off package.json + pnpm-workspace.yaml).
529    if config.rules.unused_catalog_entries != Severity::Off {
530        results.unused_catalog_entries = find_unused_catalog_entries(config, workspaces);
531    }
532
533    // Sort all result arrays for deterministic output ordering.
534    // Parallel collection and FxHashMap iteration don't guarantee order,
535    // so without sorting the same project can produce different orderings.
536    results.sort();
537
538    results
539}
540
541#[cfg(test)]
542mod tests {
543    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
544
545    // Helper: compute line offsets from source and convert byte offset
546    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
547        let offsets = compute_line_offsets(source);
548        byte_offset_to_line_col(&offsets, byte_offset)
549    }
550
551    // ── compute_line_offsets ─────────────────────────────────────
552
553    #[test]
554    fn compute_offsets_empty() {
555        assert_eq!(compute_line_offsets(""), vec![0]);
556    }
557
558    #[test]
559    fn compute_offsets_single_line() {
560        assert_eq!(compute_line_offsets("hello"), vec![0]);
561    }
562
563    #[test]
564    fn compute_offsets_multiline() {
565        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
566    }
567
568    #[test]
569    fn compute_offsets_trailing_newline() {
570        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
571    }
572
573    #[test]
574    fn compute_offsets_crlf() {
575        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
576    }
577
578    #[test]
579    fn compute_offsets_consecutive_newlines() {
580        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
581    }
582
583    // ── byte_offset_to_line_col ─────────────────────────────────
584
585    #[test]
586    fn byte_offset_empty_source() {
587        assert_eq!(line_col("", 0), (1, 0));
588    }
589
590    #[test]
591    fn byte_offset_single_line_start() {
592        assert_eq!(line_col("hello", 0), (1, 0));
593    }
594
595    #[test]
596    fn byte_offset_single_line_middle() {
597        assert_eq!(line_col("hello", 4), (1, 4));
598    }
599
600    #[test]
601    fn byte_offset_multiline_start_of_line2() {
602        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
603    }
604
605    #[test]
606    fn byte_offset_multiline_middle_of_line3() {
607        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
608    }
609
610    #[test]
611    fn byte_offset_at_newline_boundary() {
612        assert_eq!(line_col("line1\nline2", 5), (1, 5));
613    }
614
615    #[test]
616    fn byte_offset_multibyte_utf8() {
617        let source = "hi\n\u{1F600}x";
618        assert_eq!(line_col(source, 3), (2, 0));
619        assert_eq!(line_col(source, 7), (2, 4));
620    }
621
622    #[test]
623    fn byte_offset_multibyte_accented_chars() {
624        let source = "caf\u{00E9}\nbar";
625        assert_eq!(line_col(source, 6), (2, 0));
626        assert_eq!(line_col(source, 3), (1, 3));
627    }
628
629    #[test]
630    fn byte_offset_via_map_fallback() {
631        use super::*;
632        let map: LineOffsetsMap<'_> = FxHashMap::default();
633        assert_eq!(
634            super::byte_offset_to_line_col(&map, FileId(99), 42),
635            (1, 42)
636        );
637    }
638
639    #[test]
640    fn byte_offset_via_map_lookup() {
641        use super::*;
642        let offsets = compute_line_offsets("abc\ndef\nghi");
643        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
644        map.insert(FileId(0), &offsets);
645        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
646    }
647
648    // ── find_dead_code orchestration ──────────────────────────────
649
650    mod orchestration {
651        use super::super::*;
652        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
653        use std::path::PathBuf;
654
655        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
656            find_dead_code_full(graph, config, &[], None, &[], &[], false)
657        }
658
659        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
660            FallowConfig {
661                rules,
662                ..Default::default()
663            }
664            .resolve(
665                PathBuf::from("/tmp/orchestration-test"),
666                OutputFormat::Human,
667                1,
668                true,
669                true,
670            )
671        }
672
673        #[test]
674        fn find_dead_code_all_rules_off_returns_empty() {
675            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
676            use crate::graph::ModuleGraph;
677            use crate::resolve::ResolvedModule;
678            use rustc_hash::FxHashSet;
679
680            let files = vec![DiscoveredFile {
681                id: FileId(0),
682                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
683                size_bytes: 100,
684            }];
685            let entry_points = vec![EntryPoint {
686                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
687                source: EntryPointSource::ManualEntry,
688            }];
689            let resolved = vec![ResolvedModule {
690                file_id: FileId(0),
691                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
692                exports: vec![],
693                re_exports: vec![],
694                resolved_imports: vec![],
695                resolved_dynamic_imports: vec![],
696                resolved_dynamic_patterns: vec![],
697                member_accesses: vec![],
698                whole_object_uses: vec![],
699                has_cjs_exports: false,
700                unused_import_bindings: FxHashSet::default(),
701                type_referenced_import_bindings: vec![],
702                value_referenced_import_bindings: vec![],
703                namespace_object_aliases: vec![],
704            }];
705            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
706
707            let rules = RulesConfig {
708                unused_files: Severity::Off,
709                unused_exports: Severity::Off,
710                unused_types: Severity::Off,
711                private_type_leaks: Severity::Off,
712                unused_dependencies: Severity::Off,
713                unused_dev_dependencies: Severity::Off,
714                unused_optional_dependencies: Severity::Off,
715                unused_enum_members: Severity::Off,
716                unused_class_members: Severity::Off,
717                unresolved_imports: Severity::Off,
718                unlisted_dependencies: Severity::Off,
719                duplicate_exports: Severity::Off,
720                type_only_dependencies: Severity::Off,
721                circular_dependencies: Severity::Off,
722                test_only_dependencies: Severity::Off,
723                boundary_violation: Severity::Off,
724                coverage_gaps: Severity::Off,
725                feature_flags: Severity::Off,
726                stale_suppressions: Severity::Off,
727                unused_catalog_entries: Severity::Off,
728            };
729            let config = make_config_with_rules(rules);
730            let results = find_dead_code(&graph, &config);
731
732            assert!(results.unused_files.is_empty());
733            assert!(results.unused_exports.is_empty());
734            assert!(results.unused_types.is_empty());
735            assert!(results.unused_dependencies.is_empty());
736            assert!(results.unused_dev_dependencies.is_empty());
737            assert!(results.unused_optional_dependencies.is_empty());
738            assert!(results.unused_enum_members.is_empty());
739            assert!(results.unused_class_members.is_empty());
740            assert!(results.unresolved_imports.is_empty());
741            assert!(results.unlisted_dependencies.is_empty());
742            assert!(results.duplicate_exports.is_empty());
743            assert!(results.circular_dependencies.is_empty());
744            assert!(results.export_usages.is_empty());
745        }
746
747        #[test]
748        fn find_dead_code_full_collect_usages_flag() {
749            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
750            use crate::extract::{ExportName, VisibilityTag};
751            use crate::graph::{ExportSymbol, ModuleGraph};
752            use crate::resolve::ResolvedModule;
753            use oxc_span::Span;
754            use rustc_hash::FxHashSet;
755
756            let files = vec![DiscoveredFile {
757                id: FileId(0),
758                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
759                size_bytes: 100,
760            }];
761            let entry_points = vec![EntryPoint {
762                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
763                source: EntryPointSource::ManualEntry,
764            }];
765            let resolved = vec![ResolvedModule {
766                file_id: FileId(0),
767                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
768                exports: vec![],
769                re_exports: vec![],
770                resolved_imports: vec![],
771                resolved_dynamic_imports: vec![],
772                resolved_dynamic_patterns: vec![],
773                member_accesses: vec![],
774                whole_object_uses: vec![],
775                has_cjs_exports: false,
776                unused_import_bindings: FxHashSet::default(),
777                type_referenced_import_bindings: vec![],
778                value_referenced_import_bindings: vec![],
779                namespace_object_aliases: vec![],
780            }];
781            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
782            graph.modules[0].exports = vec![ExportSymbol {
783                name: ExportName::Named("myExport".to_string()),
784                is_type_only: false,
785                is_side_effect_used: false,
786                visibility: VisibilityTag::None,
787                span: Span::new(10, 30),
788                references: vec![],
789                members: vec![],
790            }];
791
792            let rules = RulesConfig::default();
793            let config = make_config_with_rules(rules);
794
795            // Without collect_usages
796            let results_no_collect = find_dead_code_full(
797                &graph,
798                &config,
799                &[],
800                None,
801                &[],
802                &[],
803                false, // collect_usages = false
804            );
805            assert!(
806                results_no_collect.export_usages.is_empty(),
807                "export_usages should be empty when collect_usages is false"
808            );
809
810            // With collect_usages
811            let results_with_collect = find_dead_code_full(
812                &graph,
813                &config,
814                &[],
815                None,
816                &[],
817                &[],
818                true, // collect_usages = true
819            );
820            assert!(
821                !results_with_collect.export_usages.is_empty(),
822                "export_usages should be populated when collect_usages is true"
823            );
824            assert_eq!(
825                results_with_collect.export_usages[0].export_name,
826                "myExport"
827            );
828        }
829
830        #[test]
831        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
832            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
833            use crate::graph::ModuleGraph;
834            use crate::resolve::ResolvedModule;
835            use rustc_hash::FxHashSet;
836
837            let files = vec![DiscoveredFile {
838                id: FileId(0),
839                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
840                size_bytes: 100,
841            }];
842            let entry_points = vec![EntryPoint {
843                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
844                source: EntryPointSource::ManualEntry,
845            }];
846            let resolved = vec![ResolvedModule {
847                file_id: FileId(0),
848                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
849                exports: vec![],
850                re_exports: vec![],
851                resolved_imports: vec![],
852                resolved_dynamic_imports: vec![],
853                resolved_dynamic_patterns: vec![],
854                member_accesses: vec![],
855                whole_object_uses: vec![],
856                has_cjs_exports: false,
857                unused_import_bindings: FxHashSet::default(),
858                type_referenced_import_bindings: vec![],
859                value_referenced_import_bindings: vec![],
860                namespace_object_aliases: vec![],
861            }];
862            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
863            let config = make_config_with_rules(RulesConfig::default());
864
865            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
866            let results = find_dead_code(&graph, &config);
867            // The entry point export analysis is skipped, so these should be empty
868            assert!(results.unused_exports.is_empty());
869        }
870
871        #[test]
872        fn suppressions_built_from_modules() {
873            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
874            use crate::extract::ModuleInfo;
875            use crate::graph::ModuleGraph;
876            use crate::resolve::ResolvedModule;
877            use crate::suppress::{IssueKind, Suppression};
878            use rustc_hash::FxHashSet;
879
880            let files = vec![
881                DiscoveredFile {
882                    id: FileId(0),
883                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
884                    size_bytes: 100,
885                },
886                DiscoveredFile {
887                    id: FileId(1),
888                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
889                    size_bytes: 100,
890                },
891            ];
892            let entry_points = vec![EntryPoint {
893                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
894                source: EntryPointSource::ManualEntry,
895            }];
896            let resolved = files
897                .iter()
898                .map(|f| ResolvedModule {
899                    file_id: f.id,
900                    path: f.path.clone(),
901                    exports: vec![],
902                    re_exports: vec![],
903                    resolved_imports: vec![],
904                    resolved_dynamic_imports: vec![],
905                    resolved_dynamic_patterns: vec![],
906                    member_accesses: vec![],
907                    whole_object_uses: vec![],
908                    has_cjs_exports: false,
909                    unused_import_bindings: FxHashSet::default(),
910                    type_referenced_import_bindings: vec![],
911                    value_referenced_import_bindings: vec![],
912                    namespace_object_aliases: vec![],
913                })
914                .collect::<Vec<_>>();
915            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
916
917            // Create module info with a file-level suppression for unused files
918            let modules = vec![ModuleInfo {
919                file_id: FileId(1),
920                exports: vec![],
921                imports: vec![],
922                re_exports: vec![],
923                dynamic_imports: vec![],
924                dynamic_import_patterns: vec![],
925                require_calls: vec![],
926                member_accesses: vec![],
927                whole_object_uses: vec![],
928                has_cjs_exports: false,
929                content_hash: 0,
930                suppressions: vec![Suppression {
931                    line: 0,
932                    comment_line: 1,
933                    kind: Some(IssueKind::UnusedFile),
934                }],
935                unused_import_bindings: vec![],
936                type_referenced_import_bindings: vec![],
937                value_referenced_import_bindings: vec![],
938                line_offsets: vec![],
939                complexity: vec![],
940                flag_uses: vec![],
941                class_heritage: vec![],
942                local_type_declarations: Vec::new(),
943                public_signature_type_references: Vec::new(),
944                namespace_object_aliases: Vec::new(),
945            }];
946
947            let rules = RulesConfig {
948                unused_files: Severity::Error,
949                ..RulesConfig::default()
950            };
951            let config = make_config_with_rules(rules);
952
953            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
954
955            // The suppression should prevent utils.ts from being reported as unused
956            // (it would normally be unused since only entry.ts is an entry point).
957            // Note: unused_files also checks if the file exists on disk, so it
958            // may still be filtered out. The key is the suppression path is exercised.
959            assert!(
960                !results
961                    .unused_files
962                    .iter()
963                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
964                "suppressed file should not appear in unused_files"
965            );
966        }
967    }
968}