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