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