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