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