Skip to main content

fallow_core/analyze/
mod.rs

1mod boundary;
2pub mod feature_flags;
3mod iconify;
4mod package_json_utils;
5mod predicates;
6mod re_export_cycles;
7mod security;
8mod unused_catalog;
9mod unused_deps;
10mod unused_exports;
11mod unused_files;
12mod unused_members;
13mod unused_overrides;
14
15#[cfg(test)]
16pub(crate) use unused_deps::matches_virtual_prefix;
17
18/// Human-readable title for a security catalogue category id, for the CLI
19/// renderer. Re-exported so the `fallow security` command can label a
20/// `TaintedSink` finding without reaching into the private `security` module.
21pub use security::catalogue_title as security_catalogue_title;
22
23use rustc_hash::{FxHashMap, FxHashSet};
24
25use fallow_config::{PackageJson, ResolvedConfig, Severity};
26
27use crate::discover::FileId;
28use crate::extract::ModuleInfo;
29use crate::graph::ModuleGraph;
30use crate::resolve::ResolvedModule;
31use fallow_types::output_dead_code::{
32    BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
33    EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
34    ReExportCycleFinding, TestOnlyDependencyFinding, TypeOnlyDependencyFinding,
35    UnlistedDependencyFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
36    UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
37    UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
38    UnusedExportFinding, UnusedFileFinding, UnusedOptionalDependencyFinding, UnusedTypeFinding,
39};
40
41use crate::results::{AnalysisResults, CircularDependency, CircularDependencyEdge};
42use crate::suppress::IssueKind;
43
44use re_export_cycles::find_re_export_cycles;
45#[expect(
46    deprecated,
47    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
48)]
49use unused_catalog::{
50    find_empty_catalog_groups, find_unresolved_catalog_references, find_unused_catalog_entries,
51    gather_pnpm_catalog_state,
52};
53#[expect(
54    deprecated,
55    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
56)]
57use unused_deps::{
58    find_test_only_dependencies, find_type_only_dependencies, find_unlisted_dependencies,
59    find_unresolved_imports, find_unused_dependencies,
60};
61#[expect(
62    deprecated,
63    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
64)]
65use unused_exports::{
66    collect_export_usages, find_private_type_leaks, find_unused_exports,
67    suppress_signature_backing_types,
68};
69#[expect(
70    deprecated,
71    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
72)]
73use unused_files::find_unused_files;
74use unused_members::find_unused_members_with_public_api_entry_points;
75#[expect(
76    deprecated,
77    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
78)]
79use unused_overrides::{
80    find_misconfigured_dependency_overrides, find_unused_dependency_overrides,
81    gather_pnpm_override_state,
82};
83
84/// Pre-computed line offset tables indexed by `FileId`, built during parse and
85/// carried through the cache. Eliminates redundant file reads during analysis.
86#[doc(hidden)]
87pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
88
89/// Convert a byte offset to (line, col) using pre-computed line offsets.
90/// Falls back to `(1, byte_offset)` when no line table is available.
91#[doc(hidden)]
92pub fn byte_offset_to_line_col(
93    line_offsets_map: &LineOffsetsMap<'_>,
94    file_id: FileId,
95    byte_offset: u32,
96) -> (u32, u32) {
97    line_offsets_map
98        .get(&file_id)
99        .map_or((1, byte_offset), |offsets| {
100            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
101        })
102}
103
104fn cycle_edge_line_col(
105    graph: &ModuleGraph,
106    line_offsets_map: &LineOffsetsMap<'_>,
107    cycle: &[FileId],
108    edge_index: usize,
109) -> Option<(u32, u32)> {
110    if cycle.is_empty() {
111        return None;
112    }
113
114    let from = cycle[edge_index];
115    let to = cycle[(edge_index + 1) % cycle.len()];
116    graph
117        .find_import_span_start(from, to)
118        .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
119}
120
121fn is_circular_dependency_suppressed(
122    graph: &ModuleGraph,
123    line_offsets_map: &LineOffsetsMap<'_>,
124    suppressions: &crate::suppress::SuppressionContext<'_>,
125    cycle: &[FileId],
126) -> bool {
127    if cycle
128        .iter()
129        .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
130    {
131        return true;
132    }
133
134    let mut line_suppressed = false;
135    for edge_index in 0..cycle.len() {
136        let from = cycle[edge_index];
137        if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
138            && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
139        {
140            line_suppressed = true;
141        }
142    }
143    line_suppressed
144}
145
146/// Read source content from disk, returning empty string on failure.
147/// Only used for LSP Code Lens reference resolution where the referencing
148/// file may not be in the line offsets map.
149fn read_source(path: &std::path::Path) -> String {
150    std::fs::read_to_string(path).unwrap_or_default()
151}
152
153/// Check whether any two files in a cycle belong to different workspace packages.
154/// Uses longest-prefix-match to assign each file to a workspace root.
155/// Files outside all workspace roots (e.g., root-level shared code) are ignored —
156/// only cycles between two distinct named workspaces are flagged.
157fn is_cross_package_cycle(
158    files: &[std::path::PathBuf],
159    workspaces: &[fallow_config::WorkspaceInfo],
160) -> bool {
161    let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
162        workspaces
163            .iter()
164            .map(|w| w.root.as_path())
165            .filter(|root| path.starts_with(root))
166            .max_by_key(|root| root.components().count())
167    };
168
169    let mut seen_workspace: Option<&std::path::Path> = None;
170    for file in files {
171        if let Some(ws) = find_workspace(file) {
172            match &seen_workspace {
173                None => seen_workspace = Some(ws),
174                Some(prev) if *prev != ws => return true,
175                _ => {}
176            }
177        }
178    }
179    false
180}
181
182fn public_workspace_roots<'a>(
183    public_packages: &[String],
184    workspaces: &'a [fallow_config::WorkspaceInfo],
185) -> Vec<&'a std::path::Path> {
186    if public_packages.is_empty() || workspaces.is_empty() {
187        return Vec::new();
188    }
189
190    workspaces
191        .iter()
192        .filter(|ws| {
193            public_packages.iter().any(|pattern| {
194                ws.name == *pattern
195                    || globset::Glob::new(pattern)
196                        .ok()
197                        .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
198            })
199        })
200        .map(|ws| ws.root.as_path())
201        .collect()
202}
203
204fn graph_path_to_file_id(graph: &ModuleGraph) -> FxHashMap<std::path::PathBuf, FileId> {
205    let mut path_to_file_id = FxHashMap::default();
206    for module in &graph.modules {
207        path_to_file_id.insert(module.path.clone(), module.file_id);
208        if let Ok(canonical) = dunce::canonicalize(&module.path) {
209            path_to_file_id.insert(canonical, module.file_id);
210        }
211    }
212    path_to_file_id
213}
214
215fn add_package_public_api_entry_points(
216    public_api_entry_points: &mut FxHashSet<FileId>,
217    path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
218    package_root: &std::path::Path,
219    package_json: &PackageJson,
220    canonical_project_root: &std::path::Path,
221) {
222    if package_json.private.unwrap_or(false) {
223        return;
224    }
225
226    for entry in package_json.entry_points() {
227        let Some(entry_point) = crate::discover::resolve_entry_path(
228            package_root,
229            &entry,
230            canonical_project_root,
231            crate::discover::EntryPointSource::PackageJsonExports,
232        ) else {
233            continue;
234        };
235
236        if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
237            dunce::canonicalize(&entry_point.path)
238                .ok()
239                .and_then(|canonical| path_to_file_id.get(&canonical).copied())
240        }) {
241            public_api_entry_points.insert(file_id);
242        }
243    }
244}
245
246fn is_source_index_under_package(path: &std::path::Path, package_root: &std::path::Path) -> bool {
247    let Ok(relative) = path.strip_prefix(package_root) else {
248        return false;
249    };
250
251    if !matches!(
252        relative.components().next(),
253        Some(std::path::Component::Normal(segment)) if segment == "src"
254    ) {
255        return false;
256    }
257
258    path.file_stem()
259        .and_then(|stem| stem.to_str())
260        .is_some_and(|stem| stem == "index")
261}
262
263fn add_exportless_package_source_indexes(
264    public_api_entry_points: &mut FxHashSet<FileId>,
265    graph: &ModuleGraph,
266    package_root: &std::path::Path,
267    package_json: &PackageJson,
268) {
269    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
270        return;
271    }
272
273    let mut roots = vec![package_root.to_path_buf()];
274    if let Ok(canonical) = dunce::canonicalize(package_root) {
275        roots.push(canonical);
276    }
277
278    for module in &graph.modules {
279        if roots
280            .iter()
281            .any(|root| is_source_index_under_package(&module.path, root))
282        {
283            public_api_entry_points.insert(module.file_id);
284        }
285    }
286}
287
288fn public_api_package_entry_points(
289    graph: &ModuleGraph,
290    config: &ResolvedConfig,
291    root_pkg: Option<&PackageJson>,
292    workspaces: &[fallow_config::WorkspaceInfo],
293) -> FxHashSet<FileId> {
294    let mut public_api_entry_points = FxHashSet::default();
295    let path_to_file_id = graph_path_to_file_id(graph);
296    let canonical_project_root =
297        dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
298
299    if let Some(pkg) = root_pkg {
300        add_package_public_api_entry_points(
301            &mut public_api_entry_points,
302            &path_to_file_id,
303            &config.root,
304            pkg,
305            &canonical_project_root,
306        );
307        add_exportless_package_source_indexes(
308            &mut public_api_entry_points,
309            graph,
310            &config.root,
311            pkg,
312        );
313    }
314
315    for workspace in workspaces {
316        let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) else {
317            continue;
318        };
319        add_package_public_api_entry_points(
320            &mut public_api_entry_points,
321            &path_to_file_id,
322            &workspace.root,
323            &pkg,
324            &canonical_project_root,
325        );
326        add_exportless_package_source_indexes(
327            &mut public_api_entry_points,
328            graph,
329            &workspace.root,
330            &pkg,
331        );
332    }
333
334    public_api_entry_points
335}
336
337fn find_circular_dependencies(
338    graph: &ModuleGraph,
339    line_offsets_map: &LineOffsetsMap<'_>,
340    suppressions: &crate::suppress::SuppressionContext<'_>,
341    workspaces: &[fallow_config::WorkspaceInfo],
342) -> Vec<CircularDependency> {
343    let cycles = graph.find_cycles();
344    let mut dependencies: Vec<CircularDependency> = cycles
345        .into_iter()
346        .filter_map(|cycle| {
347            if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
348                return None;
349            }
350
351            // One anchor per hop in cycle order: `edges[i]` is the import in
352            // `cycle[i]` pointing to `cycle[i + 1]`. Always populated for every
353            // hop (fallback `(1, 0)` if the span is somehow missing) so
354            // `edges.len() == files.len()` regardless of URL-resolvability on
355            // the consumer side. The LSP renders one squiggly per edge.
356            let edges: Vec<CircularDependencyEdge> = (0..cycle.len())
357                .map(|edge_index| {
358                    let from = cycle[edge_index];
359                    let (line, col) =
360                        cycle_edge_line_col(graph, line_offsets_map, &cycle, edge_index)
361                            .unwrap_or((1, 0));
362                    CircularDependencyEdge {
363                        path: graph.modules[from.0 as usize].path.clone(),
364                        line,
365                        col,
366                    }
367                })
368                .collect();
369
370            let files: Vec<std::path::PathBuf> =
371                edges.iter().map(|edge| edge.path.clone()).collect();
372            let length = files.len();
373            // Top-level `line`/`col` remain the first hop's anchor for
374            // backward compatibility with consumers that predate `edges`.
375            let (line, col) = edges.first().map_or((1, 0), |edge| (edge.line, edge.col));
376            Some(CircularDependency {
377                files,
378                length,
379                line,
380                col,
381                edges,
382                is_cross_package: false,
383            })
384        })
385        .collect();
386
387    if !workspaces.is_empty() {
388        for dep in &mut dependencies {
389            dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
390        }
391    }
392
393    dependencies
394}
395
396/// Thin wrapper around [`find_circular_dependencies`] that gates on
397/// `Severity::Off` and wraps the bare results in typed envelopes.
398/// Extracted from the rayon-join tree to keep nesting under the clippy
399/// `excessive_nesting` threshold (7).
400fn run_circular_dep_detector(
401    graph: &ModuleGraph,
402    config: &ResolvedConfig,
403    line_offsets_by_file: &LineOffsetsMap<'_>,
404    suppressions: &crate::suppress::SuppressionContext<'_>,
405    workspaces: &[fallow_config::WorkspaceInfo],
406) -> Vec<CircularDependencyFinding> {
407    if config.rules.circular_dependencies == Severity::Off {
408        return Vec::new();
409    }
410    find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
411        .into_iter()
412        .map(CircularDependencyFinding::with_actions)
413        .collect()
414}
415
416/// Thin wrapper around [`re_export_cycles::find_re_export_cycles`] that gates
417/// on `Severity::Off`. Extracted alongside [`run_circular_dep_detector`].
418fn run_re_export_cycle_detector(
419    graph: &ModuleGraph,
420    config: &ResolvedConfig,
421    suppressions: &crate::suppress::SuppressionContext<'_>,
422) -> Vec<ReExportCycleFinding> {
423    if config.rules.re_export_cycle == Severity::Off {
424        return Vec::new();
425    }
426    find_re_export_cycles(graph, suppressions)
427}
428
429/// Collect export usage counts for Code Lens (LSP feature). Skipped in CLI
430/// mode since the field is `#[serde(skip)]` in all output formats.
431fn run_export_usages_collector(
432    graph: &ModuleGraph,
433    line_offsets_by_file: &LineOffsetsMap<'_>,
434    collect_usages: bool,
435) -> Vec<crate::results::ExportUsage> {
436    if collect_usages {
437        collect_export_usages(graph, line_offsets_by_file)
438    } else {
439        Vec::new()
440    }
441}
442
443/// Collect every package name declared across the root `package.json` and each
444/// workspace `package.json`. This is the dependency universe the plugin system
445/// activates on, reused by the framework-scoped security catalogue rows (#861) to
446/// gate a row on the active framework. Missing or malformed manifests contribute
447/// nothing (a framework row simply stays inert), matching the conservative
448/// false-negatives-over-false-positives posture.
449fn collect_declared_dependency_names(
450    config: &ResolvedConfig,
451    root_pkg: Option<&PackageJson>,
452    workspaces: &[fallow_config::WorkspaceInfo],
453) -> FxHashSet<String> {
454    let mut deps: FxHashSet<String> = FxHashSet::default();
455    if let Some(pkg) = root_pkg {
456        deps.extend(pkg.all_dependency_names());
457    }
458    for ws in workspaces {
459        if ws.root == config.root {
460            continue; // already covered by root_pkg
461        }
462        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
463            deps.extend(pkg.all_dependency_names());
464        }
465    }
466    deps
467}
468
469/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
470#[expect(
471    deprecated,
472    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
473)]
474#[deprecated(
475    since = "2.76.0",
476    note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
477)]
478#[expect(
479    clippy::too_many_lines,
480    reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
481)]
482pub fn find_dead_code_full(
483    graph: &ModuleGraph,
484    config: &ResolvedConfig,
485    resolved_modules: &[ResolvedModule],
486    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
487    workspaces: &[fallow_config::WorkspaceInfo],
488    modules: &[ModuleInfo],
489    collect_usages: bool,
490) -> AnalysisResults {
491    let _span = tracing::info_span!("find_dead_code").entered();
492
493    let suppressions = crate::suppress::SuppressionContext::new(modules);
494
495    let line_offsets_by_file: LineOffsetsMap<'_> = modules
496        .iter()
497        .filter(|m| !m.line_offsets.is_empty())
498        .map(|m| (m.file_id, m.line_offsets.as_slice()))
499        .collect();
500
501    let pkg_path = config.root.join("package.json");
502    let pkg = PackageJson::load(&pkg_path).ok();
503    let public_api_entry_points =
504        public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
505
506    let iconify_referenced =
507        iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
508    let augmented_plugin_result;
509    let plugin_result = if iconify_referenced.is_empty() {
510        plugin_result
511    } else {
512        let mut owned = plugin_result.cloned().unwrap_or_default();
513        owned.referenced_dependencies.extend(iconify_referenced);
514        augmented_plugin_result = owned;
515        Some(&augmented_plugin_result)
516    };
517
518    let mut user_class_members = config.used_class_members.clone();
519    if let Some(plugin_result) = plugin_result {
520        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
521    }
522
523    let virtual_prefixes: Vec<&str> = plugin_result
524        .map(|pr| {
525            pr.virtual_module_prefixes
526                .iter()
527                .map(String::as_str)
528                .collect()
529        })
530        .unwrap_or_default();
531    let generated_patterns: Vec<&str> = plugin_result
532        .map(|pr| {
533            pr.generated_import_patterns
534                .iter()
535                .map(String::as_str)
536                .collect()
537        })
538        .unwrap_or_default();
539    let generated_type_prefixes: Vec<&str> = plugin_result
540        .map(|pr| {
541            pr.generated_type_import_prefixes
542                .iter()
543                .map(String::as_str)
544                .collect()
545        })
546        .unwrap_or_default();
547
548    let (
549        (unused_files, export_results),
550        (
551            (member_results, dependency_results),
552            (
553                (unresolved_imports, duplicate_exports),
554                (boundary_violations, (circular_dependencies, (re_export_cycles, export_usages))),
555            ),
556        ),
557    ) = rayon::join(
558        || {
559            rayon::join(
560                || {
561                    if config.rules.unused_files != Severity::Off {
562                        find_unused_files(graph, &suppressions)
563                            .into_iter()
564                            .map(UnusedFileFinding::with_actions)
565                            .collect::<Vec<_>>()
566                    } else {
567                        Vec::new()
568                    }
569                },
570                || {
571                    let mut results = AnalysisResults::default();
572                    if config.rules.unused_exports != Severity::Off
573                        || config.rules.unused_types != Severity::Off
574                        || config.rules.private_type_leaks != Severity::Off
575                    {
576                        let (exports, types, stale_expected) = find_unused_exports(
577                            graph,
578                            modules,
579                            config,
580                            plugin_result,
581                            &suppressions,
582                            &line_offsets_by_file,
583                        );
584                        if config.rules.unused_exports != Severity::Off {
585                            results.unused_exports = exports
586                                .into_iter()
587                                .map(UnusedExportFinding::with_actions)
588                                .collect();
589                        }
590                        if config.rules.unused_types != Severity::Off {
591                            let mut typed = types;
592                            suppress_signature_backing_types(&mut typed, graph, modules);
593                            results.unused_types = typed
594                                .into_iter()
595                                .map(UnusedTypeFinding::with_actions)
596                                .collect();
597                        }
598                        if config.rules.private_type_leaks != Severity::Off {
599                            results.private_type_leaks = find_private_type_leaks(
600                                graph,
601                                modules,
602                                config,
603                                &suppressions,
604                                &line_offsets_by_file,
605                            )
606                            .into_iter()
607                            .map(PrivateTypeLeakFinding::with_actions)
608                            .collect();
609                        }
610                        if config.rules.stale_suppressions != Severity::Off {
611                            results.stale_suppressions.extend(stale_expected);
612                        }
613                    }
614                    results
615                },
616            )
617        },
618        || {
619            rayon::join(
620                || {
621                    rayon::join(
622                        || {
623                            let mut results = AnalysisResults::default();
624                            if config.rules.unused_enum_members != Severity::Off
625                                || config.rules.unused_class_members != Severity::Off
626                            {
627                                let (enum_members, class_members) =
628                                    find_unused_members_with_public_api_entry_points(
629                                        graph,
630                                        resolved_modules,
631                                        modules,
632                                        &suppressions,
633                                        &line_offsets_by_file,
634                                        &user_class_members,
635                                        &config.ignore_decorators,
636                                        &public_api_entry_points,
637                                    );
638                                if config.rules.unused_enum_members != Severity::Off {
639                                    results.unused_enum_members = enum_members
640                                        .into_iter()
641                                        .map(UnusedEnumMemberFinding::with_actions)
642                                        .collect();
643                                }
644                                if config.rules.unused_class_members != Severity::Off {
645                                    results.unused_class_members = class_members
646                                        .into_iter()
647                                        .map(UnusedClassMemberFinding::with_actions)
648                                        .collect();
649                                }
650                            }
651                            results
652                        },
653                        || {
654                            let mut results = AnalysisResults::default();
655                            if let Some(ref pkg) = pkg {
656                                if config.rules.unused_dependencies != Severity::Off
657                                    || config.rules.unused_dev_dependencies != Severity::Off
658                                    || config.rules.unused_optional_dependencies != Severity::Off
659                                {
660                                    let (deps, dev_deps, optional_deps) = find_unused_dependencies(
661                                        graph,
662                                        pkg,
663                                        config,
664                                        plugin_result,
665                                        workspaces,
666                                    );
667                                    if config.rules.unused_dependencies != Severity::Off {
668                                        results.unused_dependencies = deps
669                                            .into_iter()
670                                            .map(UnusedDependencyFinding::with_actions)
671                                            .collect();
672                                    }
673                                    if config.rules.unused_dev_dependencies != Severity::Off {
674                                        results.unused_dev_dependencies = dev_deps
675                                            .into_iter()
676                                            .map(UnusedDevDependencyFinding::with_actions)
677                                            .collect();
678                                    }
679                                    if config.rules.unused_optional_dependencies != Severity::Off {
680                                        results.unused_optional_dependencies = optional_deps
681                                            .into_iter()
682                                            .map(UnusedOptionalDependencyFinding::with_actions)
683                                            .collect();
684                                    }
685                                }
686
687                                if config.rules.unlisted_dependencies != Severity::Off {
688                                    results.unlisted_dependencies = find_unlisted_dependencies(
689                                        graph,
690                                        pkg,
691                                        config,
692                                        workspaces,
693                                        plugin_result,
694                                        resolved_modules,
695                                        &line_offsets_by_file,
696                                    )
697                                    .into_iter()
698                                    .map(UnlistedDependencyFinding::with_actions)
699                                    .collect();
700                                }
701
702                                if config.production {
703                                    results.type_only_dependencies =
704                                        find_type_only_dependencies(graph, pkg, config, workspaces)
705                                            .into_iter()
706                                            .map(TypeOnlyDependencyFinding::with_actions)
707                                            .collect();
708                                }
709
710                                if !config.production
711                                    && config.rules.test_only_dependencies != Severity::Off
712                                {
713                                    results.test_only_dependencies =
714                                        find_test_only_dependencies(graph, pkg, config, workspaces)
715                                            .into_iter()
716                                            .map(TestOnlyDependencyFinding::with_actions)
717                                            .collect();
718                                }
719                            }
720                            results
721                        },
722                    )
723                },
724                || {
725                    rayon::join(
726                        || {
727                            rayon::join(
728                                || {
729                                    if config.rules.unresolved_imports != Severity::Off
730                                        && !resolved_modules.is_empty()
731                                    {
732                                        find_unresolved_imports(
733                                            resolved_modules,
734                                            config,
735                                            &suppressions,
736                                            &virtual_prefixes,
737                                            &generated_patterns,
738                                            &generated_type_prefixes,
739                                            &line_offsets_by_file,
740                                        )
741                                        .into_iter()
742                                        .map(UnresolvedImportFinding::with_actions)
743                                        .collect::<Vec<_>>()
744                                    } else {
745                                        Vec::new()
746                                    }
747                                },
748                                || {
749                                    if config.rules.duplicate_exports != Severity::Off {
750                                        let duplicate_exports =
751                                            if let Some(plugin_result) = plugin_result {
752                                                unused_exports::find_duplicate_exports_with_plugins(
753                                                    graph,
754                                                    config,
755                                                    &suppressions,
756                                                    &line_offsets_by_file,
757                                                    Some(plugin_result),
758                                                    resolved_modules,
759                                                )
760                                            } else {
761                                                unused_exports::find_duplicate_exports(
762                                                    graph,
763                                                    config,
764                                                    &suppressions,
765                                                    &line_offsets_by_file,
766                                                    resolved_modules,
767                                                )
768                                            };
769                                        duplicate_exports
770                                            .into_iter()
771                                            .map(DuplicateExportFinding::with_actions)
772                                            .collect::<Vec<_>>()
773                                    } else {
774                                        Vec::new()
775                                    }
776                                },
777                            )
778                        },
779                        || {
780                            rayon::join(
781                                || {
782                                    if config.rules.boundary_violation != Severity::Off
783                                        && !config.boundaries.is_empty()
784                                    {
785                                        boundary::find_boundary_violations(
786                                            graph,
787                                            config,
788                                            &suppressions,
789                                            &line_offsets_by_file,
790                                        )
791                                        .into_iter()
792                                        .map(BoundaryViolationFinding::with_actions)
793                                        .collect::<Vec<_>>()
794                                    } else {
795                                        Vec::new()
796                                    }
797                                },
798                                || {
799                                    rayon::join(
800                                        || {
801                                            run_circular_dep_detector(
802                                                graph,
803                                                config,
804                                                &line_offsets_by_file,
805                                                &suppressions,
806                                                workspaces,
807                                            )
808                                        },
809                                        || {
810                                            rayon::join(
811                                                || {
812                                                    run_re_export_cycle_detector(
813                                                        graph,
814                                                        config,
815                                                        &suppressions,
816                                                    )
817                                                },
818                                                || {
819                                                    run_export_usages_collector(
820                                                        graph,
821                                                        &line_offsets_by_file,
822                                                        collect_usages,
823                                                    )
824                                                },
825                                            )
826                                        },
827                                    )
828                                },
829                            )
830                        },
831                    )
832                },
833            )
834        },
835    );
836
837    let mut results = AnalysisResults {
838        unused_files,
839        unused_exports: export_results.unused_exports,
840        unused_types: export_results.unused_types,
841        private_type_leaks: export_results.private_type_leaks,
842        stale_suppressions: export_results.stale_suppressions,
843        unused_enum_members: member_results.unused_enum_members,
844        unused_class_members: member_results.unused_class_members,
845        unused_dependencies: dependency_results.unused_dependencies,
846        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
847        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
848        unlisted_dependencies: dependency_results.unlisted_dependencies,
849        type_only_dependencies: dependency_results.type_only_dependencies,
850        test_only_dependencies: dependency_results.test_only_dependencies,
851        unresolved_imports,
852        duplicate_exports,
853        boundary_violations,
854        circular_dependencies,
855        re_export_cycles,
856        export_usages,
857        ..AnalysisResults::default()
858    };
859
860    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
861    if !public_roots.is_empty() {
862        results.unused_exports.retain(|e| {
863            !public_roots
864                .iter()
865                .any(|root| e.export.path.starts_with(root))
866        });
867        results.unused_types.retain(|e| {
868            !public_roots
869                .iter()
870                .any(|root| e.export.path.starts_with(root))
871        });
872        results.unused_enum_members.retain(|e| {
873            !public_roots
874                .iter()
875                .any(|root| e.member.path.starts_with(root))
876        });
877        results.unused_class_members.retain(|e| {
878            !public_roots
879                .iter()
880                .any(|root| e.member.path.starts_with(root))
881        });
882    }
883
884    if config.rules.security_client_server_leak != Severity::Off {
885        let (security_findings, stats) =
886            security::find_security_findings(graph, modules, &suppressions, &line_offsets_by_file);
887        results.security_findings = security_findings;
888        results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
889    }
890
891    if config.rules.security_sink != Severity::Off {
892        let categories = config.security.categories.as_ref();
893        let filter = security::CategoryFilter::new(
894            categories.and_then(|c| c.include.clone()),
895            categories.and_then(|c| c.exclude.clone()),
896        );
897        // Framework-scoped catalogue rows (#861) gate on the active framework via
898        // the project's declared dependency set: the same dependency universe the
899        // plugin system activates on (root package.json + every workspace
900        // package.json). Built once here and passed to the detector.
901        let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
902        let (sink_findings, sink_stats) = security::find_tainted_sinks(
903            graph,
904            modules,
905            &suppressions,
906            &line_offsets_by_file,
907            &filter,
908            &declared_deps,
909            &config.root,
910        );
911        results.security_findings.extend(sink_findings);
912        results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
913    }
914
915    // Reachability-weighted ranking (issue #860): order security candidates so
916    // those reachable from a runtime/application entry point with a wider
917    // blast radius surface above isolated helpers/scripts. Reuses the existing
918    // graph reachability + reverse-dep fan-in; pairs optionally with boundary
919    // crossings already computed this run. Pure graph-side glue + output order.
920    if !results.security_findings.is_empty() {
921        let boundary_anchor_paths: rustc_hash::FxHashSet<std::path::PathBuf> = results
922            .boundary_violations
923            .iter()
924            .flat_map(|b| [b.violation.from_path.clone(), b.violation.to_path.clone()])
925            .collect();
926        security::rank_security_findings(
927            graph,
928            &boundary_anchor_paths,
929            &mut results.security_findings,
930        );
931    }
932
933    if config.rules.stale_suppressions != Severity::Off {
934        results
935            .stale_suppressions
936            .extend(suppressions.find_stale(graph, config));
937    }
938    results.suppression_count = suppressions.used_count();
939    results.active_suppressions = suppressions.all_suppressions(graph);
940
941    let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
942    let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
943    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
944    if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
945        && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
946    {
947        if need_unused_catalogs {
948            results.unused_catalog_entries = find_unused_catalog_entries(&state)
949                .into_iter()
950                .map(UnusedCatalogEntryFinding::with_actions)
951                .collect();
952        }
953        if need_empty_catalog_groups {
954            results.empty_catalog_groups = find_empty_catalog_groups(&state)
955                .into_iter()
956                .map(EmptyCatalogGroupFinding::with_actions)
957                .collect();
958        }
959        if need_unresolved_refs {
960            results.unresolved_catalog_references = find_unresolved_catalog_references(
961                &state,
962                &config.compiled_ignore_catalog_references,
963                &config.root,
964            )
965            .into_iter()
966            .map(UnresolvedCatalogReferenceFinding::with_actions)
967            .collect();
968        }
969    }
970
971    let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
972    let need_misconfigured_overrides =
973        config.rules.misconfigured_dependency_overrides != Severity::Off;
974    if (need_unused_overrides || need_misconfigured_overrides)
975        && let Some(state) = gather_pnpm_override_state(config, workspaces)
976    {
977        if need_unused_overrides {
978            results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
979                .into_iter()
980                .map(UnusedDependencyOverrideFinding::with_actions)
981                .collect();
982        }
983        if need_misconfigured_overrides {
984            results.misconfigured_dependency_overrides =
985                find_misconfigured_dependency_overrides(&state, config)
986                    .into_iter()
987                    .map(MisconfiguredDependencyOverrideFinding::with_actions)
988                    .collect();
989        }
990    }
991
992    results.sort();
993
994    results
995}
996
997#[cfg(test)]
998#[expect(
999    deprecated,
1000    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
1001)]
1002mod tests {
1003    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
1004
1005    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
1006        let offsets = compute_line_offsets(source);
1007        byte_offset_to_line_col(&offsets, byte_offset)
1008    }
1009
1010    #[test]
1011    fn compute_offsets_empty() {
1012        assert_eq!(compute_line_offsets(""), vec![0]);
1013    }
1014
1015    #[test]
1016    fn compute_offsets_single_line() {
1017        assert_eq!(compute_line_offsets("hello"), vec![0]);
1018    }
1019
1020    #[test]
1021    fn compute_offsets_multiline() {
1022        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1023    }
1024
1025    #[test]
1026    fn compute_offsets_trailing_newline() {
1027        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
1028    }
1029
1030    #[test]
1031    fn compute_offsets_crlf() {
1032        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1033    }
1034
1035    #[test]
1036    fn compute_offsets_consecutive_newlines() {
1037        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1038    }
1039
1040    #[test]
1041    fn byte_offset_empty_source() {
1042        assert_eq!(line_col("", 0), (1, 0));
1043    }
1044
1045    #[test]
1046    fn byte_offset_single_line_start() {
1047        assert_eq!(line_col("hello", 0), (1, 0));
1048    }
1049
1050    #[test]
1051    fn byte_offset_single_line_middle() {
1052        assert_eq!(line_col("hello", 4), (1, 4));
1053    }
1054
1055    #[test]
1056    fn byte_offset_multiline_start_of_line2() {
1057        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1058    }
1059
1060    #[test]
1061    fn byte_offset_multiline_middle_of_line3() {
1062        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1063    }
1064
1065    #[test]
1066    fn byte_offset_at_newline_boundary() {
1067        assert_eq!(line_col("line1\nline2", 5), (1, 5));
1068    }
1069
1070    #[test]
1071    fn byte_offset_multibyte_utf8() {
1072        let source = "hi\n\u{1F600}x";
1073        assert_eq!(line_col(source, 3), (2, 0));
1074        assert_eq!(line_col(source, 7), (2, 4));
1075    }
1076
1077    #[test]
1078    fn byte_offset_multibyte_accented_chars() {
1079        let source = "caf\u{00E9}\nbar";
1080        assert_eq!(line_col(source, 6), (2, 0));
1081        assert_eq!(line_col(source, 3), (1, 3));
1082    }
1083
1084    #[test]
1085    fn byte_offset_via_map_fallback() {
1086        use super::*;
1087        let map: LineOffsetsMap<'_> = FxHashMap::default();
1088        assert_eq!(
1089            super::byte_offset_to_line_col(&map, FileId(99), 42),
1090            (1, 42)
1091        );
1092    }
1093
1094    #[test]
1095    fn byte_offset_via_map_lookup() {
1096        use super::*;
1097        let offsets = compute_line_offsets("abc\ndef\nghi");
1098        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1099        map.insert(FileId(0), &offsets);
1100        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1101    }
1102
1103    mod orchestration {
1104        use super::super::*;
1105        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1106        use std::path::PathBuf;
1107
1108        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1109            find_dead_code_full(graph, config, &[], None, &[], &[], false)
1110        }
1111
1112        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1113            FallowConfig {
1114                rules,
1115                ..Default::default()
1116            }
1117            .resolve(
1118                PathBuf::from("/tmp/orchestration-test"),
1119                OutputFormat::Human,
1120                1,
1121                true,
1122                true,
1123                None,
1124            )
1125        }
1126
1127        #[test]
1128        fn find_dead_code_all_rules_off_returns_empty() {
1129            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1130            use crate::graph::ModuleGraph;
1131            use crate::resolve::ResolvedModule;
1132            use rustc_hash::FxHashSet;
1133
1134            let files = vec![DiscoveredFile {
1135                id: FileId(0),
1136                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1137                size_bytes: 100,
1138            }];
1139            let entry_points = vec![EntryPoint {
1140                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1141                source: EntryPointSource::ManualEntry,
1142            }];
1143            let resolved = vec![ResolvedModule {
1144                file_id: FileId(0),
1145                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1146                exports: vec![],
1147                re_exports: vec![],
1148                resolved_imports: vec![],
1149                resolved_dynamic_imports: vec![],
1150                resolved_dynamic_patterns: vec![],
1151                member_accesses: vec![],
1152                whole_object_uses: vec![],
1153                has_cjs_exports: false,
1154                has_angular_component_template_url: false,
1155                unused_import_bindings: FxHashSet::default(),
1156                type_referenced_import_bindings: vec![],
1157                value_referenced_import_bindings: vec![],
1158                namespace_object_aliases: vec![],
1159            }];
1160            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1161
1162            let rules = RulesConfig {
1163                unused_files: Severity::Off,
1164                unused_exports: Severity::Off,
1165                unused_types: Severity::Off,
1166                private_type_leaks: Severity::Off,
1167                unused_dependencies: Severity::Off,
1168                unused_dev_dependencies: Severity::Off,
1169                unused_optional_dependencies: Severity::Off,
1170                unused_enum_members: Severity::Off,
1171                unused_class_members: Severity::Off,
1172                unresolved_imports: Severity::Off,
1173                unlisted_dependencies: Severity::Off,
1174                duplicate_exports: Severity::Off,
1175                type_only_dependencies: Severity::Off,
1176                circular_dependencies: Severity::Off,
1177                re_export_cycle: Severity::Off,
1178                test_only_dependencies: Severity::Off,
1179                boundary_violation: Severity::Off,
1180                coverage_gaps: Severity::Off,
1181                feature_flags: Severity::Off,
1182                stale_suppressions: Severity::Off,
1183                unused_catalog_entries: Severity::Off,
1184                empty_catalog_groups: Severity::Off,
1185                unresolved_catalog_references: Severity::Off,
1186                unused_dependency_overrides: Severity::Off,
1187                misconfigured_dependency_overrides: Severity::Off,
1188                security_client_server_leak: Severity::Off,
1189                security_sink: Severity::Off,
1190            };
1191            let config = make_config_with_rules(rules);
1192            let results = find_dead_code(&graph, &config);
1193
1194            assert!(results.unused_files.is_empty());
1195            assert!(results.unused_exports.is_empty());
1196            assert!(results.unused_types.is_empty());
1197            assert!(results.unused_dependencies.is_empty());
1198            assert!(results.unused_dev_dependencies.is_empty());
1199            assert!(results.unused_optional_dependencies.is_empty());
1200            assert!(results.unused_enum_members.is_empty());
1201            assert!(results.unused_class_members.is_empty());
1202            assert!(results.unresolved_imports.is_empty());
1203            assert!(results.unlisted_dependencies.is_empty());
1204            assert!(results.duplicate_exports.is_empty());
1205            assert!(results.circular_dependencies.is_empty());
1206            assert!(results.export_usages.is_empty());
1207        }
1208
1209        #[test]
1210        fn find_dead_code_full_collect_usages_flag() {
1211            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1212            use crate::extract::{ExportName, VisibilityTag};
1213            use crate::graph::{ExportSymbol, ModuleGraph};
1214            use crate::resolve::ResolvedModule;
1215            use oxc_span::Span;
1216            use rustc_hash::FxHashSet;
1217
1218            let files = vec![DiscoveredFile {
1219                id: FileId(0),
1220                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1221                size_bytes: 100,
1222            }];
1223            let entry_points = vec![EntryPoint {
1224                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1225                source: EntryPointSource::ManualEntry,
1226            }];
1227            let resolved = vec![ResolvedModule {
1228                file_id: FileId(0),
1229                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1230                exports: vec![],
1231                re_exports: vec![],
1232                resolved_imports: vec![],
1233                resolved_dynamic_imports: vec![],
1234                resolved_dynamic_patterns: vec![],
1235                member_accesses: vec![],
1236                whole_object_uses: vec![],
1237                has_cjs_exports: false,
1238                has_angular_component_template_url: false,
1239                unused_import_bindings: FxHashSet::default(),
1240                type_referenced_import_bindings: vec![],
1241                value_referenced_import_bindings: vec![],
1242                namespace_object_aliases: vec![],
1243            }];
1244            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1245            graph.modules[0].exports = vec![ExportSymbol {
1246                name: ExportName::Named("myExport".to_string()),
1247                is_type_only: false,
1248                is_side_effect_used: false,
1249                visibility: VisibilityTag::None,
1250                span: Span::new(10, 30),
1251                references: vec![],
1252                members: vec![],
1253            }];
1254
1255            let rules = RulesConfig::default();
1256            let config = make_config_with_rules(rules);
1257
1258            let results_no_collect = find_dead_code_full(
1259                &graph,
1260                &config,
1261                &[],
1262                None,
1263                &[],
1264                &[],
1265                false, // collect_usages = false
1266            );
1267            assert!(
1268                results_no_collect.export_usages.is_empty(),
1269                "export_usages should be empty when collect_usages is false"
1270            );
1271
1272            let results_with_collect = find_dead_code_full(
1273                &graph,
1274                &config,
1275                &[],
1276                None,
1277                &[],
1278                &[],
1279                true, // collect_usages = true
1280            );
1281            assert!(
1282                !results_with_collect.export_usages.is_empty(),
1283                "export_usages should be populated when collect_usages is true"
1284            );
1285            assert_eq!(
1286                results_with_collect.export_usages[0].export_name,
1287                "myExport"
1288            );
1289        }
1290
1291        #[test]
1292        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1293            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1294            use crate::graph::ModuleGraph;
1295            use crate::resolve::ResolvedModule;
1296            use rustc_hash::FxHashSet;
1297
1298            let files = vec![DiscoveredFile {
1299                id: FileId(0),
1300                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1301                size_bytes: 100,
1302            }];
1303            let entry_points = vec![EntryPoint {
1304                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1305                source: EntryPointSource::ManualEntry,
1306            }];
1307            let resolved = vec![ResolvedModule {
1308                file_id: FileId(0),
1309                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1310                exports: vec![],
1311                re_exports: vec![],
1312                resolved_imports: vec![],
1313                resolved_dynamic_imports: vec![],
1314                resolved_dynamic_patterns: vec![],
1315                member_accesses: vec![],
1316                whole_object_uses: vec![],
1317                has_cjs_exports: false,
1318                has_angular_component_template_url: false,
1319                unused_import_bindings: FxHashSet::default(),
1320                type_referenced_import_bindings: vec![],
1321                value_referenced_import_bindings: vec![],
1322                namespace_object_aliases: vec![],
1323            }];
1324            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1325            let config = make_config_with_rules(RulesConfig::default());
1326
1327            let results = find_dead_code(&graph, &config);
1328            assert!(results.unused_exports.is_empty());
1329        }
1330
1331        #[test]
1332        fn suppressions_built_from_modules() {
1333            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1334            use crate::extract::ModuleInfo;
1335            use crate::graph::ModuleGraph;
1336            use crate::resolve::ResolvedModule;
1337            use crate::suppress::{IssueKind, Suppression};
1338            use rustc_hash::FxHashSet;
1339
1340            let files = vec![
1341                DiscoveredFile {
1342                    id: FileId(0),
1343                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1344                    size_bytes: 100,
1345                },
1346                DiscoveredFile {
1347                    id: FileId(1),
1348                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1349                    size_bytes: 100,
1350                },
1351            ];
1352            let entry_points = vec![EntryPoint {
1353                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1354                source: EntryPointSource::ManualEntry,
1355            }];
1356            let resolved = files
1357                .iter()
1358                .map(|f| ResolvedModule {
1359                    file_id: f.id,
1360                    path: f.path.clone(),
1361                    exports: vec![],
1362                    re_exports: vec![],
1363                    resolved_imports: vec![],
1364                    resolved_dynamic_imports: vec![],
1365                    resolved_dynamic_patterns: vec![],
1366                    member_accesses: vec![],
1367                    whole_object_uses: vec![],
1368                    has_cjs_exports: false,
1369                    has_angular_component_template_url: false,
1370                    unused_import_bindings: FxHashSet::default(),
1371                    type_referenced_import_bindings: vec![],
1372                    value_referenced_import_bindings: vec![],
1373                    namespace_object_aliases: vec![],
1374                })
1375                .collect::<Vec<_>>();
1376            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1377
1378            let modules = vec![ModuleInfo {
1379                file_id: FileId(1),
1380                exports: vec![],
1381                imports: vec![],
1382                re_exports: vec![],
1383                dynamic_imports: vec![],
1384                dynamic_import_patterns: vec![],
1385                require_calls: vec![],
1386                package_path_references: vec![],
1387                member_accesses: vec![],
1388                whole_object_uses: vec![],
1389                has_cjs_exports: false,
1390                has_angular_component_template_url: false,
1391                content_hash: 0,
1392                suppressions: vec![Suppression {
1393                    line: 0,
1394                    comment_line: 1,
1395                    kind: Some(IssueKind::UnusedFile),
1396                }],
1397                unknown_suppression_kinds: vec![],
1398                unused_import_bindings: vec![],
1399                type_referenced_import_bindings: vec![],
1400                value_referenced_import_bindings: vec![],
1401                line_offsets: vec![],
1402                complexity: vec![],
1403                flag_uses: vec![],
1404                class_heritage: vec![],
1405                injection_tokens: vec![],
1406                local_type_declarations: Vec::new(),
1407                public_signature_type_references: Vec::new(),
1408                namespace_object_aliases: Vec::new(),
1409                iconify_prefixes: Vec::new(),
1410                iconify_icon_names: Vec::new(),
1411                auto_import_candidates: Vec::new(),
1412                directives: Vec::new(),
1413                security_sinks: Vec::new(),
1414                security_sinks_skipped: 0,
1415                tainted_bindings: Vec::new(),
1416                sanitized_sink_args: Vec::new(),
1417            }];
1418
1419            let rules = RulesConfig {
1420                unused_files: Severity::Error,
1421                ..RulesConfig::default()
1422            };
1423            let config = make_config_with_rules(rules);
1424
1425            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1426
1427            assert!(
1428                !results.unused_files.iter().any(|f| f
1429                    .file
1430                    .path
1431                    .to_string_lossy()
1432                    .contains("utils.ts")),
1433                "suppressed file should not appear in unused_files"
1434            );
1435        }
1436    }
1437}