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                                            resolved_modules,
398                                        )
399                                    } else {
400                                        Vec::new()
401                                    }
402                                },
403                            )
404                        },
405                        || {
406                            rayon::join(
407                                || {
408                                    if config.rules.boundary_violation != Severity::Off
409                                        && !config.boundaries.is_empty()
410                                    {
411                                        boundary::find_boundary_violations(
412                                            graph,
413                                            config,
414                                            &suppressions,
415                                            &line_offsets_by_file,
416                                        )
417                                    } else {
418                                        Vec::new()
419                                    }
420                                },
421                                || {
422                                    rayon::join(
423                                        || {
424                                            if config.rules.circular_dependencies != Severity::Off {
425                                                find_circular_dependencies(
426                                                    graph,
427                                                    &line_offsets_by_file,
428                                                    &suppressions,
429                                                    workspaces,
430                                                )
431                                            } else {
432                                                Vec::new()
433                                            }
434                                        },
435                                        || {
436                                            // Collect export usage counts for Code Lens (LSP
437                                            // feature). Skipped in CLI mode since the field is
438                                            // #[serde(skip)] in all output formats.
439                                            if collect_usages {
440                                                collect_export_usages(graph, &line_offsets_by_file)
441                                            } else {
442                                                Vec::new()
443                                            }
444                                        },
445                                    )
446                                },
447                            )
448                        },
449                    )
450                },
451            )
452        },
453    );
454
455    let mut results = AnalysisResults {
456        unused_files,
457        unused_exports: export_results.unused_exports,
458        unused_types: export_results.unused_types,
459        private_type_leaks: export_results.private_type_leaks,
460        stale_suppressions: export_results.stale_suppressions,
461        unused_enum_members: member_results.unused_enum_members,
462        unused_class_members: member_results.unused_class_members,
463        unused_dependencies: dependency_results.unused_dependencies,
464        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
465        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
466        unlisted_dependencies: dependency_results.unlisted_dependencies,
467        type_only_dependencies: dependency_results.type_only_dependencies,
468        test_only_dependencies: dependency_results.test_only_dependencies,
469        unresolved_imports,
470        duplicate_exports,
471        boundary_violations,
472        circular_dependencies,
473        export_usages,
474        ..AnalysisResults::default()
475    };
476
477    // Filter out unused exports/types from public packages.
478    // Public packages are workspace packages whose exports are intended for external consumers.
479    if !config.public_packages.is_empty() && !workspaces.is_empty() {
480        let public_roots: Vec<&std::path::Path> = workspaces
481            .iter()
482            .filter(|ws| {
483                config.public_packages.iter().any(|pattern| {
484                    ws.name == *pattern
485                        || globset::Glob::new(pattern)
486                            .ok()
487                            .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
488                })
489            })
490            .map(|ws| ws.root.as_path())
491            .collect();
492
493        if !public_roots.is_empty() {
494            results
495                .unused_exports
496                .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
497            results
498                .unused_types
499                .retain(|e| !public_roots.iter().any(|root| e.path.starts_with(root)));
500        }
501    }
502
503    // Detect stale suppression comments (must run after all detectors)
504    if config.rules.stale_suppressions != Severity::Off {
505        results
506            .stale_suppressions
507            .extend(suppressions.find_stale(graph));
508    }
509    results.suppression_count = suppressions.used_count();
510
511    // Sort all result arrays for deterministic output ordering.
512    // Parallel collection and FxHashMap iteration don't guarantee order,
513    // so without sorting the same project can produce different orderings.
514    results.sort();
515
516    results
517}
518
519#[cfg(test)]
520mod tests {
521    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
522
523    // Helper: compute line offsets from source and convert byte offset
524    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
525        let offsets = compute_line_offsets(source);
526        byte_offset_to_line_col(&offsets, byte_offset)
527    }
528
529    // ── compute_line_offsets ─────────────────────────────────────
530
531    #[test]
532    fn compute_offsets_empty() {
533        assert_eq!(compute_line_offsets(""), vec![0]);
534    }
535
536    #[test]
537    fn compute_offsets_single_line() {
538        assert_eq!(compute_line_offsets("hello"), vec![0]);
539    }
540
541    #[test]
542    fn compute_offsets_multiline() {
543        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
544    }
545
546    #[test]
547    fn compute_offsets_trailing_newline() {
548        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
549    }
550
551    #[test]
552    fn compute_offsets_crlf() {
553        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
554    }
555
556    #[test]
557    fn compute_offsets_consecutive_newlines() {
558        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
559    }
560
561    // ── byte_offset_to_line_col ─────────────────────────────────
562
563    #[test]
564    fn byte_offset_empty_source() {
565        assert_eq!(line_col("", 0), (1, 0));
566    }
567
568    #[test]
569    fn byte_offset_single_line_start() {
570        assert_eq!(line_col("hello", 0), (1, 0));
571    }
572
573    #[test]
574    fn byte_offset_single_line_middle() {
575        assert_eq!(line_col("hello", 4), (1, 4));
576    }
577
578    #[test]
579    fn byte_offset_multiline_start_of_line2() {
580        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
581    }
582
583    #[test]
584    fn byte_offset_multiline_middle_of_line3() {
585        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
586    }
587
588    #[test]
589    fn byte_offset_at_newline_boundary() {
590        assert_eq!(line_col("line1\nline2", 5), (1, 5));
591    }
592
593    #[test]
594    fn byte_offset_multibyte_utf8() {
595        let source = "hi\n\u{1F600}x";
596        assert_eq!(line_col(source, 3), (2, 0));
597        assert_eq!(line_col(source, 7), (2, 4));
598    }
599
600    #[test]
601    fn byte_offset_multibyte_accented_chars() {
602        let source = "caf\u{00E9}\nbar";
603        assert_eq!(line_col(source, 6), (2, 0));
604        assert_eq!(line_col(source, 3), (1, 3));
605    }
606
607    #[test]
608    fn byte_offset_via_map_fallback() {
609        use super::*;
610        let map: LineOffsetsMap<'_> = FxHashMap::default();
611        assert_eq!(
612            super::byte_offset_to_line_col(&map, FileId(99), 42),
613            (1, 42)
614        );
615    }
616
617    #[test]
618    fn byte_offset_via_map_lookup() {
619        use super::*;
620        let offsets = compute_line_offsets("abc\ndef\nghi");
621        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
622        map.insert(FileId(0), &offsets);
623        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
624    }
625
626    // ── find_dead_code orchestration ──────────────────────────────
627
628    mod orchestration {
629        use super::super::*;
630        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
631        use std::path::PathBuf;
632
633        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
634            find_dead_code_full(graph, config, &[], None, &[], &[], false)
635        }
636
637        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
638            FallowConfig {
639                rules,
640                ..Default::default()
641            }
642            .resolve(
643                PathBuf::from("/tmp/orchestration-test"),
644                OutputFormat::Human,
645                1,
646                true,
647                true,
648            )
649        }
650
651        #[test]
652        fn find_dead_code_all_rules_off_returns_empty() {
653            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
654            use crate::graph::ModuleGraph;
655            use crate::resolve::ResolvedModule;
656            use rustc_hash::FxHashSet;
657
658            let files = vec![DiscoveredFile {
659                id: FileId(0),
660                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
661                size_bytes: 100,
662            }];
663            let entry_points = vec![EntryPoint {
664                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
665                source: EntryPointSource::ManualEntry,
666            }];
667            let resolved = vec![ResolvedModule {
668                file_id: FileId(0),
669                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
670                exports: vec![],
671                re_exports: vec![],
672                resolved_imports: vec![],
673                resolved_dynamic_imports: vec![],
674                resolved_dynamic_patterns: vec![],
675                member_accesses: vec![],
676                whole_object_uses: vec![],
677                has_cjs_exports: false,
678                unused_import_bindings: FxHashSet::default(),
679                type_referenced_import_bindings: vec![],
680                value_referenced_import_bindings: vec![],
681            }];
682            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
683
684            let rules = RulesConfig {
685                unused_files: Severity::Off,
686                unused_exports: Severity::Off,
687                unused_types: Severity::Off,
688                private_type_leaks: Severity::Off,
689                unused_dependencies: Severity::Off,
690                unused_dev_dependencies: Severity::Off,
691                unused_optional_dependencies: Severity::Off,
692                unused_enum_members: Severity::Off,
693                unused_class_members: Severity::Off,
694                unresolved_imports: Severity::Off,
695                unlisted_dependencies: Severity::Off,
696                duplicate_exports: Severity::Off,
697                type_only_dependencies: Severity::Off,
698                circular_dependencies: Severity::Off,
699                test_only_dependencies: Severity::Off,
700                boundary_violation: Severity::Off,
701                coverage_gaps: Severity::Off,
702                feature_flags: Severity::Off,
703                stale_suppressions: Severity::Off,
704            };
705            let config = make_config_with_rules(rules);
706            let results = find_dead_code(&graph, &config);
707
708            assert!(results.unused_files.is_empty());
709            assert!(results.unused_exports.is_empty());
710            assert!(results.unused_types.is_empty());
711            assert!(results.unused_dependencies.is_empty());
712            assert!(results.unused_dev_dependencies.is_empty());
713            assert!(results.unused_optional_dependencies.is_empty());
714            assert!(results.unused_enum_members.is_empty());
715            assert!(results.unused_class_members.is_empty());
716            assert!(results.unresolved_imports.is_empty());
717            assert!(results.unlisted_dependencies.is_empty());
718            assert!(results.duplicate_exports.is_empty());
719            assert!(results.circular_dependencies.is_empty());
720            assert!(results.export_usages.is_empty());
721        }
722
723        #[test]
724        fn find_dead_code_full_collect_usages_flag() {
725            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
726            use crate::extract::{ExportName, VisibilityTag};
727            use crate::graph::{ExportSymbol, ModuleGraph};
728            use crate::resolve::ResolvedModule;
729            use oxc_span::Span;
730            use rustc_hash::FxHashSet;
731
732            let files = vec![DiscoveredFile {
733                id: FileId(0),
734                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
735                size_bytes: 100,
736            }];
737            let entry_points = vec![EntryPoint {
738                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
739                source: EntryPointSource::ManualEntry,
740            }];
741            let resolved = vec![ResolvedModule {
742                file_id: FileId(0),
743                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
744                exports: vec![],
745                re_exports: vec![],
746                resolved_imports: vec![],
747                resolved_dynamic_imports: vec![],
748                resolved_dynamic_patterns: vec![],
749                member_accesses: vec![],
750                whole_object_uses: vec![],
751                has_cjs_exports: false,
752                unused_import_bindings: FxHashSet::default(),
753                type_referenced_import_bindings: vec![],
754                value_referenced_import_bindings: vec![],
755            }];
756            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
757            graph.modules[0].exports = vec![ExportSymbol {
758                name: ExportName::Named("myExport".to_string()),
759                is_type_only: false,
760                visibility: VisibilityTag::None,
761                span: Span::new(10, 30),
762                references: vec![],
763                members: vec![],
764            }];
765
766            let rules = RulesConfig::default();
767            let config = make_config_with_rules(rules);
768
769            // Without collect_usages
770            let results_no_collect = find_dead_code_full(
771                &graph,
772                &config,
773                &[],
774                None,
775                &[],
776                &[],
777                false, // collect_usages = false
778            );
779            assert!(
780                results_no_collect.export_usages.is_empty(),
781                "export_usages should be empty when collect_usages is false"
782            );
783
784            // With collect_usages
785            let results_with_collect = find_dead_code_full(
786                &graph,
787                &config,
788                &[],
789                None,
790                &[],
791                &[],
792                true, // collect_usages = true
793            );
794            assert!(
795                !results_with_collect.export_usages.is_empty(),
796                "export_usages should be populated when collect_usages is true"
797            );
798            assert_eq!(
799                results_with_collect.export_usages[0].export_name,
800                "myExport"
801            );
802        }
803
804        #[test]
805        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
806            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
807            use crate::graph::ModuleGraph;
808            use crate::resolve::ResolvedModule;
809            use rustc_hash::FxHashSet;
810
811            let files = vec![DiscoveredFile {
812                id: FileId(0),
813                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
814                size_bytes: 100,
815            }];
816            let entry_points = vec![EntryPoint {
817                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
818                source: EntryPointSource::ManualEntry,
819            }];
820            let resolved = vec![ResolvedModule {
821                file_id: FileId(0),
822                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
823                exports: vec![],
824                re_exports: vec![],
825                resolved_imports: vec![],
826                resolved_dynamic_imports: vec![],
827                resolved_dynamic_patterns: vec![],
828                member_accesses: vec![],
829                whole_object_uses: vec![],
830                has_cjs_exports: false,
831                unused_import_bindings: FxHashSet::default(),
832                type_referenced_import_bindings: vec![],
833                value_referenced_import_bindings: vec![],
834            }];
835            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
836            let config = make_config_with_rules(RulesConfig::default());
837
838            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
839            let results = find_dead_code(&graph, &config);
840            // The entry point export analysis is skipped, so these should be empty
841            assert!(results.unused_exports.is_empty());
842        }
843
844        #[test]
845        fn suppressions_built_from_modules() {
846            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
847            use crate::extract::ModuleInfo;
848            use crate::graph::ModuleGraph;
849            use crate::resolve::ResolvedModule;
850            use crate::suppress::{IssueKind, Suppression};
851            use rustc_hash::FxHashSet;
852
853            let files = vec![
854                DiscoveredFile {
855                    id: FileId(0),
856                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
857                    size_bytes: 100,
858                },
859                DiscoveredFile {
860                    id: FileId(1),
861                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
862                    size_bytes: 100,
863                },
864            ];
865            let entry_points = vec![EntryPoint {
866                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
867                source: EntryPointSource::ManualEntry,
868            }];
869            let resolved = files
870                .iter()
871                .map(|f| ResolvedModule {
872                    file_id: f.id,
873                    path: f.path.clone(),
874                    exports: vec![],
875                    re_exports: vec![],
876                    resolved_imports: vec![],
877                    resolved_dynamic_imports: vec![],
878                    resolved_dynamic_patterns: vec![],
879                    member_accesses: vec![],
880                    whole_object_uses: vec![],
881                    has_cjs_exports: false,
882                    unused_import_bindings: FxHashSet::default(),
883                    type_referenced_import_bindings: vec![],
884                    value_referenced_import_bindings: vec![],
885                })
886                .collect::<Vec<_>>();
887            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
888
889            // Create module info with a file-level suppression for unused files
890            let modules = vec![ModuleInfo {
891                file_id: FileId(1),
892                exports: vec![],
893                imports: vec![],
894                re_exports: vec![],
895                dynamic_imports: vec![],
896                dynamic_import_patterns: vec![],
897                require_calls: vec![],
898                member_accesses: vec![],
899                whole_object_uses: vec![],
900                has_cjs_exports: false,
901                content_hash: 0,
902                suppressions: vec![Suppression {
903                    line: 0,
904                    comment_line: 1,
905                    kind: Some(IssueKind::UnusedFile),
906                }],
907                unused_import_bindings: vec![],
908                type_referenced_import_bindings: vec![],
909                value_referenced_import_bindings: vec![],
910                line_offsets: vec![],
911                complexity: vec![],
912                flag_uses: vec![],
913                class_heritage: vec![],
914                local_type_declarations: Vec::new(),
915                public_signature_type_references: Vec::new(),
916            }];
917
918            let rules = RulesConfig {
919                unused_files: Severity::Error,
920                ..RulesConfig::default()
921            };
922            let config = make_config_with_rules(rules);
923
924            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
925
926            // The suppression should prevent utils.ts from being reported as unused
927            // (it would normally be unused since only entry.ts is an entry point).
928            // Note: unused_files also checks if the file exists on disk, so it
929            // may still be filtered out. The key is the suppression path is exercised.
930            assert!(
931                !results
932                    .unused_files
933                    .iter()
934                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
935                "suppressed file should not appear in unused_files"
936            );
937        }
938    }
939}