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