Skip to main content

fallow_core/analyze/
mod.rs

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