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