Skip to main content

fallow_core/analyze/
mod.rs

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