Skip to main content

fallow_core/analyze/
mod.rs

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