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    results.suppression_count = suppressions.used_count();
354
355    // Sort all result arrays for deterministic output ordering.
356    // Parallel collection and FxHashMap iteration don't guarantee order,
357    // so without sorting the same project can produce different orderings.
358    results.sort();
359
360    results
361}
362
363#[cfg(test)]
364mod tests {
365    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
366
367    // Helper: compute line offsets from source and convert byte offset
368    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
369        let offsets = compute_line_offsets(source);
370        byte_offset_to_line_col(&offsets, byte_offset)
371    }
372
373    // ── compute_line_offsets ─────────────────────────────────────
374
375    #[test]
376    fn compute_offsets_empty() {
377        assert_eq!(compute_line_offsets(""), vec![0]);
378    }
379
380    #[test]
381    fn compute_offsets_single_line() {
382        assert_eq!(compute_line_offsets("hello"), vec![0]);
383    }
384
385    #[test]
386    fn compute_offsets_multiline() {
387        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
388    }
389
390    #[test]
391    fn compute_offsets_trailing_newline() {
392        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
393    }
394
395    #[test]
396    fn compute_offsets_crlf() {
397        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
398    }
399
400    #[test]
401    fn compute_offsets_consecutive_newlines() {
402        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
403    }
404
405    // ── byte_offset_to_line_col ─────────────────────────────────
406
407    #[test]
408    fn byte_offset_empty_source() {
409        assert_eq!(line_col("", 0), (1, 0));
410    }
411
412    #[test]
413    fn byte_offset_single_line_start() {
414        assert_eq!(line_col("hello", 0), (1, 0));
415    }
416
417    #[test]
418    fn byte_offset_single_line_middle() {
419        assert_eq!(line_col("hello", 4), (1, 4));
420    }
421
422    #[test]
423    fn byte_offset_multiline_start_of_line2() {
424        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
425    }
426
427    #[test]
428    fn byte_offset_multiline_middle_of_line3() {
429        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
430    }
431
432    #[test]
433    fn byte_offset_at_newline_boundary() {
434        assert_eq!(line_col("line1\nline2", 5), (1, 5));
435    }
436
437    #[test]
438    fn byte_offset_multibyte_utf8() {
439        let source = "hi\n\u{1F600}x";
440        assert_eq!(line_col(source, 3), (2, 0));
441        assert_eq!(line_col(source, 7), (2, 4));
442    }
443
444    #[test]
445    fn byte_offset_multibyte_accented_chars() {
446        let source = "caf\u{00E9}\nbar";
447        assert_eq!(line_col(source, 6), (2, 0));
448        assert_eq!(line_col(source, 3), (1, 3));
449    }
450
451    #[test]
452    fn byte_offset_via_map_fallback() {
453        use super::*;
454        let map: LineOffsetsMap<'_> = FxHashMap::default();
455        assert_eq!(
456            super::byte_offset_to_line_col(&map, FileId(99), 42),
457            (1, 42)
458        );
459    }
460
461    #[test]
462    fn byte_offset_via_map_lookup() {
463        use super::*;
464        let offsets = compute_line_offsets("abc\ndef\nghi");
465        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
466        map.insert(FileId(0), &offsets);
467        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
468    }
469
470    // ── find_dead_code orchestration ──────────────────────────────
471
472    mod orchestration {
473        use super::super::*;
474        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
475        use std::path::PathBuf;
476
477        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
478            find_dead_code_full(graph, config, &[], None, &[], &[], false)
479        }
480
481        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
482            FallowConfig {
483                rules,
484                ..Default::default()
485            }
486            .resolve(
487                PathBuf::from("/tmp/orchestration-test"),
488                OutputFormat::Human,
489                1,
490                true,
491                true,
492            )
493        }
494
495        #[test]
496        fn find_dead_code_all_rules_off_returns_empty() {
497            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
498            use crate::graph::ModuleGraph;
499            use crate::resolve::ResolvedModule;
500            use rustc_hash::FxHashSet;
501
502            let files = vec![DiscoveredFile {
503                id: FileId(0),
504                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
505                size_bytes: 100,
506            }];
507            let entry_points = vec![EntryPoint {
508                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
509                source: EntryPointSource::ManualEntry,
510            }];
511            let resolved = vec![ResolvedModule {
512                file_id: FileId(0),
513                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
514                exports: vec![],
515                re_exports: vec![],
516                resolved_imports: vec![],
517                resolved_dynamic_imports: vec![],
518                resolved_dynamic_patterns: vec![],
519                member_accesses: vec![],
520                whole_object_uses: vec![],
521                has_cjs_exports: false,
522                unused_import_bindings: FxHashSet::default(),
523                type_referenced_import_bindings: vec![],
524                value_referenced_import_bindings: vec![],
525            }];
526            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
527
528            let rules = RulesConfig {
529                unused_files: Severity::Off,
530                unused_exports: Severity::Off,
531                unused_types: Severity::Off,
532                private_type_leaks: Severity::Off,
533                unused_dependencies: Severity::Off,
534                unused_dev_dependencies: Severity::Off,
535                unused_optional_dependencies: Severity::Off,
536                unused_enum_members: Severity::Off,
537                unused_class_members: Severity::Off,
538                unresolved_imports: Severity::Off,
539                unlisted_dependencies: Severity::Off,
540                duplicate_exports: Severity::Off,
541                type_only_dependencies: Severity::Off,
542                circular_dependencies: Severity::Off,
543                test_only_dependencies: Severity::Off,
544                boundary_violation: Severity::Off,
545                coverage_gaps: Severity::Off,
546                feature_flags: Severity::Off,
547                stale_suppressions: Severity::Off,
548            };
549            let config = make_config_with_rules(rules);
550            let results = find_dead_code(&graph, &config);
551
552            assert!(results.unused_files.is_empty());
553            assert!(results.unused_exports.is_empty());
554            assert!(results.unused_types.is_empty());
555            assert!(results.unused_dependencies.is_empty());
556            assert!(results.unused_dev_dependencies.is_empty());
557            assert!(results.unused_optional_dependencies.is_empty());
558            assert!(results.unused_enum_members.is_empty());
559            assert!(results.unused_class_members.is_empty());
560            assert!(results.unresolved_imports.is_empty());
561            assert!(results.unlisted_dependencies.is_empty());
562            assert!(results.duplicate_exports.is_empty());
563            assert!(results.circular_dependencies.is_empty());
564            assert!(results.export_usages.is_empty());
565        }
566
567        #[test]
568        fn find_dead_code_full_collect_usages_flag() {
569            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
570            use crate::extract::{ExportName, VisibilityTag};
571            use crate::graph::{ExportSymbol, ModuleGraph};
572            use crate::resolve::ResolvedModule;
573            use oxc_span::Span;
574            use rustc_hash::FxHashSet;
575
576            let files = vec![DiscoveredFile {
577                id: FileId(0),
578                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
579                size_bytes: 100,
580            }];
581            let entry_points = vec![EntryPoint {
582                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
583                source: EntryPointSource::ManualEntry,
584            }];
585            let resolved = vec![ResolvedModule {
586                file_id: FileId(0),
587                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
588                exports: vec![],
589                re_exports: vec![],
590                resolved_imports: vec![],
591                resolved_dynamic_imports: vec![],
592                resolved_dynamic_patterns: vec![],
593                member_accesses: vec![],
594                whole_object_uses: vec![],
595                has_cjs_exports: false,
596                unused_import_bindings: FxHashSet::default(),
597                type_referenced_import_bindings: vec![],
598                value_referenced_import_bindings: vec![],
599            }];
600            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
601            graph.modules[0].exports = vec![ExportSymbol {
602                name: ExportName::Named("myExport".to_string()),
603                is_type_only: false,
604                visibility: VisibilityTag::None,
605                span: Span::new(10, 30),
606                references: vec![],
607                members: vec![],
608            }];
609
610            let rules = RulesConfig::default();
611            let config = make_config_with_rules(rules);
612
613            // Without collect_usages
614            let results_no_collect = find_dead_code_full(
615                &graph,
616                &config,
617                &[],
618                None,
619                &[],
620                &[],
621                false, // collect_usages = false
622            );
623            assert!(
624                results_no_collect.export_usages.is_empty(),
625                "export_usages should be empty when collect_usages is false"
626            );
627
628            // With collect_usages
629            let results_with_collect = find_dead_code_full(
630                &graph,
631                &config,
632                &[],
633                None,
634                &[],
635                &[],
636                true, // collect_usages = true
637            );
638            assert!(
639                !results_with_collect.export_usages.is_empty(),
640                "export_usages should be populated when collect_usages is true"
641            );
642            assert_eq!(
643                results_with_collect.export_usages[0].export_name,
644                "myExport"
645            );
646        }
647
648        #[test]
649        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
650            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
651            use crate::graph::ModuleGraph;
652            use crate::resolve::ResolvedModule;
653            use rustc_hash::FxHashSet;
654
655            let files = vec![DiscoveredFile {
656                id: FileId(0),
657                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
658                size_bytes: 100,
659            }];
660            let entry_points = vec![EntryPoint {
661                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
662                source: EntryPointSource::ManualEntry,
663            }];
664            let resolved = vec![ResolvedModule {
665                file_id: FileId(0),
666                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
667                exports: vec![],
668                re_exports: vec![],
669                resolved_imports: vec![],
670                resolved_dynamic_imports: vec![],
671                resolved_dynamic_patterns: vec![],
672                member_accesses: vec![],
673                whole_object_uses: vec![],
674                has_cjs_exports: false,
675                unused_import_bindings: FxHashSet::default(),
676                type_referenced_import_bindings: vec![],
677                value_referenced_import_bindings: vec![],
678            }];
679            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
680            let config = make_config_with_rules(RulesConfig::default());
681
682            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
683            let results = find_dead_code(&graph, &config);
684            // The entry point export analysis is skipped, so these should be empty
685            assert!(results.unused_exports.is_empty());
686        }
687
688        #[test]
689        fn suppressions_built_from_modules() {
690            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
691            use crate::extract::ModuleInfo;
692            use crate::graph::ModuleGraph;
693            use crate::resolve::ResolvedModule;
694            use crate::suppress::{IssueKind, Suppression};
695            use rustc_hash::FxHashSet;
696
697            let files = vec![
698                DiscoveredFile {
699                    id: FileId(0),
700                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
701                    size_bytes: 100,
702                },
703                DiscoveredFile {
704                    id: FileId(1),
705                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
706                    size_bytes: 100,
707                },
708            ];
709            let entry_points = vec![EntryPoint {
710                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
711                source: EntryPointSource::ManualEntry,
712            }];
713            let resolved = files
714                .iter()
715                .map(|f| ResolvedModule {
716                    file_id: f.id,
717                    path: f.path.clone(),
718                    exports: vec![],
719                    re_exports: vec![],
720                    resolved_imports: vec![],
721                    resolved_dynamic_imports: vec![],
722                    resolved_dynamic_patterns: vec![],
723                    member_accesses: vec![],
724                    whole_object_uses: vec![],
725                    has_cjs_exports: false,
726                    unused_import_bindings: FxHashSet::default(),
727                    type_referenced_import_bindings: vec![],
728                    value_referenced_import_bindings: vec![],
729                })
730                .collect::<Vec<_>>();
731            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
732
733            // Create module info with a file-level suppression for unused files
734            let modules = vec![ModuleInfo {
735                file_id: FileId(1),
736                exports: vec![],
737                imports: vec![],
738                re_exports: vec![],
739                dynamic_imports: vec![],
740                dynamic_import_patterns: vec![],
741                require_calls: vec![],
742                member_accesses: vec![],
743                whole_object_uses: vec![],
744                has_cjs_exports: false,
745                content_hash: 0,
746                suppressions: vec![Suppression {
747                    line: 0,
748                    comment_line: 1,
749                    kind: Some(IssueKind::UnusedFile),
750                }],
751                unused_import_bindings: vec![],
752                type_referenced_import_bindings: vec![],
753                value_referenced_import_bindings: vec![],
754                line_offsets: vec![],
755                complexity: vec![],
756                flag_uses: vec![],
757                class_heritage: vec![],
758                local_type_declarations: Vec::new(),
759                public_signature_type_references: Vec::new(),
760            }];
761
762            let rules = RulesConfig {
763                unused_files: Severity::Error,
764                ..RulesConfig::default()
765            };
766            let config = make_config_with_rules(rules);
767
768            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
769
770            // The suppression should prevent utils.ts from being reported as unused
771            // (it would normally be unused since only entry.ts is an entry point).
772            // Note: unused_files also checks if the file exists on disk, so it
773            // may still be filtered out. The key is the suppression path is exercised.
774            assert!(
775                !results
776                    .unused_files
777                    .iter()
778                    .any(|f| f.path.to_string_lossy().contains("utils.ts")),
779                "suppressed file should not appear in unused_files"
780            );
781        }
782    }
783}